smoodly 0.0.15 → 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 +238 -36
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/custom-section.d.ts +10 -0
- package/dist/admin/custom-section.js +10 -0
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +6 -6
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +322 -62
- package/dist/admin/editor/GlobalInspector.d.ts +24 -0
- package/dist/admin/editor/GlobalInspector.js +13 -0
- package/dist/admin/editor/InsertPicker.d.ts +6 -5
- package/dist/admin/editor/InsertPicker.js +6 -4
- package/dist/admin/editor/InspectorFields.d.ts +21 -0
- package/dist/admin/editor/InspectorFields.js +22 -0
- package/dist/admin/editor/Outline.d.ts +0 -1
- package/dist/admin/editor/Outline.js +3 -3
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/global-edit.d.ts +39 -0
- package/dist/admin/editor/global-edit.js +124 -0
- package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
- package/dist/admin/editor/useGlobalEdit.js +18 -0
- 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 +20 -8
- 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} +29 -14
- package/dist/admin/index.d.ts +3 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +242 -56
- package/dist/admin/ops.d.ts +38 -6
- package/dist/admin/serialize.d.ts +18 -7
- package/dist/admin/serialize.js +27 -5
- package/dist/admin/shell/AdminApp.js +7 -7
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- 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/GlobalForm.js +258 -0
- package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
- package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
- package/dist/admin/shell/IconRail.js +14 -7
- package/dist/admin/shell/PagesList.js +4 -8
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- 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/supabase-auth.js +1 -1
- package/dist/admin/tree-ops.d.ts +10 -8
- package/dist/admin/tree-ops.js +28 -16
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +16 -1
- package/dist/admin/ui/primitives.js +20 -4
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +93 -13
- package/dist/admin/validate.d.ts +27 -2
- package/dist/admin/validate.js +47 -10
- package/dist/collections.d.ts +3 -9
- package/dist/collections.js +1 -2
- 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 +42 -17
- package/dist/entry-store.js +107 -40
- 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 +17 -13
- 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 +17 -5
- package/dist/site.js +36 -12
- package/dist/sql-space.js +1 -1
- package/dist/sql.js +19 -16
- package/dist/store.d.ts +45 -13
- package/dist/store.js +88 -12
- package/dist/styles.d.ts +27 -0
- package/dist/styles.js +60 -0
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +115 -39
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +90 -20
- package/dist/zones.d.ts +9 -7
- package/dist/zones.js +5 -5
- package/package.json +1 -1
- package/dist/admin/shared-items.d.ts +0 -9
- package/dist/admin/shell/SharedForm.js +0 -134
- package/dist/shared.d.ts +0 -46
- package/dist/shared.js +0 -36
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
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";
|
|
10
|
+
import { withDefaults } from "../field-defaults.js";
|
|
8
11
|
import { elementSchemas } from "../richtext-walk.js";
|
|
9
12
|
const ok = (data) => ({ ok: true, data });
|
|
13
|
+
/** A history row without its content (spec 2026-09-13 history §6). */
|
|
14
|
+
const summary = ({ id, savedAt }) => ({ id, savedAt });
|
|
10
15
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
11
16
|
/** Own-property, non-array plain object — same shape guard the tree checks
|
|
12
17
|
* below and `runAdminOp`'s envelope check both lean on. */
|
|
@@ -62,6 +67,8 @@ const classify = (e) => {
|
|
|
62
67
|
return err("blocked", message);
|
|
63
68
|
if (message.includes("cannot publish"))
|
|
64
69
|
return err("blocked", message);
|
|
70
|
+
if (message.includes("nothing published"))
|
|
71
|
+
return err("blocked", message);
|
|
65
72
|
if (message.includes("already taken"))
|
|
66
73
|
return err("conflict", message);
|
|
67
74
|
if (message.includes("already has a template"))
|
|
@@ -88,13 +95,15 @@ const classify = (e) => {
|
|
|
88
95
|
export function createAdminOps(deps) {
|
|
89
96
|
const { config, pages, entries, effects } = deps;
|
|
90
97
|
const href = deps.href ?? ((path) => path);
|
|
98
|
+
const by = deps.actor?.email ?? undefined;
|
|
91
99
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
92
100
|
// Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
|
|
93
|
-
const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {},
|
|
94
|
-
|
|
95
|
-
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;
|
|
96
105
|
/** One row per registration; null when materialization could not create it. */
|
|
97
|
-
const
|
|
106
|
+
const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
98
107
|
const fanOut = async (entryId) => {
|
|
99
108
|
if (!effects?.revalidate)
|
|
100
109
|
return;
|
|
@@ -103,28 +112,28 @@ export function createAdminOps(deps) {
|
|
|
103
112
|
effects.revalidate(entryTag(id));
|
|
104
113
|
for (const id of pageIds)
|
|
105
114
|
effects.revalidate(pageTag(id));
|
|
106
|
-
// A
|
|
115
|
+
// A global item's row is an entry like any other: when the item
|
|
107
116
|
// itself changes OR when something it REFERENCES does, the unit
|
|
108
|
-
// `
|
|
117
|
+
// `getSmoodlyGlobal` caches under `global:<name>` is stale too — and
|
|
109
118
|
// that unit is a layout, on every page. `affectedTargets` returns row
|
|
110
119
|
// ids, so resolve name → row id here. Once per fan-out, not memoized
|
|
111
120
|
// per ops instance: a row can be materialized between two calls, and
|
|
112
121
|
// an entry write is rare enough that one `entries.list` per
|
|
113
122
|
// registration beats reasoning about a stale cache. Last, so the
|
|
114
123
|
// existing entry-then-page order is untouched.
|
|
115
|
-
const registered = config.registry.
|
|
124
|
+
const registered = config.registry.globals ?? [];
|
|
116
125
|
if (registered.length === 0)
|
|
117
126
|
return;
|
|
118
127
|
const touched = new Set(entryIds);
|
|
119
128
|
for (const item of registered) {
|
|
120
129
|
const row = (await entries.list(item.name))[0];
|
|
121
130
|
if (row && touched.has(row.id))
|
|
122
|
-
effects.revalidate(
|
|
131
|
+
effects.revalidate(globalTag(item.name));
|
|
123
132
|
}
|
|
124
133
|
};
|
|
125
134
|
/** An asset's metadata (alt, title, caption, credit) is read wherever media
|
|
126
135
|
* resolves (spec 2026-09-08 §9): the entry and page units that reference the
|
|
127
|
-
* asset, directly or through an entry, the
|
|
136
|
+
* asset, directly or through an entry, the global items among those rows,
|
|
128
137
|
* and the list units of every referencing entry's collection. The walk is
|
|
129
138
|
* the entry fan-out's, seeded with the asset's id — which is no entry, so
|
|
130
139
|
* it gets no tag of its own. */
|
|
@@ -138,10 +147,10 @@ export function createAdminOps(deps) {
|
|
|
138
147
|
for (const id of pageIds)
|
|
139
148
|
effects.revalidate(pageTag(id));
|
|
140
149
|
const touched = new Set(users);
|
|
141
|
-
for (const item of config.registry.
|
|
150
|
+
for (const item of config.registry.globals ?? []) {
|
|
142
151
|
const row = (await entries.list(item.name))[0];
|
|
143
152
|
if (row && touched.has(row.id))
|
|
144
|
-
effects.revalidate(
|
|
153
|
+
effects.revalidate(globalTag(item.name));
|
|
145
154
|
}
|
|
146
155
|
for (const c of config.registry.collections ?? []) {
|
|
147
156
|
for (const id of users) {
|
|
@@ -200,9 +209,9 @@ export function createAdminOps(deps) {
|
|
|
200
209
|
return { title: sourceId, href: null };
|
|
201
210
|
return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
|
|
202
211
|
}
|
|
203
|
-
for (const item of config.registry.
|
|
212
|
+
for (const item of config.registry.globals ?? []) {
|
|
204
213
|
if (await entries.get(item.name, sourceId))
|
|
205
|
-
return { title: item.title, href: `${BASE}/
|
|
214
|
+
return { title: item.title, href: `${BASE}/globals/${encodeURIComponent(item.name)}` };
|
|
206
215
|
}
|
|
207
216
|
for (const c of config.registry.collections ?? []) {
|
|
208
217
|
const record = await entries.get(c.name, sourceId);
|
|
@@ -281,7 +290,7 @@ export function createAdminOps(deps) {
|
|
|
281
290
|
const created = await pages.createPage({
|
|
282
291
|
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
283
292
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
284
|
-
});
|
|
293
|
+
}, by);
|
|
285
294
|
expireTree();
|
|
286
295
|
return ok(created);
|
|
287
296
|
}),
|
|
@@ -293,7 +302,7 @@ export function createAdminOps(deps) {
|
|
|
293
302
|
return err("validation", "smoodly: malformed page tree.");
|
|
294
303
|
if (!(await pages.getPage(id)))
|
|
295
304
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
296
|
-
return ok({ versionId: (await pages.saveDraft(id, locale, tree)).id });
|
|
305
|
+
return ok({ versionId: (await pages.saveDraft(id, locale, tree, by)).id });
|
|
297
306
|
}),
|
|
298
307
|
publish: (id, locale) => guard(async () => {
|
|
299
308
|
const unsupported = supportedLocale(locale);
|
|
@@ -317,7 +326,11 @@ export function createAdminOps(deps) {
|
|
|
317
326
|
const schema = sectionFields(node.type);
|
|
318
327
|
if (!schema?.fields)
|
|
319
328
|
continue;
|
|
320
|
-
|
|
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) {
|
|
321
334
|
fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
|
|
322
335
|
}
|
|
323
336
|
}
|
|
@@ -330,12 +343,75 @@ export function createAdminOps(deps) {
|
|
|
330
343
|
if (fieldErrors.length > 0) {
|
|
331
344
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
332
345
|
}
|
|
333
|
-
const published = await pages.publish(id, locale);
|
|
346
|
+
const published = await pages.publish(id, locale, by);
|
|
334
347
|
// The parent's cached unit lists its published children.
|
|
335
348
|
expirePages([published.id, published.parentId]);
|
|
336
349
|
expireTree();
|
|
337
350
|
return ok(published);
|
|
338
351
|
}),
|
|
352
|
+
unpublish: (id, locale) => guard(async () => {
|
|
353
|
+
const unsupported = supportedLocale(locale);
|
|
354
|
+
if (unsupported)
|
|
355
|
+
return unsupported;
|
|
356
|
+
const record = await pages.getPage(id);
|
|
357
|
+
if (!record)
|
|
358
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
359
|
+
if (!record.locales[locale])
|
|
360
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
361
|
+
if (isHomeNode(record))
|
|
362
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
|
|
363
|
+
if (fixedNodeOf(record, config))
|
|
364
|
+
return owned(record, "it stays published");
|
|
365
|
+
const off = await pages.unpublish(id, locale, by);
|
|
366
|
+
// The parent's cached unit lists its published children; the tree listing shows status.
|
|
367
|
+
expirePages([off.id, off.parentId]);
|
|
368
|
+
expireTree();
|
|
369
|
+
return ok(off);
|
|
370
|
+
}),
|
|
371
|
+
discardDraft: (id, locale) => guard(async () => {
|
|
372
|
+
const unsupported = supportedLocale(locale);
|
|
373
|
+
if (unsupported)
|
|
374
|
+
return unsupported;
|
|
375
|
+
const record = await pages.getPage(id);
|
|
376
|
+
if (!record)
|
|
377
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
378
|
+
const row = record.locales[locale];
|
|
379
|
+
if (!row)
|
|
380
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
381
|
+
if (!row.publishedVersionId) {
|
|
382
|
+
return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
|
|
383
|
+
}
|
|
384
|
+
const restored = await pages.discardDraft(id, locale, by);
|
|
385
|
+
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
386
|
+
return ok(restored);
|
|
387
|
+
}),
|
|
388
|
+
listVersions: (id, locale) => guard(async () => {
|
|
389
|
+
const unsupported = supportedLocale(locale);
|
|
390
|
+
if (unsupported)
|
|
391
|
+
return unsupported;
|
|
392
|
+
if (!(await pages.getPage(id)))
|
|
393
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
394
|
+
return ok((await pages.listVersions(id, locale)).map(summary));
|
|
395
|
+
}),
|
|
396
|
+
restore: (id, locale, versionId) => guard(async () => {
|
|
397
|
+
const unsupported = supportedLocale(locale);
|
|
398
|
+
if (unsupported)
|
|
399
|
+
return unsupported;
|
|
400
|
+
const record = await pages.getPage(id);
|
|
401
|
+
if (!record)
|
|
402
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
403
|
+
if (!record.locales[locale])
|
|
404
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
405
|
+
if (record.template === null)
|
|
406
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is a folder — it has no versions.`);
|
|
407
|
+
const version = typeof versionId === "string" ? await pages.getVersion(versionId) : null;
|
|
408
|
+
if (!version || version.pageId !== id || version.locale !== locale) {
|
|
409
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
|
|
410
|
+
}
|
|
411
|
+
const restored = await pages.restore(id, locale, versionId, by);
|
|
412
|
+
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
413
|
+
return ok(restored);
|
|
414
|
+
}),
|
|
339
415
|
delete: (id) => guard(async () => {
|
|
340
416
|
const record = await pages.getPage(id);
|
|
341
417
|
if (!record)
|
|
@@ -364,7 +440,7 @@ export function createAdminOps(deps) {
|
|
|
364
440
|
const unsupported = supportedLocale(patch.locale);
|
|
365
441
|
if (unsupported)
|
|
366
442
|
return unsupported;
|
|
367
|
-
const renamed = await pages.rename(id, patch);
|
|
443
|
+
const renamed = await pages.rename(id, patch, by);
|
|
368
444
|
// Every descendant's path changed with this segment; the parent
|
|
369
445
|
// lists this node's title and path.
|
|
370
446
|
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
@@ -408,7 +484,7 @@ export function createAdminOps(deps) {
|
|
|
408
484
|
if (registration.schema.slug !== undefined) {
|
|
409
485
|
return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
|
|
410
486
|
}
|
|
411
|
-
const withTemplate = await pages.setTemplate(id, template);
|
|
487
|
+
const withTemplate = await pages.setTemplate(id, template, by);
|
|
412
488
|
expireTree();
|
|
413
489
|
return ok(withTemplate);
|
|
414
490
|
}),
|
|
@@ -419,7 +495,7 @@ export function createAdminOps(deps) {
|
|
|
419
495
|
if (!(await pages.getPage(id)))
|
|
420
496
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
421
497
|
// A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
|
|
422
|
-
const added = await pages.addLocale(id, locale, { from: options.from });
|
|
498
|
+
const added = await pages.addLocale(id, locale, { from: options.from }, by);
|
|
423
499
|
expireTree();
|
|
424
500
|
return ok(added);
|
|
425
501
|
}),
|
|
@@ -455,7 +531,9 @@ export function createAdminOps(deps) {
|
|
|
455
531
|
const parentLocales = record.parentId === null
|
|
456
532
|
? config.locales.supported
|
|
457
533
|
: Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
|
|
458
|
-
|
|
534
|
+
const paths = await entries.pathsOf(collection, id);
|
|
535
|
+
const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
|
|
536
|
+
return ok({ record, locale, fields, parentLocales, urls });
|
|
459
537
|
}),
|
|
460
538
|
create: (collection, input) => guard(async () => {
|
|
461
539
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -464,10 +542,7 @@ export function createAdminOps(deps) {
|
|
|
464
542
|
const descriptors = collectionFields(collection);
|
|
465
543
|
if (!descriptors)
|
|
466
544
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
467
|
-
const
|
|
468
|
-
if (fieldErrors.length > 0)
|
|
469
|
-
return err("validation", "Some fields need attention.", fieldErrors);
|
|
470
|
-
const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
|
|
545
|
+
const created = await entries.create(collection, withDefaults(input.fields, descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
|
|
471
546
|
expireCollection(collection);
|
|
472
547
|
return ok(created);
|
|
473
548
|
}),
|
|
@@ -478,10 +553,7 @@ export function createAdminOps(deps) {
|
|
|
478
553
|
const descriptors = collectionFields(collection);
|
|
479
554
|
if (!descriptors)
|
|
480
555
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
481
|
-
const
|
|
482
|
-
if (fieldErrors.length > 0)
|
|
483
|
-
return err("validation", "Some fields need attention.", fieldErrors);
|
|
484
|
-
const entry = await entries.update(collection, id, locale, fields);
|
|
556
|
+
const entry = await entries.update(collection, id, locale, fields, by);
|
|
485
557
|
await fanOut(id);
|
|
486
558
|
expireCollection(collection);
|
|
487
559
|
return ok(entry);
|
|
@@ -490,11 +562,62 @@ export function createAdminOps(deps) {
|
|
|
490
562
|
const unsupported = supportedLocale(locale);
|
|
491
563
|
if (unsupported)
|
|
492
564
|
return unsupported;
|
|
493
|
-
|
|
565
|
+
if (status === "published") {
|
|
566
|
+
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
567
|
+
const descriptors = collectionFields(collection);
|
|
568
|
+
if (!descriptors)
|
|
569
|
+
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
570
|
+
const record = await entries.get(collection, id);
|
|
571
|
+
if (!record)
|
|
572
|
+
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
573
|
+
if (!record.locales[locale])
|
|
574
|
+
return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
|
|
575
|
+
const fieldErrors = validateFields(entryFieldsIn(record, locale), descriptors, elementDescs);
|
|
576
|
+
if (fieldErrors.length > 0)
|
|
577
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
578
|
+
}
|
|
579
|
+
const entry = await entries.setStatus(collection, id, locale, status, by);
|
|
494
580
|
await fanOut(id);
|
|
495
581
|
expireCollection(collection);
|
|
496
582
|
return ok(entry);
|
|
497
583
|
}),
|
|
584
|
+
discardDraft: (collection, id, locale) => guard(async () => {
|
|
585
|
+
const unsupported = supportedLocale(locale);
|
|
586
|
+
if (unsupported)
|
|
587
|
+
return unsupported;
|
|
588
|
+
const restored = await entries.discardDraft(collection, id, locale, by);
|
|
589
|
+
await fanOut(id);
|
|
590
|
+
expireCollection(collection);
|
|
591
|
+
return ok(restored);
|
|
592
|
+
}),
|
|
593
|
+
listVersions: (collection, id, locale) => guard(async () => {
|
|
594
|
+
const unsupported = supportedLocale(locale);
|
|
595
|
+
if (unsupported)
|
|
596
|
+
return unsupported;
|
|
597
|
+
if (!(await entries.get(collection, id)))
|
|
598
|
+
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
599
|
+
return ok((await entries.listVersions(collection, id, locale)).map(summary));
|
|
600
|
+
}),
|
|
601
|
+
getVersion: (collection, id, versionId) => guard(async () => {
|
|
602
|
+
const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
|
|
603
|
+
if (!version || version.entryId !== id) {
|
|
604
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}".`);
|
|
605
|
+
}
|
|
606
|
+
return ok({ ...summary(version), fields: version.fields });
|
|
607
|
+
}),
|
|
608
|
+
restore: (collection, id, locale, versionId) => guard(async () => {
|
|
609
|
+
const unsupported = supportedLocale(locale);
|
|
610
|
+
if (unsupported)
|
|
611
|
+
return unsupported;
|
|
612
|
+
const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
|
|
613
|
+
if (!version || version.entryId !== id || version.locale !== locale) {
|
|
614
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
|
|
615
|
+
}
|
|
616
|
+
const restored = await entries.restore(collection, id, locale, versionId, by);
|
|
617
|
+
await fanOut(id);
|
|
618
|
+
expireCollection(collection);
|
|
619
|
+
return ok(restored);
|
|
620
|
+
}),
|
|
498
621
|
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
499
622
|
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
|
|
500
623
|
move: (collection, id, to) => guard(async () => {
|
|
@@ -512,7 +635,7 @@ export function createAdminOps(deps) {
|
|
|
512
635
|
const unsupported = supportedLocale(locale);
|
|
513
636
|
if (unsupported)
|
|
514
637
|
return unsupported;
|
|
515
|
-
const added = await entries.addLocale(collection, id, locale, { from: options.from });
|
|
638
|
+
const added = await entries.addLocale(collection, id, locale, { from: options.from }, by);
|
|
516
639
|
expireCollection(collection);
|
|
517
640
|
return ok(added);
|
|
518
641
|
}),
|
|
@@ -526,22 +649,22 @@ export function createAdminOps(deps) {
|
|
|
526
649
|
return ok(entry);
|
|
527
650
|
}),
|
|
528
651
|
},
|
|
529
|
-
|
|
652
|
+
globals: {
|
|
530
653
|
list: () => guard(async () => {
|
|
531
|
-
const records = await
|
|
532
|
-
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] }] : [])));
|
|
533
656
|
}),
|
|
534
657
|
get: (name, locale) => guard(async () => {
|
|
535
658
|
const unsupported = supportedLocale(locale);
|
|
536
659
|
if (unsupported)
|
|
537
660
|
return unsupported;
|
|
538
|
-
const item =
|
|
661
|
+
const item = globalItem(name);
|
|
539
662
|
if (!item)
|
|
540
|
-
return err("not_found", `smoodly: no
|
|
541
|
-
await
|
|
542
|
-
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);
|
|
543
666
|
if (!record?.locales[locale])
|
|
544
|
-
return err("not_found", `smoodly:
|
|
667
|
+
return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
|
|
545
668
|
const usage = await entries.referencesTo(name, record.id);
|
|
546
669
|
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
547
670
|
}),
|
|
@@ -549,17 +672,14 @@ export function createAdminOps(deps) {
|
|
|
549
672
|
const unsupported = supportedLocale(locale);
|
|
550
673
|
if (unsupported)
|
|
551
674
|
return unsupported;
|
|
552
|
-
const item =
|
|
675
|
+
const item = globalItem(name);
|
|
553
676
|
if (!item)
|
|
554
|
-
return err("not_found", `smoodly: no
|
|
555
|
-
const
|
|
556
|
-
if (fieldErrors.length > 0)
|
|
557
|
-
return err("validation", "Some fields need attention.", fieldErrors);
|
|
558
|
-
const record = await sharedRecord(name);
|
|
677
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
678
|
+
const record = await globalRecord(name);
|
|
559
679
|
if (!record)
|
|
560
|
-
return err("not_found", `smoodly:
|
|
561
|
-
const saved = await entries.update(name, record.id, locale, fields);
|
|
562
|
-
// fanOut expires `
|
|
680
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
681
|
+
const saved = await entries.update(name, record.id, locale, fields, by);
|
|
682
|
+
// fanOut expires `global:<name>` itself — this row IS the item's.
|
|
563
683
|
await fanOut(record.id);
|
|
564
684
|
return ok(saved);
|
|
565
685
|
}),
|
|
@@ -567,16 +687,82 @@ export function createAdminOps(deps) {
|
|
|
567
687
|
const unsupported = supportedLocale(locale);
|
|
568
688
|
if (unsupported)
|
|
569
689
|
return unsupported;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
690
|
+
const item = globalItem(name);
|
|
691
|
+
if (!item)
|
|
692
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
693
|
+
const record = await globalRecord(name);
|
|
573
694
|
if (!record)
|
|
574
|
-
return err("not_found", `smoodly:
|
|
575
|
-
|
|
695
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
696
|
+
if (status === "published") {
|
|
697
|
+
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
698
|
+
if (!record.locales[locale])
|
|
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);
|
|
705
|
+
if (fieldErrors.length > 0)
|
|
706
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
707
|
+
}
|
|
708
|
+
const changed = await entries.setStatus(name, record.id, locale, status, by);
|
|
576
709
|
// see save: the fan-out carries the item's own tag.
|
|
577
710
|
await fanOut(record.id);
|
|
578
711
|
return ok(changed);
|
|
579
712
|
}),
|
|
713
|
+
discardDraft: (name, locale) => guard(async () => {
|
|
714
|
+
const unsupported = supportedLocale(locale);
|
|
715
|
+
if (unsupported)
|
|
716
|
+
return unsupported;
|
|
717
|
+
if (!globalItem(name))
|
|
718
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
719
|
+
const record = await globalRecord(name);
|
|
720
|
+
if (!record)
|
|
721
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
722
|
+
const restored = await entries.discardDraft(name, record.id, locale, by);
|
|
723
|
+
await fanOut(record.id);
|
|
724
|
+
return ok(restored);
|
|
725
|
+
}),
|
|
726
|
+
listVersions: (name, locale) => guard(async () => {
|
|
727
|
+
const unsupported = supportedLocale(locale);
|
|
728
|
+
if (unsupported)
|
|
729
|
+
return unsupported;
|
|
730
|
+
if (!globalItem(name))
|
|
731
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
732
|
+
const record = await globalRecord(name);
|
|
733
|
+
if (!record)
|
|
734
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
735
|
+
return ok((await entries.listVersions(name, record.id, locale)).map(summary));
|
|
736
|
+
}),
|
|
737
|
+
getVersion: (name, versionId) => guard(async () => {
|
|
738
|
+
if (!globalItem(name))
|
|
739
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
740
|
+
const record = await globalRecord(name);
|
|
741
|
+
if (!record)
|
|
742
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
743
|
+
const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
|
|
744
|
+
if (!version || version.entryId !== record.id) {
|
|
745
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}".`);
|
|
746
|
+
}
|
|
747
|
+
return ok({ ...summary(version), fields: version.fields });
|
|
748
|
+
}),
|
|
749
|
+
restore: (name, locale, versionId) => guard(async () => {
|
|
750
|
+
const unsupported = supportedLocale(locale);
|
|
751
|
+
if (unsupported)
|
|
752
|
+
return unsupported;
|
|
753
|
+
if (!globalItem(name))
|
|
754
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
755
|
+
const record = await globalRecord(name);
|
|
756
|
+
if (!record)
|
|
757
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
758
|
+
const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
|
|
759
|
+
if (!version || version.entryId !== record.id || version.locale !== locale) {
|
|
760
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
|
|
761
|
+
}
|
|
762
|
+
const restored = await entries.restore(name, record.id, locale, versionId, by);
|
|
763
|
+
await fanOut(record.id);
|
|
764
|
+
return ok(restored);
|
|
765
|
+
}),
|
|
580
766
|
},
|
|
581
767
|
assets: {
|
|
582
768
|
list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -55,23 +55,34 @@ export type EntryGetResult = {
|
|
|
55
55
|
locale: string;
|
|
56
56
|
/** That locale's merged draft fields; null when the entry has no row in `locale`. */
|
|
57
57
|
fields: Record<string, unknown> | null;
|
|
58
|
+
/** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
|
|
59
|
+
urls: Record<string, string>;
|
|
58
60
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
59
61
|
parentLocales: string[];
|
|
60
62
|
};
|
|
61
|
-
export type
|
|
63
|
+
export type GlobalListItem = {
|
|
62
64
|
name: string;
|
|
63
65
|
record: EntryRecord;
|
|
64
66
|
};
|
|
65
|
-
export type
|
|
67
|
+
export type GlobalGetResult = {
|
|
66
68
|
name: string;
|
|
67
69
|
/** The node with ALL its locale rows (every supported one, by construction). */
|
|
68
70
|
record: EntryRecord;
|
|
69
71
|
locale: string;
|
|
70
|
-
/** That locale's merged draft fields — a visual item's
|
|
72
|
+
/** That locale's merged draft fields — a visual item's settings under `$settings`. */
|
|
71
73
|
fields: Record<string, unknown>;
|
|
72
74
|
/** Who links it: "used on N pages". */
|
|
73
75
|
usage: EntryUsage[];
|
|
74
76
|
};
|
|
77
|
+
/** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
|
|
78
|
+
export type VersionSummary = {
|
|
79
|
+
id: string;
|
|
80
|
+
savedAt: number;
|
|
81
|
+
};
|
|
82
|
+
/** A stored entry snapshot for the disabled form. */
|
|
83
|
+
export type EntryVersionResult = VersionSummary & {
|
|
84
|
+
fields: Record<string, unknown>;
|
|
85
|
+
};
|
|
75
86
|
/** One place an asset is used: the source row, the field, a label and an admin link. */
|
|
76
87
|
export type AssetUsageItem = {
|
|
77
88
|
sourceKind: string;
|
|
@@ -91,6 +102,10 @@ export type AdminOps = {
|
|
|
91
102
|
versionId: string;
|
|
92
103
|
}>>;
|
|
93
104
|
publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
105
|
+
/** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
|
|
106
|
+
unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
107
|
+
/** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
|
|
108
|
+
discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
94
109
|
delete(id: string): Promise<OpResult<null>>;
|
|
95
110
|
rename(id: string, patch: {
|
|
96
111
|
locale: string;
|
|
@@ -109,6 +124,10 @@ export type AdminOps = {
|
|
|
109
124
|
}): Promise<OpResult<PageRecord>>;
|
|
110
125
|
/** Deletes the locale's row, versions and paths. Never on fixed nodes or the site root. */
|
|
111
126
|
removeLocale(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
127
|
+
/** The locale's versions newest first, without their trees. */
|
|
128
|
+
listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
129
|
+
/** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
|
|
130
|
+
restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
|
|
112
131
|
};
|
|
113
132
|
entries: {
|
|
114
133
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
@@ -117,6 +136,8 @@ export type AdminOps = {
|
|
|
117
136
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
118
137
|
save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
119
138
|
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
139
|
+
/** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
|
|
140
|
+
discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
120
141
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
121
142
|
move(collection: string, id: string, to: {
|
|
122
143
|
parentId: string | null;
|
|
@@ -128,13 +149,24 @@ export type AdminOps = {
|
|
|
128
149
|
}): Promise<OpResult<EntryRecord>>;
|
|
129
150
|
/** Deletes the locale's row, versions and paths; fans out like a save. */
|
|
130
151
|
removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
152
|
+
/** Newest first; empty for a collection without `versions`. */
|
|
153
|
+
listVersions(collection: string, id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
154
|
+
/** One snapshot's fields; `not_found` unless it belongs to `id`. */
|
|
155
|
+
getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
156
|
+
/** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
|
|
157
|
+
restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
131
158
|
};
|
|
132
|
-
|
|
159
|
+
globals: {
|
|
133
160
|
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
134
|
-
list(): Promise<OpResult<
|
|
135
|
-
get(name: string, locale: string): Promise<OpResult<
|
|
161
|
+
list(): Promise<OpResult<GlobalListItem[]>>;
|
|
162
|
+
get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
|
|
136
163
|
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
137
164
|
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
165
|
+
/** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
|
|
166
|
+
discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
167
|
+
listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
168
|
+
getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
169
|
+
restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
138
170
|
};
|
|
139
171
|
assets: {
|
|
140
172
|
/** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
|