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.
Files changed (89) hide show
  1. package/README.md +408 -34
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.d.ts +3 -2
  4. package/dist/admin/editor/EditorView.js +133 -80
  5. package/dist/admin/editor/PageSettings.d.ts +13 -0
  6. package/dist/admin/editor/PageSettings.js +40 -0
  7. package/dist/admin/fixed-nodes.d.ts +23 -0
  8. package/dist/admin/fixed-nodes.js +83 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +2 -1
  10. package/dist/admin/forms/FieldWidget.js +16 -16
  11. package/dist/admin/index.d.ts +2 -1
  12. package/dist/admin/index.js +1 -0
  13. package/dist/admin/next/create-admin.js +7 -2
  14. package/dist/admin/next/op-handler.d.ts +1 -0
  15. package/dist/admin/next/op-handler.js +7 -2
  16. package/dist/admin/ops-impl.d.ts +4 -1
  17. package/dist/admin/ops-impl.js +190 -48
  18. package/dist/admin/ops.d.ts +39 -11
  19. package/dist/admin/serialize.d.ts +10 -1
  20. package/dist/admin/serialize.js +13 -2
  21. package/dist/admin/shell/AdminApp.js +36 -74
  22. package/dist/admin/shell/EntryForm.js +34 -10
  23. package/dist/admin/shell/ErrorPane.d.ts +4 -0
  24. package/dist/admin/shell/ErrorPane.js +9 -0
  25. package/dist/admin/shell/ListView.d.ts +8 -5
  26. package/dist/admin/shell/ListView.js +12 -11
  27. package/dist/admin/shell/LoginView.js +6 -2
  28. package/dist/admin/shell/PagesList.d.ts +6 -0
  29. package/dist/admin/shell/PagesList.js +148 -0
  30. package/dist/admin/shell/base.d.ts +1 -0
  31. package/dist/admin/shell/base.js +4 -0
  32. package/dist/admin/shell/pages-tree.d.ts +39 -0
  33. package/dist/admin/shell/pages-tree.js +65 -0
  34. package/dist/admin/ui/format.d.ts +1 -0
  35. package/dist/admin/ui/format.js +25 -0
  36. package/dist/admin/ui/primitives.d.ts +89 -0
  37. package/dist/admin/ui/primitives.js +115 -0
  38. package/dist/admin/ui/theme.d.ts +5 -0
  39. package/dist/admin/ui/theme.js +396 -0
  40. package/dist/collections.d.ts +23 -2
  41. package/dist/collections.js +19 -0
  42. package/dist/config.d.ts +11 -1
  43. package/dist/config.js +71 -13
  44. package/dist/entry-page.d.ts +16 -0
  45. package/dist/entry-page.js +6 -0
  46. package/dist/entry-store.d.ts +94 -7
  47. package/dist/entry-store.js +175 -26
  48. package/dist/index.d.ts +24 -8
  49. package/dist/index.js +14 -3
  50. package/dist/localize.d.ts +19 -0
  51. package/dist/localize.js +42 -0
  52. package/dist/next/index.d.ts +2 -2
  53. package/dist/next/index.js +1 -1
  54. package/dist/next/meta.d.ts +0 -6
  55. package/dist/next/meta.js +0 -11
  56. package/dist/next/page-renderer.d.ts +26 -18
  57. package/dist/next/page-renderer.js +79 -56
  58. package/dist/page.d.ts +10 -3
  59. package/dist/page.js +12 -2
  60. package/dist/pairing.d.ts +7 -6
  61. package/dist/path-index.d.ts +16 -0
  62. package/dist/path-index.js +33 -0
  63. package/dist/paths.d.ts +80 -0
  64. package/dist/paths.js +110 -0
  65. package/dist/render.d.ts +34 -5
  66. package/dist/render.js +9 -2
  67. package/dist/resolve.d.ts +8 -0
  68. package/dist/resolve.js +19 -7
  69. package/dist/revalidate.d.ts +10 -2
  70. package/dist/revalidate.js +13 -8
  71. package/dist/site.d.ts +85 -0
  72. package/dist/site.js +164 -0
  73. package/dist/sql-space.d.ts +1 -1
  74. package/dist/sql-space.js +2 -0
  75. package/dist/sql.d.ts +1 -1
  76. package/dist/sql.js +92 -15
  77. package/dist/store.d.ts +106 -30
  78. package/dist/store.js +242 -66
  79. package/dist/supabase-entry-store.d.ts +17 -3
  80. package/dist/supabase-entry-store.js +198 -22
  81. package/dist/supabase-path-index.d.ts +11 -0
  82. package/dist/supabase-path-index.js +52 -0
  83. package/dist/supabase-store.d.ts +46 -16
  84. package/dist/supabase-store.js +322 -110
  85. package/package.json +5 -4
  86. package/dist/admin/page-path.d.ts +0 -1
  87. package/dist/admin/page-path.js +0 -6
  88. package/dist/admin/shell/pages-list.d.ts +0 -22
  89. 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
- const inputStyle = {
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
- control = _jsx("input", { style: inputStyle, value: String(value ?? ""), onChange: (e) => onChange(e.target.value) });
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", { style: inputStyle, type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
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", { style: inputStyle, 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
+ 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", { style: { ...inputStyle, resize: "vertical" }, rows: 4, value: richtextToPlain(value), onChange: (e) => onChange(plainToRichtext(e.target.value)) }));
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", { style: inputStyle, placeholder: "https://\u2026", value: url, onChange: (e) => onChange(e.target.value ? { url: e.target.value } : undefined) }));
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", { style: inputStyle, 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
+ 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, marginBottom: 6 }, children: [_jsx("span", { style: { ...inputStyle, flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx("button", { type: "button", onClick: () => onChange(ids.filter((_, j) => j !== i)), style: { border: 0, background: "#efecf6", borderRadius: 8, cursor: "pointer", padding: "0 10px" }, children: "\u2715" })] }, `${id}-${i}`))), _jsxs("select", { style: inputStyle, 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
+ 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", { style: inputStyle, disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
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
- const Wrapper = d.type === "refList" ? "div" : "label";
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
  }
@@ -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";
@@ -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 (_jsx("div", { style: { fontFamily: "system-ui, sans-serif", minHeight: "100vh", background: "#f4f3f8", color: "#17141f" }, children: children }));
16
+ return (_jsxs("div", { className: "sm-admin", children: [_jsx(AdminStyles, {}), children] }));
12
17
  }
13
18
  return { AdminPage, AdminLayout };
14
19
  }
@@ -8,4 +8,5 @@ export declare function createAdminOpHandler(deps: {
8
8
  pages: PageStore;
9
9
  entries: EntryStore;
10
10
  auth: AdminAuth;
11
+ href?: (path: string, locale: string) => string;
11
12
  }): AdminOpAction;
@@ -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 { revalidateTag } from "next/cache";
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
- revalidatePage: (slug) => revalidateTag(`page:${slug}`, "max"),
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(),
@@ -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
- revalidatePage?(slug: string): void;
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. */
@@ -1,4 +1,5 @@
1
- import { affectedPageSlugs } from "../revalidate.js";
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?.revalidatePage)
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 slug of await affectedPageSlugs({ pages, entries }, entryId)) {
50
- effects.revalidatePage(slug);
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.listPages())),
64
- get: (slug) => guard(async () => {
65
- const record = await pages.getPage(slug);
66
- const draft = record ? await pages.getDraftTree(slug) : null;
67
- if (!record || !draft)
68
- return err("not_found", `smoodly: no page with slug "${slug}".`);
69
- return ok({ record, draft });
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
- const registration = config.registry.pages?.find((p) => p.schema.name === input.template);
73
- if (!registration) {
74
- return err("validation", `smoodly: no page template named "${input.template}".`);
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
- const fixed = registration.schema.slug;
77
- if (fixed !== undefined && fixed !== input.slug) {
78
- return err("validation", `smoodly: page template "${input.template}" is fixed to the slug "${fixed}" and cannot be created at "${input.slug}".`);
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
- if (fixed === undefined) {
81
- // An open page here would be shadowed forever by the fixed
82
- // registration's own route file.
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(input));
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: (slug, tree) => guard(async () => {
160
+ saveDraft: (id, tree) => guard(async () => {
91
161
  if (!isPageTree(tree))
92
162
  return err("validation", "smoodly: malformed page tree.");
93
- return ok({ versionId: (await pages.saveDraft(slug, tree)).id });
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: (slug) => guard(async () => {
96
- const draft = await pages.getDraftTree(slug);
97
- if (!draft)
98
- return err("not_found", `smoodly: no page with slug "${slug}".`);
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 record = await pages.publish(slug);
114
- effects?.revalidatePage?.(slug);
115
- return ok(record);
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: (slug) => guard(async () => {
118
- const record = await pages.getPage(slug);
119
- if (record) {
120
- // Blocked only when this record IS the registration's singleton.
121
- // A record whose template is fixed but which sits at another slug
122
- // (the route file's slug was renamed under it) is reachable at no
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
- await pages.deletePage(slug);
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
- delete: (collection, id) => guard(async () => (await entries.delete(collection, id), ok(null))),
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))),
@@ -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(slug: string): Promise<OpResult<{
19
- record: PageRecord;
20
- draft: PageTree;
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(slug: string): Promise<OpResult<PageRecord>>;
30
- delete(slug: string): Promise<OpResult<null>>;
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 — see pagePath(). */
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;
@@ -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 ?? []).map(({ schema }) => {
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
- ...(p.slug !== undefined ? { slug: p.slug } : {}),
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
  }