smoodly 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
|
@@ -1,25 +1,19 @@
|
|
|
1
|
-
import { perLocaleSegments } from "../paths.js";
|
|
1
|
+
import { fixedPageSegments, perLocaleSegments } from "../paths.js";
|
|
2
2
|
export function fixedNodeSpecs(config) {
|
|
3
3
|
const { default: def, supported } = config.locales;
|
|
4
4
|
const perLocale = (v) => perLocaleSegments(v, supported);
|
|
5
|
-
const i18nOf = (segments) => {
|
|
6
|
-
const out = {};
|
|
7
|
-
for (const [locale, segment] of Object.entries(segments)) {
|
|
8
|
-
if (locale !== def && segment !== segments[def])
|
|
9
|
-
out[locale] = { slug: segment };
|
|
10
|
-
}
|
|
11
|
-
return Object.keys(out).length > 0 ? out : null;
|
|
12
|
-
};
|
|
13
5
|
const specs = new Map();
|
|
14
6
|
for (const p of config.registry.pages ?? []) {
|
|
15
7
|
if (p.schema.kind !== "page" || p.schema.slug === undefined)
|
|
16
8
|
continue;
|
|
17
|
-
|
|
9
|
+
// Fixed pages exist in every supported locale (spec 2026-09-06 §3): a
|
|
10
|
+
// slug map that omits a locale falls back to the default locale's
|
|
11
|
+
// segment. One helper fills it for the boot check (config.ts), here
|
|
12
|
+
// and fixedPathFor (localize.ts), so the three cannot drift.
|
|
13
|
+
const segments = fixedPageSegments(p.schema.slug, config.locales);
|
|
18
14
|
specs.set(segments[def], {
|
|
19
|
-
|
|
20
|
-
i18n: i18nOf(segments),
|
|
15
|
+
segments,
|
|
21
16
|
title: p.schema.title ?? p.schema.name,
|
|
22
|
-
locales: null,
|
|
23
17
|
template: p.schema.name,
|
|
24
18
|
collection: null,
|
|
25
19
|
});
|
|
@@ -35,46 +29,66 @@ export function fixedNodeSpecs(config) {
|
|
|
35
29
|
indexPage.collection = c.name;
|
|
36
30
|
continue;
|
|
37
31
|
}
|
|
38
|
-
specs.set(segments[def], {
|
|
39
|
-
slug: segments[def],
|
|
40
|
-
i18n: i18nOf(segments),
|
|
41
|
-
title: c.title,
|
|
42
|
-
locales: typeof c.path === "string" ? null : Object.keys(segments),
|
|
43
|
-
template: null,
|
|
44
|
-
collection: c.name,
|
|
45
|
-
});
|
|
32
|
+
specs.set(segments[def], { segments, title: c.title, template: null, collection: c.name });
|
|
46
33
|
}
|
|
47
34
|
return [...specs.values()];
|
|
48
35
|
}
|
|
49
|
-
/** The spec a ROOT record materializes, or null for an editor-owned node.
|
|
36
|
+
/** The spec a ROOT record materializes, or null for an editor-owned node.
|
|
37
|
+
* Judged by the default-locale slug only — a root that exists in Finnish
|
|
38
|
+
* alone is never a fixed node, whatever its Finnish slug. */
|
|
50
39
|
export function fixedNodeOf(record, config) {
|
|
51
40
|
if (record.parentId !== null)
|
|
52
41
|
return null;
|
|
53
|
-
|
|
42
|
+
const def = config.locales.default;
|
|
43
|
+
const slug = record.locales[def]?.slug;
|
|
44
|
+
if (slug === undefined)
|
|
45
|
+
return null;
|
|
46
|
+
return fixedNodeSpecs(config).find((s) => s.segments[def] === slug) ?? null;
|
|
54
47
|
}
|
|
55
|
-
/** Create every fixed node that has no root record yet,
|
|
48
|
+
/** Create every fixed node that has no root record yet, give every fixed
|
|
49
|
+
* node the locales it lacks, then list. */
|
|
56
50
|
export async function ensureFixedNodes(pages, config) {
|
|
51
|
+
const def = config.locales.default;
|
|
57
52
|
const all = await pages.listPages();
|
|
58
|
-
const
|
|
53
|
+
const roots = all.filter((p) => p.parentId === null);
|
|
59
54
|
let attempted = false;
|
|
60
55
|
for (const spec of fixedNodeSpecs(config)) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
56
|
+
const slug = spec.segments[def];
|
|
57
|
+
let record = roots.find((p) => p.locales[def]?.slug === slug);
|
|
58
|
+
if (!record) {
|
|
59
|
+
attempted = true;
|
|
60
|
+
try {
|
|
61
|
+
record = await pages.createPage({ locale: def, slug, template: spec.template, title: spec.title });
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
// Two admins loading at once race on this very create; the loser's
|
|
65
|
+
// slugTaken is the other one winning, not a failure, so it skips
|
|
66
|
+
// silently — no warn. Anything else is worth saying out loud.
|
|
67
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
68
|
+
if (!message.includes("already exists")) {
|
|
69
|
+
console.warn(`smoodly: could not materialize "${slug}": ${message}`);
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
// Every locale the registration names — including one added to the
|
|
75
|
+
// config after the record was first materialized.
|
|
76
|
+
for (const [locale, segment] of Object.entries(spec.segments)) {
|
|
77
|
+
if (record.locales[locale])
|
|
78
|
+
continue;
|
|
79
|
+
attempted = true;
|
|
80
|
+
try {
|
|
81
|
+
record = await pages.addLocale(record.id, locale, { from: def, slug: segment });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
// One locale's row that cannot land — a segment or path an editor
|
|
85
|
+
// already claimed there — must not take the node or the whole list
|
|
86
|
+
// down: warn, skip that locale, keep materializing the rest. Only a
|
|
87
|
+
// concurrent add of the SAME locale is a race, and it skips silently.
|
|
88
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
89
|
+
if (!message.includes("already exists in locale")) {
|
|
90
|
+
console.warn(`smoodly: could not materialize "${slug}" in "${locale}": ${message}`);
|
|
91
|
+
}
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult } from "./ops.ts";
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
|
|
2
2
|
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
3
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
|
|
4
|
+
export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
|
|
4
5
|
export { makeClientOps } from "./client-ops.ts";
|
|
5
6
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
6
7
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
7
8
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
8
|
-
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminField } from "./serialize.ts";
|
|
9
|
+
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
|
|
9
10
|
export * from "./tree-ops.ts";
|
|
10
11
|
export { validateFields, type FieldError } from "./validate.ts";
|
|
11
12
|
export { plainToRichtext, richtextToPlain } from "./richtext.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
2
2
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
|
|
3
|
+
export { ensureSharedItems, initialSharedFields } from "./shared-items.js";
|
|
3
4
|
export { makeClientOps } from "./client-ops.js";
|
|
4
5
|
export { gateAdminOp } from "./auth.js";
|
|
5
6
|
export { supabaseAdminAuth } from "./supabase-auth.js";
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { entryFieldsIn } from "../entry-store.js";
|
|
2
|
+
import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
2
3
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
4
|
+
import { ensureSharedItems } from "./shared-items.js";
|
|
3
5
|
import { validateFields } from "./validate.js";
|
|
4
6
|
const ok = (data) => ({ ok: true, data });
|
|
5
7
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
@@ -30,6 +32,27 @@ function isPageTree(tree) {
|
|
|
30
32
|
/** Store throws are stringly-typed; classify the known guards, default to internal. */
|
|
31
33
|
const classify = (e) => {
|
|
32
34
|
const message = e instanceof Error ? e.message : String(e);
|
|
35
|
+
if (message.includes("already exists in locale"))
|
|
36
|
+
return err("conflict", message);
|
|
37
|
+
if (message.includes("not a supported locale"))
|
|
38
|
+
return err("validation", message);
|
|
39
|
+
if (message.includes("add it there first") ||
|
|
40
|
+
message.includes("remove it from") ||
|
|
41
|
+
message.includes("at least one locale") ||
|
|
42
|
+
message.includes("cannot move under it")) {
|
|
43
|
+
return err("blocked", message);
|
|
44
|
+
}
|
|
45
|
+
if (message.includes("does not exist in locale"))
|
|
46
|
+
return err("not_found", message);
|
|
47
|
+
if (message.includes("cannot take a slug"))
|
|
48
|
+
return err("validation", message);
|
|
49
|
+
// Ahead of the generic "already exists" check below: the singleton
|
|
50
|
+
// refusal's message ("...already exists — it is a singleton.") contains
|
|
51
|
+
// "already exists" as a substring, so it must classify here first or it
|
|
52
|
+
// would be misread as a plain conflict.
|
|
53
|
+
if (message.includes("is owned by code") || message.includes("it is a singleton") || message.includes("exists in every locale")) {
|
|
54
|
+
return err("blocked", message);
|
|
55
|
+
}
|
|
33
56
|
if (message.includes("already exists"))
|
|
34
57
|
return err("conflict", message);
|
|
35
58
|
if (message.includes("is referenced by"))
|
|
@@ -62,6 +85,9 @@ export function createAdminOps(deps) {
|
|
|
62
85
|
const href = deps.href ?? ((path) => path);
|
|
63
86
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
64
87
|
const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
|
|
88
|
+
const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
|
|
89
|
+
/** One row per registration; null when materialization could not create it. */
|
|
90
|
+
const sharedRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
65
91
|
const fanOut = async (entryId) => {
|
|
66
92
|
if (!effects?.revalidate)
|
|
67
93
|
return;
|
|
@@ -70,6 +96,24 @@ export function createAdminOps(deps) {
|
|
|
70
96
|
effects.revalidate(entryTag(id));
|
|
71
97
|
for (const id of pageIds)
|
|
72
98
|
effects.revalidate(pageTag(id));
|
|
99
|
+
// A shared item's row is an entry like any other: when the item
|
|
100
|
+
// itself changes OR when something it REFERENCES does, the unit
|
|
101
|
+
// `getSmoodlyShared` caches under `shared:<name>` is stale too — and
|
|
102
|
+
// that unit is a layout, on every page. `affectedTargets` returns row
|
|
103
|
+
// ids, so resolve name → row id here. Once per fan-out, not memoized
|
|
104
|
+
// per ops instance: a row can be materialized between two calls, and
|
|
105
|
+
// an entry write is rare enough that one `entries.list` per
|
|
106
|
+
// registration beats reasoning about a stale cache. Last, so the
|
|
107
|
+
// existing entry-then-page order is untouched.
|
|
108
|
+
const registered = config.registry.shared ?? [];
|
|
109
|
+
if (registered.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const touched = new Set(entryIds);
|
|
112
|
+
for (const item of registered) {
|
|
113
|
+
const row = (await entries.list(item.name))[0];
|
|
114
|
+
if (row && touched.has(row.id))
|
|
115
|
+
effects.revalidate(sharedTag(item.name));
|
|
116
|
+
}
|
|
73
117
|
};
|
|
74
118
|
/** Expire the cached units a tree change touched, in order, once each. */
|
|
75
119
|
const expirePages = (ids) => {
|
|
@@ -100,15 +144,22 @@ export function createAdminOps(deps) {
|
|
|
100
144
|
}
|
|
101
145
|
return out;
|
|
102
146
|
};
|
|
103
|
-
const
|
|
147
|
+
const def = config.locales.default;
|
|
148
|
+
/** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
|
|
149
|
+
const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
|
|
150
|
+
const owned = (record, verb) => err("blocked", `smoodly: "${nameOf(record)}" is owned by code — ${verb}.`);
|
|
104
151
|
/** The record serving "/" — guarded whether or not a registration claims
|
|
105
|
-
* its slug: the config's homePageSlug is what routes it.
|
|
106
|
-
|
|
152
|
+
* its slug: the config's homePageSlug is what routes it. Judged by the
|
|
153
|
+
* DEFAULT locale's slug (paths.ts isHomeNode, the same rule). */
|
|
154
|
+
const isHomeNode = (r) => r.parentId === null && r.locales[def]?.slug === config.homePageSlug;
|
|
155
|
+
const supportedLocale = (locale) => typeof locale === "string" && config.locales.supported.includes(locale)
|
|
156
|
+
? null
|
|
157
|
+
: err("validation", `smoodly: "${String(locale)}" is not a supported locale.`);
|
|
107
158
|
const openTemplate = (name) => config.registry.pages?.find((p) => p.schema.kind === "page" && p.schema.name === name);
|
|
108
159
|
const mountGuard = (parent) => {
|
|
109
160
|
const fixed = fixedNodeOf(parent, config);
|
|
110
161
|
return fixed?.collection
|
|
111
|
-
? err("blocked", `smoodly: pages cannot be created under "${parent
|
|
162
|
+
? err("blocked", `smoodly: pages cannot be created under "${nameOf(parent)}" — the "${fixed.collection}" collection owns that URL space.`)
|
|
112
163
|
: null;
|
|
113
164
|
};
|
|
114
165
|
const guard = async (work) => {
|
|
@@ -122,16 +173,27 @@ export function createAdminOps(deps) {
|
|
|
122
173
|
return {
|
|
123
174
|
pages: {
|
|
124
175
|
list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
|
|
125
|
-
get: (id) => guard(async () => {
|
|
176
|
+
get: (id, locale) => guard(async () => {
|
|
177
|
+
const unsupported = supportedLocale(locale);
|
|
178
|
+
if (unsupported)
|
|
179
|
+
return unsupported;
|
|
126
180
|
const record = await pages.getPage(id);
|
|
127
181
|
if (!record)
|
|
128
182
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
129
|
-
const draft = record.template === null ? null : await pages.getDraftTree(id);
|
|
183
|
+
const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
|
|
130
184
|
const paths = await pages.pathsOf(id);
|
|
131
|
-
const urls = Object.fromEntries(Object.entries(paths).map(([
|
|
132
|
-
|
|
185
|
+
const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
|
|
186
|
+
// A locale can only be added where the parent already has it; a
|
|
187
|
+
// root has no parent, so every supported locale is open.
|
|
188
|
+
const parentLocales = record.parentId === null
|
|
189
|
+
? config.locales.supported
|
|
190
|
+
: Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
|
|
191
|
+
return ok({ record, locale, draft, paths, urls, parentLocales });
|
|
133
192
|
}),
|
|
134
193
|
create: (input) => guard(async () => {
|
|
194
|
+
const unsupported = supportedLocale(input.locale);
|
|
195
|
+
if (unsupported)
|
|
196
|
+
return unsupported;
|
|
135
197
|
if (input.template !== null) {
|
|
136
198
|
const registration = openTemplate(input.template);
|
|
137
199
|
if (!registration)
|
|
@@ -153,26 +215,35 @@ export function createAdminOps(deps) {
|
|
|
153
215
|
await ensureFixedNodes(pages, config);
|
|
154
216
|
}
|
|
155
217
|
return ok(await pages.createPage({
|
|
156
|
-
slug: input.slug, template: input.template, parentId: input.parentId,
|
|
218
|
+
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
157
219
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
158
220
|
}));
|
|
159
221
|
}),
|
|
160
|
-
saveDraft: (id, tree) => guard(async () => {
|
|
222
|
+
saveDraft: (id, locale, tree) => guard(async () => {
|
|
223
|
+
const unsupported = supportedLocale(locale);
|
|
224
|
+
if (unsupported)
|
|
225
|
+
return unsupported;
|
|
161
226
|
if (!isPageTree(tree))
|
|
162
227
|
return err("validation", "smoodly: malformed page tree.");
|
|
163
228
|
if (!(await pages.getPage(id)))
|
|
164
229
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
165
|
-
return ok({ versionId: (await pages.saveDraft(id, tree)).id });
|
|
230
|
+
return ok({ versionId: (await pages.saveDraft(id, locale, tree)).id });
|
|
166
231
|
}),
|
|
167
|
-
publish: (id) => guard(async () => {
|
|
232
|
+
publish: (id, locale) => guard(async () => {
|
|
233
|
+
const unsupported = supportedLocale(locale);
|
|
234
|
+
if (unsupported)
|
|
235
|
+
return unsupported;
|
|
168
236
|
const record = await pages.getPage(id);
|
|
169
237
|
if (!record)
|
|
170
238
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
171
|
-
const
|
|
239
|
+
const row = record.locales[locale];
|
|
240
|
+
if (!row)
|
|
241
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
242
|
+
const draft = record.template === null ? null : await pages.getDraftTree(id, locale);
|
|
172
243
|
if (!draft) {
|
|
173
244
|
return record.template === null
|
|
174
|
-
? err("blocked", `smoodly: "${record
|
|
175
|
-
: err("blocked", `smoodly: "${
|
|
245
|
+
? err("blocked", `smoodly: "${nameOf(record)}" is a folder — give it a template first.`)
|
|
246
|
+
: err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
|
|
176
247
|
}
|
|
177
248
|
const fieldErrors = [];
|
|
178
249
|
for (const nodes of Object.values(draft.zones)) {
|
|
@@ -186,9 +257,9 @@ export function createAdminOps(deps) {
|
|
|
186
257
|
}
|
|
187
258
|
}
|
|
188
259
|
if (fieldErrors.length > 0) {
|
|
189
|
-
return err("validation", `smoodly: cannot publish "${
|
|
260
|
+
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
190
261
|
}
|
|
191
|
-
const published = await pages.publish(id);
|
|
262
|
+
const published = await pages.publish(id, locale);
|
|
192
263
|
// The parent's cached unit lists its published children.
|
|
193
264
|
expirePages([published.id, published.parentId]);
|
|
194
265
|
return ok(published);
|
|
@@ -198,10 +269,10 @@ export function createAdminOps(deps) {
|
|
|
198
269
|
if (!record)
|
|
199
270
|
return ok(null); // nothing to delete is not a failure
|
|
200
271
|
if (fixedNodeOf(record, config)) {
|
|
201
|
-
return err("blocked", `smoodly: "${record
|
|
272
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is owned by code — remove the registration to remove it.`);
|
|
202
273
|
}
|
|
203
274
|
if (isHomeNode(record))
|
|
204
|
-
return err("blocked", `smoodly: "${record
|
|
275
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be deleted.`);
|
|
205
276
|
await pages.deletePage(id);
|
|
206
277
|
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
207
278
|
// found at the first miss; the parent's unit lists its children.
|
|
@@ -215,11 +286,11 @@ export function createAdminOps(deps) {
|
|
|
215
286
|
if (fixedNodeOf(record, config))
|
|
216
287
|
return owned(record, "rename it in the registration");
|
|
217
288
|
if (isHomeNode(record) && patch.slug !== undefined) {
|
|
218
|
-
return err("blocked", `smoodly: "${record
|
|
219
|
-
}
|
|
220
|
-
if (!config.locales.supported.includes(patch.locale)) {
|
|
221
|
-
return err("validation", `smoodly: "${patch.locale}" is not a supported locale.`);
|
|
289
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — its slug is the config's homePageSlug.`);
|
|
222
290
|
}
|
|
291
|
+
const unsupported = supportedLocale(patch.locale);
|
|
292
|
+
if (unsupported)
|
|
293
|
+
return unsupported;
|
|
223
294
|
const renamed = await pages.rename(id, patch);
|
|
224
295
|
// Every descendant's path changed with this segment; the parent
|
|
225
296
|
// lists this node's title and path.
|
|
@@ -233,7 +304,7 @@ export function createAdminOps(deps) {
|
|
|
233
304
|
if (fixedNodeOf(record, config))
|
|
234
305
|
return owned(record, "it stays at the root");
|
|
235
306
|
if (isHomeNode(record))
|
|
236
|
-
return err("blocked", `smoodly: "${record
|
|
307
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
|
|
237
308
|
if (to.parentId !== null) {
|
|
238
309
|
const parent = await pages.getPage(to.parentId);
|
|
239
310
|
if (!parent)
|
|
@@ -252,7 +323,7 @@ export function createAdminOps(deps) {
|
|
|
252
323
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
253
324
|
const fixed = fixedNodeOf(record, config);
|
|
254
325
|
if (fixed?.collection) {
|
|
255
|
-
return err("blocked", `smoodly: "${record
|
|
326
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
|
|
256
327
|
}
|
|
257
328
|
const registration = openTemplate(template);
|
|
258
329
|
if (!registration)
|
|
@@ -262,35 +333,79 @@ export function createAdminOps(deps) {
|
|
|
262
333
|
}
|
|
263
334
|
return ok(await pages.setTemplate(id, template));
|
|
264
335
|
}),
|
|
336
|
+
addLocale: (id, locale, options) => guard(async () => {
|
|
337
|
+
const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
|
|
338
|
+
if (unsupported)
|
|
339
|
+
return unsupported;
|
|
340
|
+
if (!(await pages.getPage(id)))
|
|
341
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
342
|
+
// A new locale is a draft: nothing public changes, nothing to expire.
|
|
343
|
+
return ok(await pages.addLocale(id, locale, { from: options.from }));
|
|
344
|
+
}),
|
|
345
|
+
removeLocale: (id, locale) => guard(async () => {
|
|
346
|
+
const unsupported = supportedLocale(locale);
|
|
347
|
+
if (unsupported)
|
|
348
|
+
return unsupported;
|
|
349
|
+
const record = await pages.getPage(id);
|
|
350
|
+
if (!record)
|
|
351
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
352
|
+
if (fixedNodeOf(record, config))
|
|
353
|
+
return owned(record, "it exists in every locale");
|
|
354
|
+
if (isHomeNode(record))
|
|
355
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it exists in every locale.`);
|
|
356
|
+
const removed = await pages.removeLocale(id, locale);
|
|
357
|
+
// The locale may have been published: its unit and the parent's listing go.
|
|
358
|
+
expirePages([id, record.parentId]);
|
|
359
|
+
return ok(removed);
|
|
360
|
+
}),
|
|
265
361
|
},
|
|
266
362
|
entries: {
|
|
267
363
|
list: (collection) => guard(async () => ok(await entries.list(collection))),
|
|
268
|
-
get: (collection, id) => guard(async () => {
|
|
269
|
-
const
|
|
270
|
-
|
|
364
|
+
get: (collection, id, locale) => guard(async () => {
|
|
365
|
+
const unsupported = supportedLocale(locale);
|
|
366
|
+
if (unsupported)
|
|
367
|
+
return unsupported;
|
|
368
|
+
const record = await entries.get(collection, id);
|
|
369
|
+
if (!record)
|
|
370
|
+
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
371
|
+
const fields = record.locales[locale] ? entryFieldsIn(record, locale) : null;
|
|
372
|
+
// A locale can only be added where the parent already has it; a root has no parent.
|
|
373
|
+
const parentLocales = record.parentId === null
|
|
374
|
+
? config.locales.supported
|
|
375
|
+
: Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
|
|
376
|
+
return ok({ record, locale, fields, parentLocales });
|
|
271
377
|
}),
|
|
272
|
-
create: (collection,
|
|
378
|
+
create: (collection, input) => guard(async () => {
|
|
379
|
+
const unsupported = supportedLocale(input.locale);
|
|
380
|
+
if (unsupported)
|
|
381
|
+
return unsupported;
|
|
273
382
|
const descriptors = collectionFields(collection);
|
|
274
383
|
if (!descriptors)
|
|
275
384
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
276
|
-
const fieldErrors = validateFields(fields, descriptors);
|
|
385
|
+
const fieldErrors = validateFields(input.fields, descriptors);
|
|
277
386
|
if (fieldErrors.length > 0)
|
|
278
387
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
279
|
-
return ok(await entries.create(collection, fields));
|
|
388
|
+
return ok(await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null }));
|
|
280
389
|
}),
|
|
281
|
-
save: (collection, id, fields) => guard(async () => {
|
|
390
|
+
save: (collection, id, locale, fields) => guard(async () => {
|
|
391
|
+
const unsupported = supportedLocale(locale);
|
|
392
|
+
if (unsupported)
|
|
393
|
+
return unsupported;
|
|
282
394
|
const descriptors = collectionFields(collection);
|
|
283
395
|
if (!descriptors)
|
|
284
396
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
285
397
|
const fieldErrors = validateFields(fields, descriptors);
|
|
286
398
|
if (fieldErrors.length > 0)
|
|
287
399
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
288
|
-
const entry = await entries.update(collection, id, fields);
|
|
400
|
+
const entry = await entries.update(collection, id, locale, fields);
|
|
289
401
|
await fanOut(id);
|
|
290
402
|
return ok(entry);
|
|
291
403
|
}),
|
|
292
|
-
setStatus: (collection, id, status) => guard(async () => {
|
|
293
|
-
const
|
|
404
|
+
setStatus: (collection, id, locale, status) => guard(async () => {
|
|
405
|
+
const unsupported = supportedLocale(locale);
|
|
406
|
+
if (unsupported)
|
|
407
|
+
return unsupported;
|
|
408
|
+
const entry = await entries.setStatus(collection, id, locale, status);
|
|
294
409
|
await fanOut(id);
|
|
295
410
|
return ok(entry);
|
|
296
411
|
}),
|
|
@@ -304,6 +419,73 @@ export function createAdminOps(deps) {
|
|
|
304
419
|
}
|
|
305
420
|
return ok(moved);
|
|
306
421
|
}),
|
|
422
|
+
// A new locale starts as a draft: nothing public changes, nothing to expire.
|
|
423
|
+
addLocale: (collection, id, locale, options) => guard(async () => {
|
|
424
|
+
const unsupported = supportedLocale(locale);
|
|
425
|
+
if (unsupported)
|
|
426
|
+
return unsupported;
|
|
427
|
+
return ok(await entries.addLocale(collection, id, locale, { from: options.from }));
|
|
428
|
+
}),
|
|
429
|
+
removeLocale: (collection, id, locale) => guard(async () => {
|
|
430
|
+
const unsupported = supportedLocale(locale);
|
|
431
|
+
if (unsupported)
|
|
432
|
+
return unsupported;
|
|
433
|
+
const entry = await entries.removeLocale(collection, id, locale);
|
|
434
|
+
await fanOut(id);
|
|
435
|
+
return ok(entry);
|
|
436
|
+
}),
|
|
437
|
+
},
|
|
438
|
+
shared: {
|
|
439
|
+
list: () => guard(async () => {
|
|
440
|
+
const records = await ensureSharedItems(entries, config);
|
|
441
|
+
return ok((config.registry.shared ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
|
|
442
|
+
}),
|
|
443
|
+
get: (name, locale) => guard(async () => {
|
|
444
|
+
const unsupported = supportedLocale(locale);
|
|
445
|
+
if (unsupported)
|
|
446
|
+
return unsupported;
|
|
447
|
+
const item = sharedItem(name);
|
|
448
|
+
if (!item)
|
|
449
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
450
|
+
await ensureSharedItems(entries, config);
|
|
451
|
+
const record = await sharedRecord(name);
|
|
452
|
+
if (!record?.locales[locale])
|
|
453
|
+
return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
|
|
454
|
+
const usage = await entries.referencesTo(name, record.id);
|
|
455
|
+
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
456
|
+
}),
|
|
457
|
+
save: (name, locale, fields) => guard(async () => {
|
|
458
|
+
const unsupported = supportedLocale(locale);
|
|
459
|
+
if (unsupported)
|
|
460
|
+
return unsupported;
|
|
461
|
+
const item = sharedItem(name);
|
|
462
|
+
if (!item)
|
|
463
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
464
|
+
const fieldErrors = validateFields(fields, item.fields);
|
|
465
|
+
if (fieldErrors.length > 0)
|
|
466
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
467
|
+
const record = await sharedRecord(name);
|
|
468
|
+
if (!record)
|
|
469
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
470
|
+
const saved = await entries.update(name, record.id, locale, fields);
|
|
471
|
+
// fanOut expires `shared:<name>` itself — this row IS the item's.
|
|
472
|
+
await fanOut(record.id);
|
|
473
|
+
return ok(saved);
|
|
474
|
+
}),
|
|
475
|
+
setStatus: (name, locale, status) => guard(async () => {
|
|
476
|
+
const unsupported = supportedLocale(locale);
|
|
477
|
+
if (unsupported)
|
|
478
|
+
return unsupported;
|
|
479
|
+
if (!sharedItem(name))
|
|
480
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
481
|
+
const record = await sharedRecord(name);
|
|
482
|
+
if (!record)
|
|
483
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
484
|
+
const changed = await entries.setStatus(name, record.id, locale, status);
|
|
485
|
+
// see save: the fan-out carries the item's own tag.
|
|
486
|
+
await fanOut(record.id);
|
|
487
|
+
return ok(changed);
|
|
488
|
+
}),
|
|
307
489
|
},
|
|
308
490
|
preview: {
|
|
309
491
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|