smoodly 0.0.32 → 0.0.34

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 (76) hide show
  1. package/README.md +77 -15
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +5 -3
  5. package/dist/admin/editor/PageSettings.js +7 -2
  6. package/dist/admin/editor/autosave.d.ts +2 -1
  7. package/dist/admin/editor/autosave.js +6 -0
  8. package/dist/admin/editor/useCanvasBridge.js +25 -2
  9. package/dist/admin/fixed-nodes.d.ts +9 -24
  10. package/dist/admin/fixed-nodes.js +36 -72
  11. package/dist/admin/forms/FieldWidget.d.ts +5 -1
  12. package/dist/admin/forms/FieldWidget.js +6 -3
  13. package/dist/admin/forms/slug-field.d.ts +17 -0
  14. package/dist/admin/forms/slug-field.js +39 -0
  15. package/dist/admin/health.js +9 -0
  16. package/dist/admin/next/create-admin.d.ts +4 -3
  17. package/dist/admin/next/create-admin.js +14 -3
  18. package/dist/admin/ops-impl.js +144 -14
  19. package/dist/admin/redirect-path.d.ts +3 -0
  20. package/dist/admin/redirect-path.js +9 -0
  21. package/dist/admin/serialize.d.ts +3 -0
  22. package/dist/admin/serialize.js +1 -0
  23. package/dist/admin/shell/AddRedirectModal.js +2 -2
  24. package/dist/admin/shell/EntryForm.js +46 -6
  25. package/dist/admin/shell/GlobalForm.js +1 -1
  26. package/dist/admin/shell/PageDialogs.js +11 -5
  27. package/dist/admin/shell/PagesList.js +29 -12
  28. package/dist/admin/shell/live-url.d.ts +3 -1
  29. package/dist/admin/shell/live-url.js +5 -2
  30. package/dist/admin/shell/pages-tree.d.ts +19 -14
  31. package/dist/admin/shell/pages-tree.js +49 -16
  32. package/dist/admin/ui/primitives.js +1 -0
  33. package/dist/admin/ui/theme.js +1 -0
  34. package/dist/admin/validate.d.ts +2 -0
  35. package/dist/admin/validate.js +9 -0
  36. package/dist/anchors.d.ts +42 -0
  37. package/dist/anchors.js +72 -0
  38. package/dist/cli/export.js +3 -1
  39. package/dist/cli/import.d.ts +1 -0
  40. package/dist/cli/import.js +13 -2
  41. package/dist/cli/space-io.d.ts +1 -2
  42. package/dist/cli/space-io.js +5 -5
  43. package/dist/collections.js +15 -0
  44. package/dist/config.d.ts +6 -0
  45. package/dist/config.js +53 -18
  46. package/dist/current-space.d.ts +4 -0
  47. package/dist/current-space.js +9 -0
  48. package/dist/entry-store.js +4 -2
  49. package/dist/env.d.ts +4 -3
  50. package/dist/env.js +15 -17
  51. package/dist/index.d.ts +4 -2
  52. package/dist/index.js +4 -2
  53. package/dist/next/page-renderer.js +2 -2
  54. package/dist/path-index.d.ts +11 -0
  55. package/dist/path-index.js +52 -25
  56. package/dist/paths.d.ts +32 -12
  57. package/dist/paths.js +80 -21
  58. package/dist/schema-version.d.ts +1 -1
  59. package/dist/schema-version.js +1 -1
  60. package/dist/site.d.ts +7 -3
  61. package/dist/site.js +26 -15
  62. package/dist/space-transfer.d.ts +2 -0
  63. package/dist/space-transfer.js +8 -1
  64. package/dist/sql.js +35 -9
  65. package/dist/store.d.ts +15 -1
  66. package/dist/store.js +27 -6
  67. package/dist/supabase-entry-store.d.ts +0 -1
  68. package/dist/supabase-entry-store.js +6 -11
  69. package/dist/supabase-path-index.d.ts +12 -1
  70. package/dist/supabase-path-index.js +56 -19
  71. package/dist/supabase-store.d.ts +2 -0
  72. package/dist/supabase-store.js +30 -9
  73. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  74. package/package.json +1 -1
  75. package/dist/admin/shell/page-dialogs.d.ts +0 -9
  76. package/dist/admin/shell/page-dialogs.js +0 -19
@@ -0,0 +1,39 @@
1
+ // What a slug box does (spec 2026-09-28 ASCII slugs §4–§6): pure, shared
2
+ // by the page dialogs, page Settings, the slug field widget and the entry
3
+ // form. The grammar and the slugifier live in paths.ts.
4
+ import { slugify } from "../../paths.js";
5
+ /** The slug follows its title until the editor types a slug of their
6
+ * own; a slug equal to what the previous title produced is still
7
+ * "following". */
8
+ export function followsTitle(slug, previousTitle) {
9
+ return slug === "" || slug === slugify(previousTitle);
10
+ }
11
+ /** Under a slug box whose source text makes no slug (§6). */
12
+ export const NO_LETTERS_HINT = "This title has no letters a slug can use — type one, like novosti.";
13
+ /** What a slug box holds after blur (§4): the slugified text; when
14
+ * nothing survives, the text as typed if it has letters (Новости), so
15
+ * publish can name the problem, and "" if it had none (" ", "---"). */
16
+ export function tidySlug(value) {
17
+ const tidy = slugify(value);
18
+ if (tidy !== "")
19
+ return tidy;
20
+ return /[\p{L}\p{N}]/u.test(value) ? value : "";
21
+ }
22
+ /** NO_LETTERS_HINT when `source` has text but no slug in it (§6). */
23
+ export function slugHint(source) {
24
+ return typeof source === "string" && source.trim() !== "" && slugify(source) === "" ? NO_LETTERS_HINT : undefined;
25
+ }
26
+ /** The entry form's fields after `key` changed to `value` (§5): while the
27
+ * row has never been published, an `f.slug({ from })` slug that still
28
+ * follows the previous `from` value follows the new one. A published
29
+ * row's slug is a live URL and never moves on its own. */
30
+ export function withFollowingSlug(fields, key, value, from, neverPublished) {
31
+ const next = { ...fields, [key]: value };
32
+ if (from === undefined || key !== from || !neverPublished)
33
+ return next;
34
+ const slug = typeof fields.slug === "string" ? fields.slug : "";
35
+ const previous = typeof fields[from] === "string" ? fields[from] : "";
36
+ if (!followsTitle(slug, previous))
37
+ return next;
38
+ return { ...next, slug: slugify(typeof value === "string" ? value : "") };
39
+ }
@@ -1,5 +1,6 @@
1
1
  import { entryFieldsIn } from "../entry-store.js";
2
2
  import { entryDrift, mergeVersions, pageDrift } from "./drift.js";
3
+ import { isSegment } from "../paths.js";
3
4
  export function buildHealthReport(registry, pages, entries) {
4
5
  const documents = [];
5
6
  const def = registry.locales.default;
@@ -30,6 +31,14 @@ export function buildHealthReport(registry, pages, entries) {
30
31
  const lived = live[locale] ? entryDrift(registry, record.collection, { fields: live[locale] }) : [];
31
32
  for (const f of mergeVersions(draft, lived))
32
33
  findings.push({ ...f, locale });
34
+ // The slug is the row's column, never versioned: one value, live once published. Drift
35
+ // leaves it out (a marker would flash while it is typed); publish checks the grammar.
36
+ if (collection?.fields.slug?.type === "slug" && row.slug !== null && row.slug !== "" && !isSegment(row.slug)) {
37
+ findings.push({
38
+ kind: "misfit", path: "slug", subject: { scope: "collection", name: record.collection, field: "slug" },
39
+ value: row.slug, detail: { type: "slug" }, draft: true, live: row.status === "published", locale,
40
+ });
41
+ }
33
42
  }
34
43
  if (findings.length === 0)
35
44
  continue;
@@ -4,15 +4,16 @@ import type { AdminOpAction } from "../ops.ts";
4
4
  import type { AuthConfig } from "../shell/session.ts";
5
5
  import type { ContentConfig } from "../../env.ts";
6
6
  /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
7
- * concurrency §5); absent — an app wired before it — presence is off and everything else works. */
7
+ * concurrency §5), async because its space is asked of the database; absent or failed, presence
8
+ * is off and everything else works. */
8
9
  export declare function createSmoodlyAdmin(input: {
9
10
  config: ResolvedConfig;
10
11
  op: AdminOpAction;
11
12
  auth: () => AuthConfig;
12
- content?: () => ContentConfig;
13
+ content?: () => ContentConfig | Promise<ContentConfig>;
13
14
  }): {
14
15
  AdminPage: () => null;
15
16
  AdminLayout: ({ children }: {
16
17
  children: ReactNode;
17
- }) => import("react").JSX.Element;
18
+ }) => Promise<import("react").JSX.Element>;
18
19
  };
@@ -3,7 +3,8 @@ import { serializeAdminConfig } from "../serialize.js";
3
3
  import { AdminApp } from "../shell/AdminApp.js";
4
4
  import { AdminStyles } from "../ui/theme.js";
5
5
  /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
6
- * concurrency §5); absent — an app wired before it — presence is off and everything else works. */
6
+ * concurrency §5), async because its space is asked of the database; absent or failed, presence
7
+ * is off and everything else works. */
7
8
  export function createSmoodlyAdmin(input) {
8
9
  const registry = serializeAdminConfig(input.config);
9
10
  /** The catch-all page only makes every /admin URL exist. The shell is
@@ -23,8 +24,18 @@ export function createSmoodlyAdmin(input) {
23
24
  * suppressHydrationWarning: the theme boot script sets `data-theme`
24
25
  * on this element before React hydrates, on purpose (no white flash
25
26
  * for a dark-mode editor), so the attribute differs from the server's. */
26
- function AdminLayout({ children }) {
27
- return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: input.content?.() }), children] }));
27
+ async function AdminLayout({ children }) {
28
+ // Presence is the one thing that needs it: a failed lookup (an unmigrated project, an
29
+ // unreachable database) is logged and turns presence off, so the shell still reaches
30
+ // sign-in, the schema notice or the admin-path ErrorPane.
31
+ let content;
32
+ try {
33
+ content = await input.content?.();
34
+ }
35
+ catch (e) {
36
+ console.error("smoodly: editing presence is off —", e instanceof Error ? e.message : e);
37
+ }
38
+ return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: content }), children] }));
28
39
  }
29
40
  return { AdminPage, AdminLayout };
30
41
  }
@@ -1,12 +1,14 @@
1
- import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
1
+ import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
+ import { chainOf, isSegment, pagePathIn } from "../paths.js";
3
+ import { servedAnchor } from "../anchors.js";
2
4
  import { distinctTags } from "../tags.js";
3
5
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
4
6
  import { ASSET_COLLECTION } from "../media.js";
5
7
  import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
6
8
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
7
- import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
9
+ import { ensureFixedNodes, fixedNodeOf, fixedNodeSpecs } from "./fixed-nodes.js";
8
10
  import { ensureGlobalItems } from "./global-items.js";
9
- import { validateCustomNode, validateFields, validateNode } from "./validate.js";
11
+ import { SLUG_GRAMMAR_MESSAGE, validateCustomNode, validateFields, validateNode } from "./validate.js";
10
12
  import { serializeAdminConfig } from "./serialize.js";
11
13
  import { entryDrift, pageDrift } from "./drift.js";
12
14
  import { publishErrors } from "./drift-copy.js";
@@ -15,7 +17,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
15
17
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
16
18
  import { withDefaults } from "../field-defaults.js";
17
19
  import { elementSchemas } from "../richtext-walk.js";
18
- import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
20
+ import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "./redirect-path.js";
19
21
  import { SCHEMA_VERSION } from "../schema-version.js";
20
22
  import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
21
23
  const ok = (data) => ({ ok: true, data });
@@ -305,6 +307,66 @@ export function createAdminOps(deps) {
305
307
  ? err("blocked", `smoodly: pages cannot be created under "${nameOf(parent)}" — the "${fixed.collection}" collection owns that URL space.`)
306
308
  : null;
307
309
  };
310
+ /** Who wins a path in a locale over a node hanging on `own` — its chain root's owner key, `''` in the editors'
311
+ * tree (spec 2026-09-29 anchored paths §5): the longest code address the path is or lies below, as the index
312
+ * serves it (§2). Null when that is the node's own anchor, or the editors' tree. */
313
+ const ownerOf = (path, locale, own = "") => {
314
+ const served = servedAnchor(config.anchors, locale, path);
315
+ return served === "" || served === own ? null : fixedNodeSpecs(config).find((s) => s.key === served) ?? null;
316
+ };
317
+ const pathOpts = { locales: config.locales, homePageSlug: config.homePageSlug, addresses: config.addresses };
318
+ /** A record's own anchor: its chain root's owner key, `''` in the editors' tree. */
319
+ const anchorOf = async (record) => {
320
+ let r = record;
321
+ while (r.parentId !== null) {
322
+ const up = await pages.getPage(r.parentId);
323
+ if (!up)
324
+ break;
325
+ r = up;
326
+ }
327
+ return r.fixed ?? "";
328
+ };
329
+ /** The refusal for a write that would put the node or its subtree where another code address wins (spec 2026-09-29
330
+ * anchored paths §5): the tree after `change`, derived in memory, checked against the config. Only a path the write
331
+ * changes counts: a page already hidden in one locale may still be renamed in another, or off the address. */
332
+ const landsOnCode = async (id, change) => {
333
+ const before = await pages.listPages();
334
+ const was = new Map(before.map((r) => [r.id, r]));
335
+ const all = before.map((r) => (r.id === id ? change(r) : r));
336
+ const byId = new Map(all.map((r) => [r.id, r]));
337
+ // A move into its own subtree is a cycle here; the store refuses it with its own message.
338
+ for (let up = byId.get(id).parentId; up !== null; up = byId.get(up)?.parentId ?? null)
339
+ if (up === id)
340
+ return null;
341
+ const inSubtree = (r) => r.id === id || (r.parentId !== null && byId.has(r.parentId) && inSubtree(byId.get(r.parentId)));
342
+ for (const node of all.filter(inSubtree)) {
343
+ const own = chainOf(node, (x) => byId.get(x))[0].fixed ?? "";
344
+ for (const locale of Object.keys(node.locales)) {
345
+ const path = pagePathIn(node, (x) => byId.get(x), locale, pathOpts);
346
+ if (path === null || path === pagePathIn(was.get(node.id), (x) => was.get(x), locale, pathOpts))
347
+ continue;
348
+ const owner = ownerOf(path, locale, own);
349
+ if (owner)
350
+ return belongsTo(path, owner);
351
+ }
352
+ }
353
+ return null;
354
+ };
355
+ const belongsTo = (path, owner) => err("validation", `smoodly: "${path}" belongs to the ${owner.title} ${owner.collection ? "collection" : "page"}.`);
356
+ /** A parent where another code address wins — a page that sits there, hidden (§4) — takes no new children. */
357
+ const insideCode = async (parent) => {
358
+ const own = await anchorOf(parent);
359
+ for (const [locale, path] of Object.entries(await pages.pathsOf(parent.id))) {
360
+ const owner = ownerOf(path, locale, own);
361
+ if (owner)
362
+ return belongsTo(`/${owner.segments[locale]}`, owner);
363
+ }
364
+ return null;
365
+ };
366
+ /** A record whose owner no registration names any more: deleting it is all that is open (§5). */
367
+ const orphan = (record) => record.fixed && !fixedNodeOf(record, config)
368
+ ? err("blocked", `smoodly: "${nameOf(record)}" is no longer in code — delete it, or register it again.`)
369
+ : null;
308
370
  const guard = async (work) => {
309
371
  try {
310
372
  return await work();
@@ -313,6 +375,8 @@ export function createAdminOps(deps) {
313
375
  return classify(e);
314
376
  }
315
377
  };
378
+ /** Where a new child may not go: a collection's record, a record no longer in code, a page where a code address wins. */
379
+ const parentGuard = async (parent) => mountGuard(parent) ?? orphan(parent) ?? (await insideCode(parent));
316
380
  return {
317
381
  pages: {
318
382
  list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
@@ -350,17 +414,26 @@ export function createAdminOps(deps) {
350
414
  return err("validation", `smoodly: page type "${input.template}" is fixed — Smoodly creates that page for you.`);
351
415
  }
352
416
  }
417
+ let parentPath = "";
418
+ let own = "";
353
419
  if (input.parentId !== null) {
354
420
  const parent = await pages.getPage(input.parentId);
355
421
  if (!parent)
356
422
  return err("not_found", `smoodly: no page "${input.parentId}".`);
357
- const blocked = mountGuard(parent);
423
+ const blocked = await parentGuard(parent);
358
424
  if (blocked)
359
425
  return blocked;
426
+ parentPath = (await pages.pathsOf(parent.id))[input.locale];
427
+ own = await anchorOf(parent);
360
428
  }
361
- else {
362
- // A root create competes with the code-owned slugs: make sure they exist first.
363
- await ensureFixedNodes(pages, config);
429
+ // A code address is code's whether or not its record has landed yet (spec 2026-09-29 anchored paths §5).
430
+ if (parentPath !== undefined) {
431
+ // A root on the home slug serves "/" (isHomeNode), where a registered home page wins.
432
+ const home = input.parentId === null && input.locale === def && input.slug === config.homePageSlug;
433
+ const path = home ? "/" : `${parentPath}/${input.slug}`;
434
+ const owner = ownerOf(path, input.locale, own);
435
+ if (owner)
436
+ return belongsTo(path, owner);
364
437
  }
365
438
  const created = await pages.createPage({
366
439
  locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
@@ -445,6 +518,9 @@ export function createAdminOps(deps) {
445
518
  return err("not_found", `smoodly: no page "${id}".`);
446
519
  if (!record.locales[locale])
447
520
  return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
521
+ const gone = orphan(record);
522
+ if (gone)
523
+ return gone;
448
524
  if (isHomeNode(record))
449
525
  return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
450
526
  if (fixedNodeOf(record, config))
@@ -512,7 +588,8 @@ export function createAdminOps(deps) {
512
588
  if (fixedNodeOf(record, config)) {
513
589
  return err("blocked", `smoodly: "${nameOf(record)}" is owned by code — remove the registration to remove it.`);
514
590
  }
515
- if (isHomeNode(record))
591
+ // A home page no longer in code may go too (§5): the registration that serves "/" now lands after it.
592
+ if (isHomeNode(record) && !orphan(record))
516
593
  return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be deleted.`);
517
594
  await pages.deletePage(id);
518
595
  // The cached unit is keyed by (locale, path) but tagged by the id
@@ -525,6 +602,9 @@ export function createAdminOps(deps) {
525
602
  const record = await pages.getPage(id);
526
603
  if (!record)
527
604
  return err("not_found", `smoodly: no page "${id}".`);
605
+ const gone = orphan(record);
606
+ if (gone)
607
+ return gone;
528
608
  if (fixedNodeOf(record, config))
529
609
  return owned(record, "rename it in the registration");
530
610
  if (isHomeNode(record) && patch.slug !== undefined) {
@@ -535,6 +615,12 @@ export function createAdminOps(deps) {
535
615
  return unsupported;
536
616
  if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
537
617
  return slugTaken();
618
+ if (patch.slug !== undefined) {
619
+ const slug = patch.slug;
620
+ const lands = await landsOnCode(id, (r) => r.locales[patch.locale] ? { ...r, locales: { ...r.locales, [patch.locale]: { ...r.locales[patch.locale], slug } } } : r);
621
+ if (lands)
622
+ return lands;
623
+ }
538
624
  const { redirect, ...rest } = patch;
539
625
  const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
540
626
  // Every descendant's path changed with this segment; the parent
@@ -547,6 +633,9 @@ export function createAdminOps(deps) {
547
633
  const record = await pages.getPage(id);
548
634
  if (!record)
549
635
  return err("not_found", `smoodly: no page "${id}".`);
636
+ const gone = orphan(record);
637
+ if (gone)
638
+ return gone;
550
639
  if (to.parentId === null && Object.values(record.locales).some((row) => adminSlug(row.slug)))
551
640
  return slugTaken();
552
641
  if (to.parentId !== null) {
@@ -559,10 +648,16 @@ export function createAdminOps(deps) {
559
648
  const parent = await pages.getPage(to.parentId);
560
649
  if (!parent)
561
650
  return err("not_found", `smoodly: no page "${to.parentId}".`);
562
- const blocked = mountGuard(parent);
651
+ // A reorder among a holding page's children is no new placement.
652
+ const blocked = to.parentId === record.parentId ? null : await parentGuard(parent);
563
653
  if (blocked)
564
654
  return blocked;
565
655
  }
656
+ if (to.parentId !== record.parentId) {
657
+ const lands = await landsOnCode(id, (r) => ({ ...r, parentId: to.parentId }));
658
+ if (lands)
659
+ return lands;
660
+ }
566
661
  const { redirect, ...dest } = to;
567
662
  const moved = await pages.move(id, dest, { redirect: redirect ?? true });
568
663
  expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
@@ -573,6 +668,9 @@ export function createAdminOps(deps) {
573
668
  const record = await pages.getPage(id);
574
669
  if (!record)
575
670
  return err("not_found", `smoodly: no page "${id}".`);
671
+ const gone = orphan(record);
672
+ if (gone)
673
+ return gone;
576
674
  const fixed = fixedNodeOf(record, config);
577
675
  if (fixed?.collection) {
578
676
  return err("blocked", `smoodly: "${nameOf(record)}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
@@ -591,8 +689,16 @@ export function createAdminOps(deps) {
591
689
  const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
592
690
  if (unsupported)
593
691
  return unsupported;
594
- if (!(await pages.getPage(id)))
692
+ const record = await pages.getPage(id);
693
+ if (!record)
595
694
  return err("not_found", `smoodly: no page "${id}".`);
695
+ const gone = orphan(record);
696
+ if (gone)
697
+ return gone;
698
+ // The new locale copies the source's slug: it may not land where code wins the address (spec 2026-09-29 anchored paths §5).
699
+ const lands = await landsOnCode(id, (r) => r.locales[options.from] && !r.locales[locale] ? { ...r, locales: { ...r.locales, [locale]: r.locales[options.from] } } : r);
700
+ if (lands)
701
+ return lands;
596
702
  // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
597
703
  const added = await pages.addLocale(id, locale, { from: options.from }, by);
598
704
  expireTree();
@@ -605,6 +711,9 @@ export function createAdminOps(deps) {
605
711
  const record = await pages.getPage(id);
606
712
  if (!record)
607
713
  return err("not_found", `smoodly: no page "${id}".`);
714
+ const gone = orphan(record);
715
+ if (gone)
716
+ return gone;
608
717
  if (fixedNodeOf(record, config))
609
718
  return owned(record, "it exists in every locale");
610
719
  if (isHomeNode(record))
@@ -675,7 +784,27 @@ export function createAdminOps(deps) {
675
784
  const descriptors = collectionFields(collection);
676
785
  if (!descriptors)
677
786
  return err("not_found", `smoodly: no collection named "${collection}".`);
678
- const entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
787
+ // A published row's slug is its live address and is never versioned, so this
788
+ // save is its publish: the grammar holds here (spec 2026-09-28 ASCII slugs §3).
789
+ const slug = typeof fields.slug === "string" && fields.slug !== "" ? fields.slug : null;
790
+ if (descriptors.slug !== undefined && (slug === null || !isSegment(slug))) {
791
+ const row = (await entries.get(collection, id))?.locales[locale];
792
+ if (row?.status === "published" && row.slug !== null && row.slug !== slug) {
793
+ const message = slug === null ? "Required while the entry is published." : SLUG_GRAMMAR_MESSAGE;
794
+ return err("validation", "Some fields need attention.", [{ field: "slug", message }]);
795
+ }
796
+ }
797
+ let entry;
798
+ try {
799
+ entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
800
+ }
801
+ catch (e) {
802
+ // A sibling's slug is the slug field's problem, not the whole draft's (spec 2026-09-28 ASCII slugs §5).
803
+ if (e instanceof Error && e.message === entryErrors.slugTaken(collection).message) {
804
+ return err("validation", "Some fields need attention.", [{ field: "slug", message: "Another entry here already uses this slug." }]);
805
+ }
806
+ throw e;
807
+ }
679
808
  await fanOut(id);
680
809
  expireCollection(collection);
681
810
  return ok(entry);
@@ -1051,7 +1180,8 @@ export function createAdminOps(deps) {
1051
1180
  }
1052
1181
  return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
1053
1182
  }),
1054
- add: (locale, path, target) => guard(async () => {
1183
+ add: (locale, typed, target) => guard(async () => {
1184
+ const path = normalizeRedirectPath(typed);
1055
1185
  const unsupported = supportedLocale(locale);
1056
1186
  if (unsupported)
1057
1187
  return unsupported;
@@ -1073,7 +1203,7 @@ export function createAdminOps(deps) {
1073
1203
  const unsupported = supportedLocale(locale);
1074
1204
  if (unsupported)
1075
1205
  return unsupported;
1076
- await pages.removeRedirect(locale, path);
1206
+ await pages.removeRedirect(locale, path); // the stored address, as list gave it
1077
1207
  return ok(null);
1078
1208
  }),
1079
1209
  },
@@ -1,3 +1,6 @@
1
+ /** The one stored form of an old address (the renderer decodes a request the same way):
2
+ * a pasted `/s%C3%A4%C3%A4` and a typed `/sää` are one row. Checked after this. */
3
+ export declare const normalizeRedirectPath: (path: string) => string;
1
4
  export type RedirectPathProblem = "empty" | "leading-slash" | "root" | "trailing-slash" | "double-slash" | "whitespace" | "query-or-hash" | "admin" | "extension";
2
5
  export declare const REDIRECT_PATH_MESSAGE: Record<RedirectPathProblem, string>;
3
6
  export declare function redirectPathProblem(path: string, opts: {
@@ -1,3 +1,12 @@
1
+ // The old-address check for a redirect by hand (spec 2026-09-26
2
+ // redirects §5). Pure: the Add dialog asks it for instant feedback and
3
+ // the op asks it again. The shape is "a path the catch-all will see":
4
+ // segments need not be slugs — old sites had capitals, dots and
5
+ // underscores — but nothing here may be unreachable by construction.
6
+ import { decodeSegment } from "../paths.js";
7
+ /** The one stored form of an old address (the renderer decodes a request the same way):
8
+ * a pasted `/s%C3%A4%C3%A4` and a typed `/sää` are one row. Checked after this. */
9
+ export const normalizeRedirectPath = (path) => path.split("/").map(decodeSegment).join("/");
1
10
  export const REDIRECT_PATH_MESSAGE = {
2
11
  empty: "Enter the old address.",
3
12
  "leading-slash": "Start with a slash: /old-address.",
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
+ import type { Addresses } from "../addresses.ts";
2
3
  import type { Descriptor } from "../fields.ts";
3
4
  import type { ZonePolicy } from "../zones.ts";
4
5
  export type AdminField = Record<string, unknown> & {
@@ -80,6 +81,8 @@ export type AdminRegistry = {
80
81
  css: boolean;
81
82
  /** Which slug the site root serves. */
82
83
  homePageSlug: string;
84
+ /** Code-owned addresses by owner key (spec 2026-09-29 nested paths §3). */
85
+ addresses: Addresses;
83
86
  /** How deep editors may nest pages; 1 = flat. */
84
87
  pageDepth: number;
85
88
  /** Upload limits, so a widget can refuse a file before the round trip. */
@@ -106,6 +106,7 @@ export function serializeAdminConfig(config) {
106
106
  locales: config.locales,
107
107
  ...(config.timeZone !== undefined ? { timeZone: config.timeZone } : {}),
108
108
  homePageSlug: config.homePageSlug,
109
+ addresses: config.addresses,
109
110
  pageDepth: config.pageDepth,
110
111
  media: { maxSize: config.media.maxSize, accept: [...config.media.accept] },
111
112
  adminPath: config.admin.path,
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // before the op repeats the check, and the target — one type-ahead over
6
6
  // every page and routed entry live in the locale, "Title · /path".
7
7
  import { useState } from "react";
8
- import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "../redirect-path.js";
8
+ import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "../redirect-path.js";
9
9
  import { Combobox } from "../ui/Combobox.js";
10
10
  import { Modal } from "../ui/Modal.js";
11
11
  import { Banner, Button, Field } from "../ui/primitives.js";
@@ -15,7 +15,7 @@ export function AddRedirectModal({ locale, registry, targets, onClose, onAdd })
15
15
  const [target, setTarget] = useState(null);
16
16
  const [error, setError] = useState(null);
17
17
  const [busy, setBusy] = useState(false);
18
- const problem = redirectPathProblem(path, { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
18
+ const problem = redirectPathProblem(normalizeRedirectPath(path), { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
19
19
  const picked = target ? targets.find((t) => t.target.kind === target.kind && t.target.id === target.id) : undefined;
20
20
  const submit = async (e) => {
21
21
  e.preventDefault();
@@ -48,8 +48,11 @@ import { unsetAtPath } from "../field-paths.js";
48
48
  import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
49
49
  import { editorsOf } from "./presence.js";
50
50
  import { PresenceLine } from "../ui/PresenceLine.js";
51
+ import { slugHint, tidySlug, withFollowingSlug } from "../forms/slug-field.js";
51
52
  /** A stable empty map for the hooks that run before the "no such collection" return. */
52
53
  const NO_FIELDS = {};
54
+ /** True while the entry's slug box has focus: its field wrapper carries the path. */
55
+ const slugBoxFocused = () => typeof document !== "undefined" && document.activeElement instanceof HTMLElement && document.activeElement.closest('[data-sm-path="slug"]') !== null;
53
56
  export function EntryForm({ ops, registry, collection, id }) {
54
57
  const adminHref = useAdminHref();
55
58
  const meta = registry.collections.find((c) => c.name === collection);
@@ -62,6 +65,9 @@ export function EntryForm({ ops, registry, collection, id }) {
62
65
  const [parentLocales, setParentLocales] = useState(registry.locales.supported);
63
66
  const [notice, setNotice] = useState(null);
64
67
  const [fieldErrors, setFieldErrors] = useState({});
68
+ /** A refused save's field errors (spec 2026-09-28 ASCII slugs): kept apart from the publish refusal's, which
69
+ * revalidation shrinks — a sibling's slug is no rule the form can check. Cleared by the next save that lands. */
70
+ const [saveErrors, setSaveErrors] = useState({});
65
71
  const [adding, setAdding] = useState(false);
66
72
  const [reloadKey, setReloadKey] = useState(0);
67
73
  /** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
@@ -84,6 +90,8 @@ export function EntryForm({ ops, registry, collection, id }) {
84
90
  const confirmedSlug = useRef(null);
85
91
  /** The confirm's "Redirect the old address" answer for the save that follows it (spec 2026-09-26 redirects §6). */
86
92
  const redirectRef = useRef(true);
93
+ /** A live slug change the autosave held back while its box had focus (spec 2026-09-28 ASCII slugs §4). */
94
+ const slugHeld = useRef(false);
87
95
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
88
96
  /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
89
97
  const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
@@ -98,7 +106,23 @@ export function EntryForm({ ops, registry, collection, id }) {
98
106
  // A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
99
107
  const row = recordRef.current?.locales[locale];
100
108
  const stored = row?.slug ?? null;
101
- const next = typeof value.slug === "string" ? value.slug : null;
109
+ let next = typeof value.slug === "string" ? value.slug : null;
110
+ // A live slug changes when its box loses focus, never at a pause mid-typing (spec
111
+ // 2026-09-28 ASCII slugs §4): until then the rest of the draft saves with the live slug.
112
+ if (liveUrlChanges({ status: row?.status, from: stored, to: next }) && slugBoxFocused()) {
113
+ slugHeld.current = true;
114
+ value = { ...value, slug: stored };
115
+ next = stored;
116
+ }
117
+ // Tidied before the question, so the dialog asks about the URL that is saved.
118
+ if (next !== null && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
119
+ const tidy = tidySlug(next);
120
+ if (tidy !== next) {
121
+ next = tidy;
122
+ value = { ...value, slug: tidy };
123
+ setFields((f) => ({ ...(f ?? {}), slug: tidy }));
124
+ }
125
+ }
102
126
  if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
103
127
  const current = recordRef.current;
104
128
  const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
@@ -116,13 +140,19 @@ export function EntryForm({ ops, registry, collection, id }) {
116
140
  if (r.ok) {
117
141
  redirectRef.current = true;
118
142
  setRecord(r.data);
143
+ setSaveErrors({});
119
144
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
120
145
  }
121
146
  else if (isStale(r))
122
147
  setStale(staleInfoOf(r));
148
+ // A refused save (a sibling's slug, a live slug outside the grammar) marks its fields until a save lands.
149
+ else if (r.code === "validation" && r.fields)
150
+ setSaveErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
123
151
  return r;
124
152
  },
125
- onSaved: () => setDirty(false),
153
+ // A held slug is still unsaved: the form stays dirty until its box loses focus.
154
+ onSaved: () => { if (!slugHeld.current)
155
+ setDirty(false); },
126
156
  });
127
157
  // Reload and close ask only while a save is pending or in flight.
128
158
  useLeaveGuard(dirty || saveState !== "saved");
@@ -143,6 +173,8 @@ export function EntryForm({ ops, registry, collection, id }) {
143
173
  let cancelled = false;
144
174
  setFields(null);
145
175
  setFieldErrors({});
176
+ setSaveErrors({});
177
+ slugHeld.current = false;
146
178
  setDirty(false);
147
179
  setStale(null);
148
180
  setTouched(false);
@@ -167,7 +199,7 @@ export function EntryForm({ ops, registry, collection, id }) {
167
199
  const findings = useMemo(() => (fields && record?.locales[locale]
168
200
  ? entryDrift(registry, collection, { fields, shared: record.fields, own: record.locales[locale].fields })
169
201
  : []), [fields, record, locale, registry, collection]);
170
- const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors }), [findings, fieldErrors]);
202
+ const markerErrors = useMemo(() => ({ ...misfitErrors(findings), ...fieldErrors, ...saveErrors }), [findings, fieldErrors, saveErrors]);
171
203
  if (!meta)
172
204
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
173
205
  const row = record?.locales[locale];
@@ -418,8 +450,16 @@ export function EntryForm({ ops, registry, collection, id }) {
418
450
  return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
419
451
  };
420
452
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
421
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
422
- setFields((f) => ({ ...(f ?? {}), [key]: value }));
453
+ // f.slug({ from }) (spec 2026-09-28 ASCII slugs §5–§6): the field a never-published slug follows.
454
+ const declaredFrom = meta.fields.slug?.from;
455
+ const slugFrom = typeof declaredFrom === "string" ? declaredFrom : undefined;
456
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: markerErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, hint: key === "slug" && slugFrom && !fields?.slug ? slugHint(fields?.[slugFrom]) : undefined, onBlur: key === "slug" ? () => { if (slugHeld.current) {
457
+ slugHeld.current = false;
458
+ setDirty(true);
459
+ mark();
460
+ } } : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
461
+ const neverPublished = (recordRef.current?.locales[locale]?.publishedVersionId ?? null) === null;
462
+ setFields((f) => withFollowingSlug(f ?? {}, key, value, slugFrom, neverPublished));
423
463
  setDirty(true);
424
464
  setTouched(true);
425
465
  mark();
@@ -431,7 +471,7 @@ export function EntryForm({ ops, registry, collection, id }) {
431
471
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
432
472
  },
433
473
  { label: heading },
434
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
474
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
435
475
  revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [Object.entries(meta.fields).map(widget), _jsx(OrphanFields, { orphans: findings, onClear: (path) => {
436
476
  setFields((f) => unsetAtPath(f ?? {}, path));
437
477
  setDirty(true);
@@ -318,6 +318,6 @@ export function GlobalForm({ ops, registry, name }) {
318
318
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
319
319
  },
320
320
  { label: item.title },
321
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
321
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
322
322
  revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
323
323
  }