smoodly 0.0.4 → 0.0.6
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { plainToRichtext, richtextToPlain } from "../richtext.js";
|
|
4
|
+
import { Field, IconButton } from "../ui/primitives.js";
|
|
4
5
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
5
|
-
|
|
6
|
-
width: "100%", boxSizing: "border-box", border: "1px solid #d6d3e0", borderRadius: 8,
|
|
7
|
-
padding: "9px 10px", fontFamily: "inherit", fontSize: 13.5, background: "#fff", color: "#17141f",
|
|
8
|
-
};
|
|
9
|
-
export function FieldWidget({ descriptor: d, value, onChange, error, refOptions = [], fieldKey, }) {
|
|
6
|
+
export function FieldWidget({ descriptor: d, value, onChange, error, refOptions = [], fieldKey, prefix, }) {
|
|
10
7
|
const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
|
|
11
8
|
let control;
|
|
12
9
|
switch (d.type) {
|
|
13
10
|
case "text":
|
|
14
11
|
case "link":
|
|
15
12
|
case "slug":
|
|
16
|
-
case "date":
|
|
17
|
-
|
|
13
|
+
case "date": {
|
|
14
|
+
const input = (_jsx("input", { className: d.type === "slug" ? "sm-input sm-input--mono" : "sm-input", value: String(value ?? ""), onChange: (e) => onChange(e.target.value) }));
|
|
15
|
+
control = d.type === "slug" && prefix
|
|
16
|
+
? _jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), input] })
|
|
17
|
+
: input;
|
|
18
18
|
break;
|
|
19
|
+
}
|
|
19
20
|
case "number":
|
|
20
|
-
control = (_jsx("input", {
|
|
21
|
+
control = (_jsx("input", { className: "sm-input", type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
|
|
21
22
|
break;
|
|
22
23
|
case "select": {
|
|
23
24
|
const options = Array.isArray(d.options) ? d.options : [];
|
|
24
|
-
control = (_jsxs("select", {
|
|
25
|
+
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o, children: o }, o))] }));
|
|
25
26
|
break;
|
|
26
27
|
}
|
|
27
28
|
case "richtext":
|
|
28
|
-
control = (_jsx("textarea", {
|
|
29
|
+
control = (_jsx("textarea", { className: "sm-textarea", rows: 5, value: richtextToPlain(value), onChange: (e) => onChange(plainToRichtext(e.target.value)) }));
|
|
29
30
|
break;
|
|
30
31
|
case "image":
|
|
31
32
|
case "video":
|
|
32
33
|
case "file": {
|
|
33
34
|
const url = value?.url ?? "";
|
|
34
|
-
control = (_jsx("input", {
|
|
35
|
+
control = (_jsx("input", { className: "sm-input", placeholder: "https://\u2026", value: url, onChange: (e) => onChange(e.target.value ? { url: e.target.value } : undefined) }));
|
|
35
36
|
break;
|
|
36
37
|
}
|
|
37
38
|
case "ref":
|
|
38
|
-
control = (_jsxs("select", {
|
|
39
|
+
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: "\u2014" }), refOptions.map((o) => _jsx("option", { value: o.id, children: optionTitle(o) }, o.id))] }));
|
|
39
40
|
break;
|
|
40
41
|
case "refList": {
|
|
41
42
|
const ids = Array.isArray(value) ? value : [];
|
|
42
43
|
const byId = new Map(refOptions.map((o) => [o.id, o]));
|
|
43
|
-
control = (_jsxs("div", { children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6,
|
|
44
|
+
control = (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { className: "sm-input", style: { display: "flex", alignItems: "center", flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx(IconButton, { label: "Remove", size: 28, onClick: () => onChange(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${id}-${i}`))), _jsxs("select", { className: "sm-select", value: "", onChange: (e) => e.target.value && onChange([...ids, e.target.value]), children: [_jsx("option", { value: "", children: "Add\u2026" }), refOptions.filter((o) => !ids.includes(o.id)).map((o) => (_jsx("option", { value: o.id, children: optionTitle(o) }, o.id)))] })] }));
|
|
44
45
|
break;
|
|
45
46
|
}
|
|
46
47
|
default:
|
|
47
|
-
control = _jsx("textarea", {
|
|
48
|
+
control = _jsx("textarea", { className: "sm-textarea", disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
|
|
48
49
|
}
|
|
49
50
|
// A <label> activates its first labelable descendant, so it may only wrap a
|
|
50
51
|
// single control — around refList's rows it would turn every click into a
|
|
51
52
|
// press of the first row's remove button.
|
|
52
|
-
|
|
53
|
-
return (_jsxs(Wrapper, { style: { display: "block", marginBottom: 14 }, children: [_jsx("span", { style: { display: "block", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", opacity: 0.55, marginBottom: 6 }, children: label(fieldKey) }), control, error && _jsx("span", { style: { display: "block", color: "#b91c1c", fontSize: 12, marginTop: 4 }, children: error })] }));
|
|
53
|
+
return (_jsx(Field, { label: label(fieldKey), error: error, asDiv: d.type === "refList", children: control }));
|
|
54
54
|
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError } from "./ops.ts";
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult } from "./ops.ts";
|
|
2
2
|
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
|
+
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
|
|
3
4
|
export { makeClientOps } from "./client-ops.ts";
|
|
4
5
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
5
6
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
2
|
+
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
|
|
2
3
|
export { makeClientOps } from "./client-ops.js";
|
|
3
4
|
export { gateAdminOp } from "./auth.js";
|
|
4
5
|
export { supabaseAdminAuth } from "./supabase-auth.js";
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { serializeAdminConfig } from "../serialize.js";
|
|
3
3
|
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
|
+
import { AdminStyles } from "../ui/theme.js";
|
|
4
5
|
export function createSmoodlyAdmin(input) {
|
|
5
6
|
const registry = serializeAdminConfig(input.config);
|
|
6
7
|
async function AdminPage({ params }) {
|
|
7
8
|
const { segments = [] } = await params;
|
|
8
9
|
return _jsx(AdminApp, { registry: registry, segments: segments, op: input.op, auth: input.auth() });
|
|
9
10
|
}
|
|
11
|
+
/** The admin owns the viewport: the whole UI is a fixed three-column
|
|
12
|
+
* shell that scrolls internally, never the host document. The design
|
|
13
|
+
* system's tokens ride along in one <style> — the host app imports
|
|
14
|
+
* nothing (see ui/theme.tsx). */
|
|
10
15
|
function AdminLayout({ children }) {
|
|
11
|
-
return (
|
|
16
|
+
return (_jsxs("div", { className: "sm-admin", children: [_jsx(AdminStyles, {}), children] }));
|
|
12
17
|
}
|
|
13
18
|
return { AdminPage, AdminLayout };
|
|
14
19
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The Next face of the ops seam: ONE server-action body, gated by
|
|
3
3
|
// AdminAuth. The app's 'use server' file wraps this in its single
|
|
4
4
|
// exported async function.
|
|
5
|
-
import {
|
|
5
|
+
import { updateTag } from "next/cache";
|
|
6
6
|
import { draftMode } from "next/headers";
|
|
7
7
|
import { gateAdminOp } from "../auth.js";
|
|
8
8
|
import { createAdminOps, runAdminOp } from "../ops-impl.js";
|
|
@@ -11,8 +11,13 @@ export function createAdminOpHandler(deps) {
|
|
|
11
11
|
config: deps.config,
|
|
12
12
|
pages: deps.pages,
|
|
13
13
|
entries: deps.entries,
|
|
14
|
+
href: deps.href,
|
|
14
15
|
effects: {
|
|
15
|
-
|
|
16
|
+
// updateTag, not revalidateTag(tag, profile): the profile form is
|
|
17
|
+
// stale-while-revalidate, so the first request after publish still
|
|
18
|
+
// served the cached null (a 404) and only the second saw the page.
|
|
19
|
+
// updateTag expires now — Server Actions only, which this is.
|
|
20
|
+
revalidate: (tag) => updateTag(tag),
|
|
16
21
|
draft: {
|
|
17
22
|
enable: async () => (await draftMode()).enable(),
|
|
18
23
|
disable: async () => (await draftMode()).disable(),
|
package/dist/admin/ops-impl.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { EntryStore } from "../entry-store.ts";
|
|
|
3
3
|
import type { PageStore } from "../store.ts";
|
|
4
4
|
import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
|
|
5
5
|
export type AdminEffects = {
|
|
6
|
-
|
|
6
|
+
/** Expire one cache tag (`pageTag(id)` / `entryTag(id)`). */
|
|
7
|
+
revalidate?(tag: string): void;
|
|
7
8
|
draft?: {
|
|
8
9
|
enable(): void | Promise<void>;
|
|
9
10
|
disable(): void | Promise<void>;
|
|
@@ -14,6 +15,8 @@ export declare function createAdminOps(deps: {
|
|
|
14
15
|
pages: PageStore;
|
|
15
16
|
entries: EntryStore;
|
|
16
17
|
effects?: AdminEffects;
|
|
18
|
+
/** Locale-relative CMS path → site URL (the renderer's seam, applied here for the admin). Default: identity. */
|
|
19
|
+
href?: (path: string, locale: string) => string;
|
|
17
20
|
}): AdminOps;
|
|
18
21
|
/** Path-dispatch for the one-server-action seam. Own-property checks only —
|
|
19
22
|
* "constructor.constructor" and prototype paths must not resolve. */
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { affectedTargets, entryTag, pageTag } from "../revalidate.js";
|
|
2
|
+
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
2
3
|
import { validateFields } from "./validate.js";
|
|
3
4
|
const ok = (data) => ({ ok: true, data });
|
|
4
5
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
@@ -35,20 +36,80 @@ const classify = (e) => {
|
|
|
35
36
|
return err("blocked", message);
|
|
36
37
|
if (message.includes("cannot publish"))
|
|
37
38
|
return err("blocked", message);
|
|
39
|
+
if (message.includes("already taken"))
|
|
40
|
+
return err("conflict", message);
|
|
41
|
+
if (message.includes("already has a template"))
|
|
42
|
+
return err("conflict", message);
|
|
43
|
+
if (message.includes("exceed the tree depth") || message.includes("has child"))
|
|
44
|
+
return err("blocked", message);
|
|
45
|
+
// The tree guards: editing a folder as a page, moving a node into its
|
|
46
|
+
// own subtree, and the home page's no-children invariant.
|
|
47
|
+
if (message.includes("is a folder") ||
|
|
48
|
+
message.includes("cannot be moved under itself") ||
|
|
49
|
+
message.includes("cannot have child pages")) {
|
|
50
|
+
return err("blocked", message);
|
|
51
|
+
}
|
|
52
|
+
if (message.includes("must be in the same collection"))
|
|
53
|
+
return err("validation", message);
|
|
54
|
+
if (message.includes("not a valid slug"))
|
|
55
|
+
return err("validation", message);
|
|
38
56
|
if (message.includes("no page") || message.includes("no entry") || message.includes("no collection"))
|
|
39
57
|
return err("not_found", message);
|
|
40
58
|
return err("internal", message);
|
|
41
59
|
};
|
|
42
60
|
export function createAdminOps(deps) {
|
|
43
61
|
const { config, pages, entries, effects } = deps;
|
|
62
|
+
const href = deps.href ?? ((path) => path);
|
|
44
63
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
45
64
|
const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
|
|
46
65
|
const fanOut = async (entryId) => {
|
|
47
|
-
if (!effects?.
|
|
66
|
+
if (!effects?.revalidate)
|
|
67
|
+
return;
|
|
68
|
+
const { pageIds, entryIds } = await affectedTargets({ pages, entries }, entryId);
|
|
69
|
+
for (const id of entryIds)
|
|
70
|
+
effects.revalidate(entryTag(id));
|
|
71
|
+
for (const id of pageIds)
|
|
72
|
+
effects.revalidate(pageTag(id));
|
|
73
|
+
};
|
|
74
|
+
/** Expire the cached units a tree change touched, in order, once each. */
|
|
75
|
+
const expirePages = (ids) => {
|
|
76
|
+
if (!effects?.revalidate)
|
|
48
77
|
return;
|
|
49
|
-
for (const
|
|
50
|
-
effects.
|
|
78
|
+
for (const id of new Set(ids.filter((x) => typeof x === "string")))
|
|
79
|
+
effects.revalidate(pageTag(id));
|
|
80
|
+
};
|
|
81
|
+
/** A node and every descendant, node first, in listPages() order. */
|
|
82
|
+
const subtreeIds = async (id) => {
|
|
83
|
+
const all = await pages.listPages();
|
|
84
|
+
const out = [id];
|
|
85
|
+
for (let i = 0; i < out.length; i++) {
|
|
86
|
+
for (const p of all)
|
|
87
|
+
if (p.parentId === out[i])
|
|
88
|
+
out.push(p.id);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
/** Same walk over one collection's entries. */
|
|
93
|
+
const entrySubtreeIds = async (collection, id) => {
|
|
94
|
+
const all = await entries.list(collection);
|
|
95
|
+
const out = [id];
|
|
96
|
+
for (let i = 0; i < out.length; i++) {
|
|
97
|
+
for (const e of all)
|
|
98
|
+
if (e.parentId === out[i])
|
|
99
|
+
out.push(e.id);
|
|
51
100
|
}
|
|
101
|
+
return out;
|
|
102
|
+
};
|
|
103
|
+
const owned = (record, verb) => err("blocked", `smoodly: "${record.slug}" is owned by code — ${verb}.`);
|
|
104
|
+
/** The record serving "/" — guarded whether or not a registration claims
|
|
105
|
+
* its slug: the config's homePageSlug is what routes it. */
|
|
106
|
+
const isHomeNode = (r) => r.parentId === null && r.slug === config.homePageSlug;
|
|
107
|
+
const openTemplate = (name) => config.registry.pages?.find((p) => p.schema.kind === "page" && p.schema.name === name);
|
|
108
|
+
const mountGuard = (parent) => {
|
|
109
|
+
const fixed = fixedNodeOf(parent, config);
|
|
110
|
+
return fixed?.collection
|
|
111
|
+
? err("blocked", `smoodly: pages cannot be created under "${parent.slug}" — the "${fixed.collection}" collection owns that URL space.`)
|
|
112
|
+
: null;
|
|
52
113
|
};
|
|
53
114
|
const guard = async (work) => {
|
|
54
115
|
try {
|
|
@@ -60,42 +121,59 @@ export function createAdminOps(deps) {
|
|
|
60
121
|
};
|
|
61
122
|
return {
|
|
62
123
|
pages: {
|
|
63
|
-
list: () => guard(async () => ok(await pages
|
|
64
|
-
get: (
|
|
65
|
-
const record = await pages.getPage(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
124
|
+
list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
|
|
125
|
+
get: (id) => guard(async () => {
|
|
126
|
+
const record = await pages.getPage(id);
|
|
127
|
+
if (!record)
|
|
128
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
129
|
+
const draft = record.template === null ? null : await pages.getDraftTree(id);
|
|
130
|
+
const paths = await pages.pathsOf(id);
|
|
131
|
+
const urls = Object.fromEntries(Object.entries(paths).map(([locale, path]) => [locale, href(path, locale)]));
|
|
132
|
+
return ok({ record, draft, paths, urls });
|
|
70
133
|
}),
|
|
71
134
|
create: (input) => guard(async () => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
135
|
+
if (input.template !== null) {
|
|
136
|
+
const registration = openTemplate(input.template);
|
|
137
|
+
if (!registration)
|
|
138
|
+
return err("validation", `smoodly: no page template named "${input.template}".`);
|
|
139
|
+
if (registration.schema.slug !== undefined) {
|
|
140
|
+
return err("validation", `smoodly: page template "${input.template}" is fixed — Smoodly creates that page for you.`);
|
|
141
|
+
}
|
|
75
142
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
143
|
+
if (input.parentId !== null) {
|
|
144
|
+
const parent = await pages.getPage(input.parentId);
|
|
145
|
+
if (!parent)
|
|
146
|
+
return err("not_found", `smoodly: no page "${input.parentId}".`);
|
|
147
|
+
const blocked = mountGuard(parent);
|
|
148
|
+
if (blocked)
|
|
149
|
+
return blocked;
|
|
79
150
|
}
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const owner = config.registry.pages?.find((p) => p.schema.slug === input.slug);
|
|
84
|
-
if (owner) {
|
|
85
|
-
return err("validation", `smoodly: the slug "${input.slug}" belongs to the page template "${owner.schema.name}" and its own route file.`);
|
|
86
|
-
}
|
|
151
|
+
else {
|
|
152
|
+
// A root create competes with the code-owned slugs: make sure they exist first.
|
|
153
|
+
await ensureFixedNodes(pages, config);
|
|
87
154
|
}
|
|
88
|
-
return ok(await pages.createPage(
|
|
155
|
+
return ok(await pages.createPage({
|
|
156
|
+
slug: input.slug, template: input.template, parentId: input.parentId,
|
|
157
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
158
|
+
}));
|
|
89
159
|
}),
|
|
90
|
-
saveDraft: (
|
|
160
|
+
saveDraft: (id, tree) => guard(async () => {
|
|
91
161
|
if (!isPageTree(tree))
|
|
92
162
|
return err("validation", "smoodly: malformed page tree.");
|
|
93
|
-
|
|
163
|
+
if (!(await pages.getPage(id)))
|
|
164
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
165
|
+
return ok({ versionId: (await pages.saveDraft(id, tree)).id });
|
|
94
166
|
}),
|
|
95
|
-
publish: (
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
return err("not_found", `smoodly: no page
|
|
167
|
+
publish: (id) => guard(async () => {
|
|
168
|
+
const record = await pages.getPage(id);
|
|
169
|
+
if (!record)
|
|
170
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
171
|
+
const draft = record.template === null ? null : await pages.getDraftTree(id);
|
|
172
|
+
if (!draft) {
|
|
173
|
+
return record.template === null
|
|
174
|
+
? err("blocked", `smoodly: "${record.slug}" is a folder — give it a template first.`)
|
|
175
|
+
: err("blocked", `smoodly: "${record.slug}" has no draft to publish.`);
|
|
176
|
+
}
|
|
99
177
|
const fieldErrors = [];
|
|
100
178
|
for (const nodes of Object.values(draft.zones)) {
|
|
101
179
|
for (const node of nodes) {
|
|
@@ -108,27 +186,82 @@ export function createAdminOps(deps) {
|
|
|
108
186
|
}
|
|
109
187
|
}
|
|
110
188
|
if (fieldErrors.length > 0) {
|
|
111
|
-
return err("validation", `smoodly: cannot publish "${slug}" — fix the listed fields first.`, fieldErrors);
|
|
189
|
+
return err("validation", `smoodly: cannot publish "${record.slug}" — fix the listed fields first.`, fieldErrors);
|
|
112
190
|
}
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
191
|
+
const published = await pages.publish(id);
|
|
192
|
+
// The parent's cached unit lists its published children.
|
|
193
|
+
expirePages([published.id, published.parentId]);
|
|
194
|
+
return ok(published);
|
|
116
195
|
}),
|
|
117
|
-
delete: (
|
|
118
|
-
const record = await pages.getPage(
|
|
119
|
-
if (record)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// URL — blocking it would strand it forever.
|
|
124
|
-
const registration = config.registry.pages?.find((p) => p.schema.name === record.template);
|
|
125
|
-
if (registration && registration.schema.slug === record.slug) {
|
|
126
|
-
return err("blocked", `smoodly: "${slug}" is a fixed page owned by the "${record.template}" route file — remove the route file to remove it.`);
|
|
127
|
-
}
|
|
196
|
+
delete: (id) => guard(async () => {
|
|
197
|
+
const record = await pages.getPage(id);
|
|
198
|
+
if (!record)
|
|
199
|
+
return ok(null); // nothing to delete is not a failure
|
|
200
|
+
if (fixedNodeOf(record, config)) {
|
|
201
|
+
return err("blocked", `smoodly: "${record.slug}" is owned by code — remove the registration to remove it.`);
|
|
128
202
|
}
|
|
129
|
-
|
|
203
|
+
if (isHomeNode(record))
|
|
204
|
+
return err("blocked", `smoodly: "${record.slug}" is the site root — it cannot be deleted.`);
|
|
205
|
+
await pages.deletePage(id);
|
|
206
|
+
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
207
|
+
// found at the first miss; the parent's unit lists its children.
|
|
208
|
+
expirePages([id, record.parentId]);
|
|
130
209
|
return ok(null);
|
|
131
210
|
}),
|
|
211
|
+
rename: (id, patch) => guard(async () => {
|
|
212
|
+
const record = await pages.getPage(id);
|
|
213
|
+
if (!record)
|
|
214
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
215
|
+
if (fixedNodeOf(record, config))
|
|
216
|
+
return owned(record, "rename it in the registration");
|
|
217
|
+
if (isHomeNode(record) && patch.slug !== undefined) {
|
|
218
|
+
return err("blocked", `smoodly: "${record.slug}" is the site root — its slug is the config's homePageSlug.`);
|
|
219
|
+
}
|
|
220
|
+
if (!config.locales.supported.includes(patch.locale)) {
|
|
221
|
+
return err("validation", `smoodly: "${patch.locale}" is not a supported locale.`);
|
|
222
|
+
}
|
|
223
|
+
const renamed = await pages.rename(id, patch);
|
|
224
|
+
// Every descendant's path changed with this segment; the parent
|
|
225
|
+
// lists this node's title and path.
|
|
226
|
+
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
227
|
+
return ok(renamed);
|
|
228
|
+
}),
|
|
229
|
+
move: (id, to) => guard(async () => {
|
|
230
|
+
const record = await pages.getPage(id);
|
|
231
|
+
if (!record)
|
|
232
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
233
|
+
if (fixedNodeOf(record, config))
|
|
234
|
+
return owned(record, "it stays at the root");
|
|
235
|
+
if (isHomeNode(record))
|
|
236
|
+
return err("blocked", `smoodly: "${record.slug}" is the site root — it stays at the root.`);
|
|
237
|
+
if (to.parentId !== null) {
|
|
238
|
+
const parent = await pages.getPage(to.parentId);
|
|
239
|
+
if (!parent)
|
|
240
|
+
return err("not_found", `smoodly: no page "${to.parentId}".`);
|
|
241
|
+
const blocked = mountGuard(parent);
|
|
242
|
+
if (blocked)
|
|
243
|
+
return blocked;
|
|
244
|
+
}
|
|
245
|
+
const moved = await pages.move(id, to);
|
|
246
|
+
expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
|
|
247
|
+
return ok(moved);
|
|
248
|
+
}),
|
|
249
|
+
setTemplate: (id, template) => guard(async () => {
|
|
250
|
+
const record = await pages.getPage(id);
|
|
251
|
+
if (!record)
|
|
252
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
253
|
+
const fixed = fixedNodeOf(record, config);
|
|
254
|
+
if (fixed?.collection) {
|
|
255
|
+
return err("blocked", `smoodly: "${record.slug}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
|
|
256
|
+
}
|
|
257
|
+
const registration = openTemplate(template);
|
|
258
|
+
if (!registration)
|
|
259
|
+
return err("validation", `smoodly: no page template named "${template}".`);
|
|
260
|
+
if (registration.schema.slug !== undefined) {
|
|
261
|
+
return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
|
|
262
|
+
}
|
|
263
|
+
return ok(await pages.setTemplate(id, template));
|
|
264
|
+
}),
|
|
132
265
|
},
|
|
133
266
|
entries: {
|
|
134
267
|
list: (collection) => guard(async () => ok(await entries.list(collection))),
|
|
@@ -161,7 +294,16 @@ export function createAdminOps(deps) {
|
|
|
161
294
|
await fanOut(id);
|
|
162
295
|
return ok(entry);
|
|
163
296
|
}),
|
|
164
|
-
|
|
297
|
+
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
298
|
+
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), ok(null))),
|
|
299
|
+
move: (collection, id, to) => guard(async () => {
|
|
300
|
+
const moved = await entries.move(collection, id, to);
|
|
301
|
+
if (effects?.revalidate) {
|
|
302
|
+
for (const entryId of await entrySubtreeIds(collection, id))
|
|
303
|
+
effects.revalidate(entryTag(entryId));
|
|
304
|
+
}
|
|
305
|
+
return ok(moved);
|
|
306
|
+
}),
|
|
165
307
|
},
|
|
166
308
|
preview: {
|
|
167
309
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -12,22 +12,46 @@ export type OpResult<T> = {
|
|
|
12
12
|
ok: true;
|
|
13
13
|
data: T;
|
|
14
14
|
} | OpError;
|
|
15
|
+
export type PageCreateInput = {
|
|
16
|
+
parentId: string | null;
|
|
17
|
+
/** The default-locale segment. */
|
|
18
|
+
slug: string;
|
|
19
|
+
/** Defaults to the slug. */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** null = a folder. Only OPEN registrations may be named — fixed pages are materialized. */
|
|
22
|
+
template: string | null;
|
|
23
|
+
};
|
|
24
|
+
export type PageGetResult = {
|
|
25
|
+
record: PageRecord;
|
|
26
|
+
/** null for a folder. */
|
|
27
|
+
draft: PageTree | null;
|
|
28
|
+
/** locale → locale-relative CMS path (the `paths` index). */
|
|
29
|
+
paths: Record<string, string>;
|
|
30
|
+
/** locale → site URL, `href` applied server-side (the canvas and "View live" use these). */
|
|
31
|
+
urls: Record<string, string>;
|
|
32
|
+
};
|
|
15
33
|
export type AdminOps = {
|
|
16
34
|
pages: {
|
|
35
|
+
/** Lists every node, materializing fixed pages and collection mounts first. */
|
|
17
36
|
list(): Promise<OpResult<PageRecord[]>>;
|
|
18
|
-
get(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}>>;
|
|
22
|
-
create(input: {
|
|
23
|
-
slug: string;
|
|
24
|
-
template: string;
|
|
25
|
-
}): Promise<OpResult<PageRecord>>;
|
|
26
|
-
saveDraft(slug: string, tree: PageTree): Promise<OpResult<{
|
|
37
|
+
get(id: string): Promise<OpResult<PageGetResult>>;
|
|
38
|
+
create(input: PageCreateInput): Promise<OpResult<PageRecord>>;
|
|
39
|
+
saveDraft(id: string, tree: PageTree): Promise<OpResult<{
|
|
27
40
|
versionId: string;
|
|
28
41
|
}>>;
|
|
29
|
-
publish(
|
|
30
|
-
delete(
|
|
42
|
+
publish(id: string): Promise<OpResult<PageRecord>>;
|
|
43
|
+
delete(id: string): Promise<OpResult<null>>;
|
|
44
|
+
rename(id: string, patch: {
|
|
45
|
+
locale: string;
|
|
46
|
+
slug?: string;
|
|
47
|
+
title?: string;
|
|
48
|
+
}): Promise<OpResult<PageRecord>>;
|
|
49
|
+
move(id: string, to: {
|
|
50
|
+
parentId: string | null;
|
|
51
|
+
index?: number;
|
|
52
|
+
}): Promise<OpResult<PageRecord>>;
|
|
53
|
+
/** "Add page here": a folder becomes a page. */
|
|
54
|
+
setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
|
|
31
55
|
};
|
|
32
56
|
entries: {
|
|
33
57
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
@@ -36,6 +60,10 @@ export type AdminOps = {
|
|
|
36
60
|
save(collection: string, id: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
37
61
|
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
38
62
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
63
|
+
move(collection: string, id: string, to: {
|
|
64
|
+
parentId: string | null;
|
|
65
|
+
index?: number;
|
|
66
|
+
}): Promise<OpResult<EntryRecord>>;
|
|
39
67
|
};
|
|
40
68
|
preview: {
|
|
41
69
|
enable(): Promise<OpResult<null>>;
|
|
@@ -19,11 +19,18 @@ export type AdminCollection = {
|
|
|
19
19
|
title: string;
|
|
20
20
|
titleField?: string;
|
|
21
21
|
fields: Record<string, AdminField>;
|
|
22
|
+
/** The mount's segment per supported locale (spec 2026-09-05 §3); absent = entries have no URL. */
|
|
23
|
+
path?: Record<string, string>;
|
|
24
|
+
/** How deep entries may nest; 1 = flat. */
|
|
25
|
+
depth: number;
|
|
22
26
|
};
|
|
23
27
|
export type AdminPageTemplate = {
|
|
24
28
|
name: string;
|
|
25
29
|
title: string;
|
|
30
|
+
/** The default-locale segment of a fixed registration; absent = open. */
|
|
26
31
|
slug?: string;
|
|
32
|
+
/** The per-locale segments, only when the registration declares an object. */
|
|
33
|
+
slugs?: Record<string, string>;
|
|
27
34
|
zones: Record<string, ZonePolicy>;
|
|
28
35
|
};
|
|
29
36
|
export type AdminRegistry = {
|
|
@@ -34,7 +41,9 @@ export type AdminRegistry = {
|
|
|
34
41
|
default: string;
|
|
35
42
|
supported: string[];
|
|
36
43
|
};
|
|
37
|
-
/** Which slug the site root serves
|
|
44
|
+
/** Which slug the site root serves. */
|
|
38
45
|
homePageSlug: string;
|
|
46
|
+
/** How deep editors may nest pages; 1 = flat. */
|
|
47
|
+
pageDepth: number;
|
|
39
48
|
};
|
|
40
49
|
export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;
|
package/dist/admin/serialize.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { fixedSlug } from "../page.js";
|
|
2
|
+
import { collectionDepth } from "../collections.js";
|
|
3
|
+
import { perLocaleSegments } from "../paths.js";
|
|
1
4
|
function serializeDescriptor(d) {
|
|
2
5
|
const out = { type: d.type };
|
|
3
6
|
for (const [key, value] of Object.entries(d)) {
|
|
@@ -17,6 +20,7 @@ function serializeMap(map) {
|
|
|
17
20
|
return Object.fromEntries(Object.entries(map ?? {}).map(([k, d]) => [k, serializeDescriptor(d)]));
|
|
18
21
|
}
|
|
19
22
|
export function serializeAdminConfig(config) {
|
|
23
|
+
const perLocale = (v) => perLocaleSegments(v, config.locales.supported);
|
|
20
24
|
return {
|
|
21
25
|
sections: (config.registry.sections ?? []).map(({ schema }) => {
|
|
22
26
|
const s = schema;
|
|
@@ -33,17 +37,24 @@ export function serializeAdminConfig(config) {
|
|
|
33
37
|
}),
|
|
34
38
|
collections: (config.registry.collections ?? []).map((c) => ({
|
|
35
39
|
name: c.name, title: c.title, titleField: c.titleField, fields: serializeMap(c.fields),
|
|
40
|
+
...(c.path !== undefined ? { path: perLocale(c.path) } : {}),
|
|
41
|
+
depth: collectionDepth(c),
|
|
36
42
|
})),
|
|
37
|
-
pages: (config.registry.pages ?? [])
|
|
43
|
+
pages: (config.registry.pages ?? [])
|
|
44
|
+
.filter(({ schema }) => schema.kind === "page")
|
|
45
|
+
.map(({ schema }) => {
|
|
38
46
|
const p = schema;
|
|
47
|
+
const slug = fixedSlug(p, config.locales.default);
|
|
39
48
|
return {
|
|
40
49
|
name: p.name,
|
|
41
50
|
title: p.title ?? p.name,
|
|
42
|
-
...(
|
|
51
|
+
...(slug !== undefined ? { slug } : {}),
|
|
52
|
+
...(typeof p.slug === "object" && p.slug !== null ? { slugs: { ...p.slug } } : {}),
|
|
43
53
|
zones: p.zones ?? {},
|
|
44
54
|
};
|
|
45
55
|
}),
|
|
46
56
|
locales: config.locales,
|
|
47
57
|
homePageSlug: config.homePageSlug,
|
|
58
|
+
pageDepth: config.pageDepth,
|
|
48
59
|
};
|
|
49
60
|
}
|