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.
Files changed (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. 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
- const segments = perLocale(p.schema.slug);
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
- slug: segments[def],
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
- return fixedNodeSpecs(config).find((s) => s.slug === record.slug) ?? null;
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, then list. */
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 taken = new Set(all.filter((p) => p.parentId === null).map((p) => p.slug));
53
+ const roots = all.filter((p) => p.parentId === null);
59
54
  let attempted = false;
60
55
  for (const spec of fixedNodeSpecs(config)) {
61
- if (taken.has(spec.slug))
62
- continue;
63
- attempted = true;
64
- try {
65
- await pages.createPage({
66
- slug: spec.slug, template: spec.template, title: spec.title, i18n: spec.i18n, locales: spec.locales,
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
- catch (e) {
70
- // One spec that cannot land a path an editor already claimed in some
71
- // locale must not take the whole list down: warn, skip it, keep
72
- // materializing the rest. Two admins loading at once can also race on
73
- // this same create; the loser's slugTaken is the other one winning,
74
- // not a failure, so it skips silently — no warn.
75
- const message = e instanceof Error ? e.message : String(e);
76
- if (!message.includes("already exists")) {
77
- console.warn(`smoodly: could not materialize "${spec.slug}": ${message}`);
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
  }
@@ -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";
@@ -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";
@@ -1,5 +1,7 @@
1
- import { affectedTargets, entryTag, pageTag } from "../revalidate.js";
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 owned = (record, verb) => err("blocked", `smoodly: "${record.slug}" is owned by code — ${verb}.`);
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
- const isHomeNode = (r) => r.parentId === null && r.slug === config.homePageSlug;
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.slug}" — the "${fixed.collection}" collection owns that URL space.`)
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(([locale, path]) => [locale, href(path, locale)]));
132
- return ok({ record, draft, paths, urls });
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 draft = record.template === null ? null : await pages.getDraftTree(id);
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.slug}" is a folder — give it a template first.`)
175
- : err("blocked", `smoodly: "${record.slug}" has no draft to publish.`);
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 "${record.slug}" — fix the listed fields first.`, fieldErrors);
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.slug}" is owned by code — remove the registration to remove it.`);
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.slug}" is the site root — it cannot be deleted.`);
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.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.`);
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.slug}" is the site root — it stays at the root.`);
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.slug}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
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 entry = await entries.get(collection, id);
270
- return entry ? ok(entry) : err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
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, fields) => guard(async () => {
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 entry = await entries.setStatus(collection, id, status);
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))),