smoodly 0.0.33 → 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 (62) hide show
  1. package/README.md +71 -17
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +4 -2
  5. package/dist/admin/editor/PageSettings.js +2 -1
  6. package/dist/admin/editor/useCanvasBridge.js +25 -2
  7. package/dist/admin/fixed-nodes.d.ts +9 -24
  8. package/dist/admin/fixed-nodes.js +36 -72
  9. package/dist/admin/health.js +9 -0
  10. package/dist/admin/next/create-admin.d.ts +4 -3
  11. package/dist/admin/next/create-admin.js +14 -3
  12. package/dist/admin/ops-impl.js +121 -12
  13. package/dist/admin/redirect-path.d.ts +3 -0
  14. package/dist/admin/redirect-path.js +9 -0
  15. package/dist/admin/serialize.d.ts +3 -0
  16. package/dist/admin/serialize.js +1 -0
  17. package/dist/admin/shell/AddRedirectModal.js +2 -2
  18. package/dist/admin/shell/PagesList.js +29 -12
  19. package/dist/admin/shell/live-url.d.ts +3 -1
  20. package/dist/admin/shell/live-url.js +5 -2
  21. package/dist/admin/shell/pages-tree.d.ts +19 -14
  22. package/dist/admin/shell/pages-tree.js +49 -16
  23. package/dist/admin/ui/theme.js +1 -0
  24. package/dist/anchors.d.ts +42 -0
  25. package/dist/anchors.js +72 -0
  26. package/dist/cli/export.js +3 -1
  27. package/dist/cli/import.d.ts +1 -0
  28. package/dist/cli/import.js +13 -2
  29. package/dist/cli/space-io.d.ts +1 -2
  30. package/dist/cli/space-io.js +5 -5
  31. package/dist/collections.js +5 -0
  32. package/dist/config.d.ts +6 -0
  33. package/dist/config.js +53 -18
  34. package/dist/current-space.d.ts +4 -0
  35. package/dist/current-space.js +9 -0
  36. package/dist/entry-store.js +4 -2
  37. package/dist/env.d.ts +4 -3
  38. package/dist/env.js +15 -17
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/next/page-renderer.js +2 -2
  42. package/dist/path-index.d.ts +11 -0
  43. package/dist/path-index.js +52 -25
  44. package/dist/paths.d.ts +25 -12
  45. package/dist/paths.js +59 -21
  46. package/dist/schema-version.d.ts +1 -1
  47. package/dist/schema-version.js +1 -1
  48. package/dist/site.d.ts +7 -3
  49. package/dist/site.js +26 -15
  50. package/dist/space-transfer.d.ts +2 -0
  51. package/dist/space-transfer.js +8 -1
  52. package/dist/sql.js +35 -9
  53. package/dist/store.d.ts +15 -1
  54. package/dist/store.js +27 -6
  55. package/dist/supabase-entry-store.d.ts +0 -1
  56. package/dist/supabase-entry-store.js +6 -11
  57. package/dist/supabase-path-index.d.ts +12 -1
  58. package/dist/supabase-path-index.js +56 -19
  59. package/dist/supabase-store.d.ts +2 -0
  60. package/dist/supabase-store.js +30 -9
  61. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  62. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
- import { isSegment } from "../paths.js";
2
+ import { chainOf, isSegment, pagePathIn } from "../paths.js";
3
+ import { servedAnchor } from "../anchors.js";
3
4
  import { distinctTags } from "../tags.js";
4
5
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
5
6
  import { ASSET_COLLECTION } from "../media.js";
6
7
  import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
7
8
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
8
- import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
9
+ import { ensureFixedNodes, fixedNodeOf, fixedNodeSpecs } from "./fixed-nodes.js";
9
10
  import { ensureGlobalItems } from "./global-items.js";
10
11
  import { SLUG_GRAMMAR_MESSAGE, validateCustomNode, validateFields, validateNode } from "./validate.js";
11
12
  import { serializeAdminConfig } from "./serialize.js";
@@ -16,7 +17,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
16
17
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
17
18
  import { withDefaults } from "../field-defaults.js";
18
19
  import { elementSchemas } from "../richtext-walk.js";
19
- import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
20
+ import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "./redirect-path.js";
20
21
  import { SCHEMA_VERSION } from "../schema-version.js";
21
22
  import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
22
23
  const ok = (data) => ({ ok: true, data });
@@ -306,6 +307,66 @@ export function createAdminOps(deps) {
306
307
  ? err("blocked", `smoodly: pages cannot be created under "${nameOf(parent)}" — the "${fixed.collection}" collection owns that URL space.`)
307
308
  : null;
308
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;
309
370
  const guard = async (work) => {
310
371
  try {
311
372
  return await work();
@@ -314,6 +375,8 @@ export function createAdminOps(deps) {
314
375
  return classify(e);
315
376
  }
316
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));
317
380
  return {
318
381
  pages: {
319
382
  list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
@@ -351,17 +414,26 @@ export function createAdminOps(deps) {
351
414
  return err("validation", `smoodly: page type "${input.template}" is fixed — Smoodly creates that page for you.`);
352
415
  }
353
416
  }
417
+ let parentPath = "";
418
+ let own = "";
354
419
  if (input.parentId !== null) {
355
420
  const parent = await pages.getPage(input.parentId);
356
421
  if (!parent)
357
422
  return err("not_found", `smoodly: no page "${input.parentId}".`);
358
- const blocked = mountGuard(parent);
423
+ const blocked = await parentGuard(parent);
359
424
  if (blocked)
360
425
  return blocked;
426
+ parentPath = (await pages.pathsOf(parent.id))[input.locale];
427
+ own = await anchorOf(parent);
361
428
  }
362
- else {
363
- // A root create competes with the code-owned slugs: make sure they exist first.
364
- 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);
365
437
  }
366
438
  const created = await pages.createPage({
367
439
  locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
@@ -446,6 +518,9 @@ export function createAdminOps(deps) {
446
518
  return err("not_found", `smoodly: no page "${id}".`);
447
519
  if (!record.locales[locale])
448
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;
449
524
  if (isHomeNode(record))
450
525
  return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
451
526
  if (fixedNodeOf(record, config))
@@ -513,7 +588,8 @@ export function createAdminOps(deps) {
513
588
  if (fixedNodeOf(record, config)) {
514
589
  return err("blocked", `smoodly: "${nameOf(record)}" is owned by code — remove the registration to remove it.`);
515
590
  }
516
- 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))
517
593
  return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be deleted.`);
518
594
  await pages.deletePage(id);
519
595
  // The cached unit is keyed by (locale, path) but tagged by the id
@@ -526,6 +602,9 @@ export function createAdminOps(deps) {
526
602
  const record = await pages.getPage(id);
527
603
  if (!record)
528
604
  return err("not_found", `smoodly: no page "${id}".`);
605
+ const gone = orphan(record);
606
+ if (gone)
607
+ return gone;
529
608
  if (fixedNodeOf(record, config))
530
609
  return owned(record, "rename it in the registration");
531
610
  if (isHomeNode(record) && patch.slug !== undefined) {
@@ -536,6 +615,12 @@ export function createAdminOps(deps) {
536
615
  return unsupported;
537
616
  if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
538
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
+ }
539
624
  const { redirect, ...rest } = patch;
540
625
  const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
541
626
  // Every descendant's path changed with this segment; the parent
@@ -548,6 +633,9 @@ export function createAdminOps(deps) {
548
633
  const record = await pages.getPage(id);
549
634
  if (!record)
550
635
  return err("not_found", `smoodly: no page "${id}".`);
636
+ const gone = orphan(record);
637
+ if (gone)
638
+ return gone;
551
639
  if (to.parentId === null && Object.values(record.locales).some((row) => adminSlug(row.slug)))
552
640
  return slugTaken();
553
641
  if (to.parentId !== null) {
@@ -560,10 +648,16 @@ export function createAdminOps(deps) {
560
648
  const parent = await pages.getPage(to.parentId);
561
649
  if (!parent)
562
650
  return err("not_found", `smoodly: no page "${to.parentId}".`);
563
- 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);
564
653
  if (blocked)
565
654
  return blocked;
566
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
+ }
567
661
  const { redirect, ...dest } = to;
568
662
  const moved = await pages.move(id, dest, { redirect: redirect ?? true });
569
663
  expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
@@ -574,6 +668,9 @@ export function createAdminOps(deps) {
574
668
  const record = await pages.getPage(id);
575
669
  if (!record)
576
670
  return err("not_found", `smoodly: no page "${id}".`);
671
+ const gone = orphan(record);
672
+ if (gone)
673
+ return gone;
577
674
  const fixed = fixedNodeOf(record, config);
578
675
  if (fixed?.collection) {
579
676
  return err("blocked", `smoodly: "${nameOf(record)}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
@@ -592,8 +689,16 @@ export function createAdminOps(deps) {
592
689
  const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
593
690
  if (unsupported)
594
691
  return unsupported;
595
- if (!(await pages.getPage(id)))
692
+ const record = await pages.getPage(id);
693
+ if (!record)
596
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;
597
702
  // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
598
703
  const added = await pages.addLocale(id, locale, { from: options.from }, by);
599
704
  expireTree();
@@ -606,6 +711,9 @@ export function createAdminOps(deps) {
606
711
  const record = await pages.getPage(id);
607
712
  if (!record)
608
713
  return err("not_found", `smoodly: no page "${id}".`);
714
+ const gone = orphan(record);
715
+ if (gone)
716
+ return gone;
609
717
  if (fixedNodeOf(record, config))
610
718
  return owned(record, "it exists in every locale");
611
719
  if (isHomeNode(record))
@@ -1072,7 +1180,8 @@ export function createAdminOps(deps) {
1072
1180
  }
1073
1181
  return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
1074
1182
  }),
1075
- add: (locale, path, target) => guard(async () => {
1183
+ add: (locale, typed, target) => guard(async () => {
1184
+ const path = normalizeRedirectPath(typed);
1076
1185
  const unsupported = supportedLocale(locale);
1077
1186
  if (unsupported)
1078
1187
  return unsupported;
@@ -1094,7 +1203,7 @@ export function createAdminOps(deps) {
1094
1203
  const unsupported = supportedLocale(locale);
1095
1204
  if (unsupported)
1096
1205
  return unsupported;
1097
- await pages.removeRedirect(locale, path);
1206
+ await pages.removeRedirect(locale, path); // the stored address, as list gave it
1098
1207
  return ok(null);
1099
1208
  }),
1100
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();
@@ -9,7 +9,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
9
9
  // absent from the selected locale is dimmed and offers "Add <locale>".
10
10
  import { useCallback, useEffect, useState } from "react";
11
11
  import { ListView } from "./ListView.js";
12
- import { canDragRow, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
12
+ import { canDragRow, codeAddressNote, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
13
13
  import { Button, StatusBadge } from "../ui/primitives.js";
14
14
  import { statusLabel } from "../ui/status-label.js";
15
15
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
@@ -68,7 +68,7 @@ export function PagesList({ ops, registry }) {
68
68
  return;
69
69
  // A published page under a new parent is a new live URL (members management §5).
70
70
  const current = row.record.locales[locale];
71
- const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path });
71
+ const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path }, codeAddressNote(row.path, registry, locale));
72
72
  if (!live.ok)
73
73
  return;
74
74
  const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index, redirect: live.redirect }));
@@ -151,7 +151,7 @@ export function PagesList({ ops, registry }) {
151
151
  // The site root's slug is the config's homePageSlug — title only there.
152
152
  if (row.isHome)
153
153
  return attempt(() => ops.pages.rename(row.id, { locale, title: values.title }));
154
- const live = await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path });
154
+ const live = await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path }, codeAddressNote(row.path, registry, locale));
155
155
  if (!live.ok)
156
156
  return null;
157
157
  return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug, redirect: live.redirect }));
@@ -196,8 +196,16 @@ export function PagesList({ ops, registry }) {
196
196
  };
197
197
  const menuItems = (row) => {
198
198
  const items = [];
199
+ // A record no longer in code (spec 2026-09-29 nested paths §5): deleting it is all that is open.
200
+ if (row.orphan) {
201
+ if (!row.hasChildren)
202
+ items.push({ label: "Delete", run: () => destroy(row) });
203
+ return items;
204
+ }
199
205
  if (!row.present) {
200
- items.push({ label: `Add ${locale}`, run: () => addLocale(row) });
206
+ // A code-owned record absent here waits for its address in this locale (an editor's page holds it, §5).
207
+ if (!row.fixed)
208
+ items.push({ label: `Add ${locale}`, run: () => addLocale(row) });
201
209
  return items;
202
210
  }
203
211
  if (row.canHaveChildren && !noOpen)
@@ -221,8 +229,10 @@ export function PagesList({ ops, registry }) {
221
229
  // Only a mount with no index page has nowhere else to go, so it opens
222
230
  // the collection list. The collection itself lives on the rail.
223
231
  const onRow = (row) => {
224
- if (!row.present)
225
- addLocale(row);
232
+ if (!row.present) {
233
+ if (!row.orphan && !row.fixed)
234
+ addLocale(row);
235
+ }
226
236
  else if (row.template !== null)
227
237
  openEditor(row.id);
228
238
  else if (row.kind === "mount" && row.collection)
@@ -249,11 +259,18 @@ export function PagesList({ ops, registry }) {
249
259
  key: row.id,
250
260
  onClick: () => onRow(row),
251
261
  cells: [
252
- _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
253
- row.present
254
- ? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
255
- : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
256
- _jsx("span", { className: "sm-cell sm-cell--muted", style: dim, children: row.templateTitle }, "k"),
262
+ _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), row.conflict
263
+ ? (
264
+ // An editor's page on a code address (spec 2026-09-29 nested paths §5): the notice under its title.
265
+ // One line in the fixed-height row; the whole notice on hover.
266
+ _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx("span", { className: "sm-cell--warn", title: row.conflict, children: row.conflict })] }))
267
+ : _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
268
+ row.orphan
269
+ ? _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "p")
270
+ : row.present
271
+ ? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
272
+ : _jsx("span", { className: "sm-cell sm-cell--muted", children: row.fixed ? `not in ${locale}` : `not in ${locale} — click to add` }, "p"),
273
+ _jsx("span", { className: "sm-cell sm-cell--muted", style: dim, children: row.orphan ? "No longer in code" : row.templateTitle }, "k"),
257
274
  row.status
258
275
  ? _jsx(StatusBadge, { status: statusLabel(row.status) }, "s")
259
276
  : _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
@@ -264,7 +281,7 @@ export function PagesList({ ops, registry }) {
264
281
  position: "absolute",
265
282
  left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
266
283
  top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
267
- }, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "Owned by code \u2014 nothing to change here." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
284
+ }, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: menu?.row.orphan ? "No longer in code — move its child pages out, then delete it." : "Owned by code — nothing to change here." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
268
285
  closeDialog(); return message; }) })), dialog?.kind === "template" && (_jsx(PageFormDialog, { title: "Add page here", submitLabel: "Add page", fields: { template: true }, templates: creatable, onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
269
286
  closeDialog(); return message; }) })), dialog?.kind === "rename" && (_jsx(PageFormDialog, { title: "Rename page", submitLabel: "Rename", fields: { title: true, slug: !dialog.row.isHome }, initial: { title: dialog.row.record.locales[locale]?.title ?? "", slug: dialog.row.record.locales[locale]?.slug ?? "" }, parentPath: dialog.row.isHome ? undefined : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
270
287
  closeDialog(); return message; }) }))] }));
@@ -12,7 +12,9 @@ export declare function liveUrlChanges(input: {
12
12
  to: string | null;
13
13
  }): boolean;
14
14
  /** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
15
- export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject): Promise<{
15
+ export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject,
16
+ /** A line after the body: the code addresses below the page that stay put (spec 2026-09-29 nested paths §3). */
17
+ note?: string): Promise<{
16
18
  ok: boolean;
17
19
  redirect: boolean;
18
20
  }>;
@@ -16,9 +16,12 @@ export function liveUrlChanges(input) {
16
16
  return input.status === "published" && input.from !== null && input.to !== input.from;
17
17
  }
18
18
  /** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
19
- export async function confirmLiveUrl(input, subject) {
19
+ export async function confirmLiveUrl(input, subject,
20
+ /** A line after the body: the code addresses below the page that stay put (spec 2026-09-29 nested paths §3). */
21
+ note) {
20
22
  if (!liveUrlChanges(input))
21
23
  return { ok: true, redirect: true };
22
- const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, option: { ...LIVE_URL_OPTION }, subject });
24
+ const body = note ? `${LIVE_URL_CONFIRM.body} ${note}` : LIVE_URL_CONFIRM.body;
25
+ const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, body, option: { ...LIVE_URL_OPTION }, subject });
23
26
  return { ok: yes, redirect: checked };
24
27
  }
@@ -1,20 +1,21 @@
1
1
  import type { PageLocale, PageRecord } from "../../store.ts";
2
2
  import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
3
3
  import type { Projection } from "../ui/useSortable.ts";
4
- type NodeLike = {
5
- parentId: string | null;
6
- locales: Record<string, {
7
- slug: string;
8
- }>;
9
- };
10
4
  /** Registrations editors may create pages against: fixed ones are
11
5
  * materialized by Smoodly, never offered. */
12
6
  export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
13
- /** The collection a ROOT record is the mount of, if any — known from the
14
- * registry, never from a column (spec 2026-09-05 §4). */
15
- export declare function mountOf(record: NodeLike, registry: AdminRegistry): AdminCollection | null;
16
- /** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
17
- export declare function isFixedNode(record: NodeLike, registry: AdminRegistry): boolean;
7
+ /** The collection a record is the address of — by owner, never by slug, while code still gives
8
+ * it an address (spec 2026-09-29 nested paths §3). */
9
+ export declare function mountOf(record: {
10
+ fixed?: string | null;
11
+ }, registry: AdminRegistry): AdminCollection | null;
12
+ /** Fixed pages and mounts: owned by a registration that still exists. Never renamed, moved,
13
+ * deleted or stripped of a locale by editors. */
14
+ export declare function isFixedNode(record: {
15
+ fixed?: string | null;
16
+ }, registry: AdminRegistry): boolean;
17
+ /** The rename/move confirm's extra line (spec 2026-09-29 nested paths §3): code addresses below a page stay put. */
18
+ export declare function codeAddressNote(path: string | null, registry: AdminRegistry, locale: string): string | undefined;
18
19
  /** A node's title for display in a locale it may not exist in: its own,
19
20
  * else the default locale's, else any. Display only — never content. */
20
21
  export declare function displayTitle(record: PageRecord, locale: string, registry: AdminRegistry): string;
@@ -46,6 +47,10 @@ export type PagesTreeRow = {
46
47
  kind: "page" | "folder" | "mount";
47
48
  collection: string | null;
48
49
  fixed: boolean;
50
+ /** The notice on an editor's page that sits on a code address in this locale (spec 2026-09-29 nested paths §5). */
51
+ conflict: string | null;
52
+ /** A code-owned record no registration names any more: it has no path and may only be deleted. */
53
+ orphan: boolean;
49
54
  /** The record serving "/" — never moved or deleted, and its default-locale slug is the config's. */
50
55
  isHome: boolean;
51
56
  template: string | null;
@@ -58,8 +63,9 @@ export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegi
58
63
  export type TreeProjection = Projection;
59
64
  /** Every row drags: root order is the listing's order, nothing code owns.
60
65
  * Fixed nodes and the home page stay at the root — projectDrop refuses
61
- * them any deeper slot, the move op refuses any other parent. */
62
- export declare const canDragRow: (_row: PagesTreeRow) => boolean;
66
+ * them any deeper slot, the move op refuses any other parent. A record
67
+ * no longer in code does not move at all: deleting it is all that is open. */
68
+ export declare const canDragRow: (row: PagesTreeRow) => boolean;
63
69
  /** Whether a row may only reorder among the root siblings. */
64
70
  export declare const rootOnly: (row: PagesTreeRow) => boolean;
65
71
  /** Levels below a node: 0 for a leaf. From the records, because the
@@ -78,4 +84,3 @@ export declare function subtreeHeight(records: {
78
84
  * code-owned or the site root.
79
85
  */
80
86
  export declare function projectDrop(rows: PagesTreeRow[], from: number, slot: number, xDrift: number, height: number, pageDepth: number, indent?: number): TreeProjection | null;
81
- export {};
@@ -6,26 +6,54 @@
6
6
  // an "Add" action. The shell is not DOM-tested; every decision that
7
7
  // matters lives here.
8
8
  import { buildPageTree } from "../../paths.js";
9
+ import { ownerCollection, pageKey } from "../../addresses.js";
9
10
  /** Registrations editors may create pages against: fixed ones are
10
11
  * materialized by Smoodly, never offered. */
11
12
  export function openTemplates(templates) {
12
13
  return templates.filter((t) => t.slug === undefined);
13
14
  }
14
- /** The default-locale slug that identifies a ROOT node to the registry;
15
- * a root that exists in another locale only is never code-owned. */
16
- const rootSlug = (record, registry) => record.parentId === null ? record.locales[registry.locales.default]?.slug : undefined;
17
- /** The collection a ROOT record is the mount of, if any — known from the
18
- * registry, never from a column (spec 2026-09-05 §4). */
15
+ /** The collection a record is the address of — by owner, never by slug, while code still gives
16
+ * it an address (spec 2026-09-29 nested paths §3). */
19
17
  export function mountOf(record, registry) {
20
- const slug = rootSlug(record, registry);
21
- if (slug === undefined)
22
- return null;
23
- return registry.collections.find((c) => c.path?.[registry.locales.default] === slug) ?? null;
18
+ const name = record.fixed && registry.addresses[record.fixed] ? ownerCollection(record.fixed) : null;
19
+ return name === null ? null : registry.collections.find((c) => c.name === name) ?? null;
24
20
  }
25
- /** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
21
+ /** Fixed pages and mounts: owned by a registration that still exists. Never renamed, moved,
22
+ * deleted or stripped of a locale by editors. */
26
23
  export function isFixedNode(record, registry) {
27
- const slug = rootSlug(record, registry);
28
- return slug !== undefined && (registry.pages.some((t) => t.slug === slug) || mountOf(record, registry) !== null);
24
+ return !!record.fixed && registry.addresses[record.fixed] !== undefined;
25
+ }
26
+ /** Who owns `path` in a locale: a code address other than the home page's, which serves "/". */
27
+ function ownerAt(path, registry, locale) {
28
+ for (const [key, segments] of Object.entries(registry.addresses)) {
29
+ const address = segments[locale];
30
+ if (address === undefined || segments[registry.locales.default] === registry.homePageSlug || `/${address}` !== path)
31
+ continue;
32
+ const collection = ownerCollection(key);
33
+ const title = collection
34
+ ? registry.collections.find((c) => c.name === collection)?.title ?? collection
35
+ : registry.pages.find((p) => pageKey(p.name) === key)?.title ?? key;
36
+ return { key, label: collection ? `the ${title} collection` : `the ${title} page` };
37
+ }
38
+ return null;
39
+ }
40
+ /** The notice on a page that sits on another code address, which visitors get instead (spec 2026-09-29 anchored paths §4). */
41
+ function conflictNote(path, registry, locale) {
42
+ if (path === null)
43
+ return null;
44
+ const owner = ownerAt(path, registry, locale);
45
+ if (!owner)
46
+ return null;
47
+ return `${path} is set in code for ${owner.label}, so visitors don't see this page. Rename or move it.`;
48
+ }
49
+ /** The rename/move confirm's extra line (spec 2026-09-29 nested paths §3): code addresses below a page stay put. */
50
+ export function codeAddressNote(path, registry, locale) {
51
+ if (path === null)
52
+ return undefined;
53
+ const below = Object.values(registry.addresses)
54
+ .map((s) => s[locale])
55
+ .filter((a) => a !== undefined && `/${a}`.startsWith(`${path}/`));
56
+ return below.length === 0 ? undefined : below.map((a) => `/${a} stays where it is — it is set in code.`).join(" ");
29
57
  }
30
58
  /** A node's title for display in a locale it may not exist in: its own,
31
59
  * else the default locale's, else any. Display only — never content. */
@@ -54,7 +82,7 @@ export function localeStatusOf(row) {
54
82
  return row.publishedVersionId === row.draftVersionId ? "published" : "draft edits";
55
83
  }
56
84
  export function pagesTreeRows(records, registry, locale, collapsed) {
57
- const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
85
+ const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug, addresses: registry.addresses };
58
86
  const templateTitle = (name) => registry.pages.find((t) => t.name === name)?.title ?? name;
59
87
  const rows = [];
60
88
  const walk = (nodes, depth, parentId) => {
@@ -65,6 +93,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
65
93
  const mount = mountOf(r, registry);
66
94
  const isHome = r.parentId === null && r.locales[registry.locales.default]?.slug === registry.homePageSlug;
67
95
  const expanded = !collapsed.has(r.id);
96
+ const orphan = !!r.fixed && registry.addresses[r.fixed] === undefined;
68
97
  rows.push({
69
98
  id: r.id,
70
99
  parentId,
@@ -80,6 +109,8 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
80
109
  kind: mount ? "mount" : r.template === null ? "folder" : "page",
81
110
  collection: mount?.name ?? null,
82
111
  fixed: isFixedNode(r, registry),
112
+ conflict: r.fixed ? null : conflictNote(node.path, registry, locale),
113
+ orphan,
83
114
  isHome,
84
115
  template: r.template,
85
116
  templateTitle: mount
@@ -87,7 +118,8 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
87
118
  : r.template === null ? "Path" : templateTitle(r.template),
88
119
  status: !row || r.template === null ? null : localeStatusOf(row),
89
120
  // A child needs its parent in the locale (the parent rule), so an absent node offers none.
90
- canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
121
+ // A record no longer in code has no URL to give a child (spec 2026-09-29 nested paths §5).
122
+ canHaveChildren: present && !mount && !isHome && !orphan && depth + 1 < registry.pageDepth,
91
123
  record: r,
92
124
  });
93
125
  if (expanded)
@@ -99,8 +131,9 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
99
131
  }
100
132
  /** Every row drags: root order is the listing's order, nothing code owns.
101
133
  * Fixed nodes and the home page stay at the root — projectDrop refuses
102
- * them any deeper slot, the move op refuses any other parent. */
103
- export const canDragRow = (_row) => true;
134
+ * them any deeper slot, the move op refuses any other parent. A record
135
+ * no longer in code does not move at all: deleting it is all that is open. */
136
+ export const canDragRow = (row) => !row.orphan;
104
137
  /** Whether a row may only reorder among the root siblings. */
105
138
  export const rootOnly = (row) => row.fixed || row.isHome;
106
139
  /** Levels below a node: 0 for a leaf. From the records, because the