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.
- package/README.md +71 -17
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +4 -2
- package/dist/admin/editor/PageSettings.js +2 -1
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +121 -12
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/ui/theme.js +1 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/next/page-renderer.js +2 -2
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +25 -12
- package/dist/paths.js +59 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql.js +35 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/package.json +1 -1
package/dist/admin/ops-impl.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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. */
|
package/dist/admin/serialize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ? "▾" : "▸" }),
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
14
|
-
*
|
|
15
|
-
export declare function mountOf(record:
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
|
21
|
-
|
|
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:
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|