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
package/README.md CHANGED
@@ -601,6 +601,28 @@ Implemented:
601
601
  `cli-transfer`, `space-transfer.supabase`,
602
602
  `examples/site/e2e/transfer.spec.ts`.
603
603
 
604
+ - Nested addresses (spec 2026-09-29 nested paths, schema v5): a
605
+ collection `path` or fixed `slug` may have several parts
606
+ (`company/news`), each checked as a segment, a locale the map omits
607
+ filled from the default. `src/addresses.ts` turns the registry into
608
+ code addresses by owner key (`collection:<name>`, `page:<name>`),
609
+ `config.addresses`; `pages.fixed` names a code-owned record's owner,
610
+ the record stays a root with the last part as its slug, and its path
611
+ comes from the config. Anchored paths (spec 2026-09-29 anchored
612
+ paths): each `paths` row names its anchor (`pages.fixed`'s owner key,
613
+ `''` for the editors' tree) and stores only the editors' part; the
614
+ path index (`src/anchors.ts`, both adapters, `config.anchors`) joins
615
+ the address from the config, so a code change needs no write and the
616
+ live site does one lookup per request. `ensureFixedNodes` finds
617
+ records by owner and creates them over an editor's page, never
618
+ adopting it: code wins the address. The ops refuse a page write onto
619
+ or below a code address naming the owner; the Pages list flags an
620
+ editor's page there and a record no longer in code (delete only).
621
+ Tests: `addresses`, `anchors`, the path index contract,
622
+ `admin-fixed-nodes`, `admin-ops-addresses`, `admin-pages-tree`,
623
+ `site`, the page and entry store contracts,
624
+ `examples/site/e2e/entries.spec.ts`.
625
+
604
626
  Not yet: canvas drag-and-drop (list fields, the outline, manual
605
627
  collections and the pages tree drag since 2026-09-09 — spec
606
628
  `docs/superpowers/specs/2026-09-09-drag-and-drop-design.md`), undo/redo, sample/placeholder content
@@ -627,20 +649,55 @@ after both are on the registry. From the repo root:
627
649
  `prepublishOnly` rebuilds each package. The Claude Code skill
628
650
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
629
651
 
630
- ## Follow-ups (logged 2026-09-28, ASCII slugs spec)
631
-
632
- - Unicode redirect sources: a replaced site's old address may hold `ä`
633
- (`/sää-tänään`). Unchecked whether the catch-all hands `site.locate`
634
- the decoded or the percent-encoded path, so a source typed with `ä`
635
- may never match. Test it with a real old URL; normalize (decode + NFC)
636
- on both sides if it fails.
637
- - Content health does not report an entry slug outside the grammar: the
638
- scan and the markers use drift's `fits` (a slug fits if it is text), and
639
- the grammar is checked only at publish and on a published row's save.
640
- - `f.slug()` under a key other than `slug` is accepted by `collection()`
641
- but never follows its `from` and is not the entry's URL; refuse it, or
642
- say so in the Fields guide, which already says the field must be named
643
- `slug`.
652
+ ## Follow-ups (logged 2026-09-29, nested collection paths)
653
+
654
+ - `homePageSlug` is compared with a collection's whole default path, so
655
+ `"home/news"` passes boot with the home page as its middle part: the
656
+ home record serves `/`, so `/home` is no page while `/home/news` is.
657
+ Refuse an address whose first part is the home slug if a site trips on
658
+ it.
659
+ - An owner key that changes in code — a fixed page that becomes a
660
+ collection's index page (`page:x` → `collection:y`), a collection
661
+ renamed — leaves the old record "No longer in code" with its
662
+ sections and history, and a new empty record takes the address.
663
+ Carry the record over (rewrite `fixed`) when a site asks.
664
+ - An index page registration renamed (`a` → `b`) leaves the record on
665
+ template `a`: `ensureFixedNodes` only handles a folder gaining an
666
+ index page, and `setTemplate` refuses a record that has one.
667
+ - The rename/move confirm's code-address line names the addresses in
668
+ the selected locale only, though a move re-paths every locale; the
669
+ Pages list's conflict notice is one ellipsized line (the whole text
670
+ on hover), and an editor's flagged page cannot be moved from the
671
+ notice itself.
672
+ - An index page removed from code leaves the collection's record with
673
+ its template (spec amendment 3): the record keeps rendering as that
674
+ page type's stale name until an editor or a later pass clears it.
675
+ - The code-owned part of a URL is never stored (spec 2026-09-29 anchored
676
+ paths): a row names its anchor and the index joins the config's
677
+ address, so an address changed in code moves its URLs at the deploy
678
+ with no write, and the old URLs need a `next.config.ts` redirect.
679
+ Rows written before schema v5's anchors (none installed) would sit
680
+ under `''` and go dark under a code address until re-saved.
681
+ - `listLive` / `listRedirects` on Supabase read at most PostgREST's
682
+ `max_rows` (1000) per locale, as before, now ordered by (anchor, path)
683
+ and sorted by URL in JS; page them if a locale's redirect list grows
684
+ past it.
685
+ - Older than this plan: the Supabase `addLocale` rollback on
686
+ `alreadyInLocale` (`supabase-store.ts`, the insertLocale catch)
687
+ deletes the racing WINNER's locale row and versions. Two admins
688
+ adding one locale at once converge on the next list; fix by rolling
689
+ back only what this call inserted.
690
+
691
+ ## Follow-ups (logged 2026-09-29, pre-install fixes)
692
+
693
+ - Content health lists an entry slug outside the grammar, but its link
694
+ opens an entry form that marks nothing on the slug: drift leaves the
695
+ slug out on purpose (a marker would flash while it is typed), so only
696
+ the publish refusal marks it. Mark it in the form once the box has lost
697
+ focus, if editors miss it.
698
+ - Content health does not check page slugs: pages refuse a bad slug at
699
+ create and rename, so only an import of older data or a hand edit could
700
+ hold one.
644
701
 
645
702
  ## Follow-ups (logged 2026-09-28, export/import spec)
646
703
 
@@ -662,9 +719,6 @@ after both are on the registry. From the repo root:
662
719
  - Import cannot expire the app's cache tags; it prints a restart note. A
663
720
  revalidate call through the app (an op the CLI could reach) would make
664
721
  the note unnecessary.
665
- - A resumed import that lands nothing still prints the full "Imported …"
666
- summary and the member list — accurate (the space holds all of it) but
667
- noisy; count what actually landed and say "nothing new" when it is zero.
668
722
 
669
723
  ## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
670
724
 
@@ -0,0 +1,42 @@
1
+ import { type LocaleSet } from "./paths.ts";
2
+ /** owner key → locale → address, without slashes at either end (`company/news`). */
3
+ export type Addresses = Record<string, Record<string, string>>;
4
+ export declare const collectionKey: (name: string) => string;
5
+ export declare const pageKey: (name: string) => string;
6
+ /** The collection an owner key names, or null for a fixed page. */
7
+ export declare const ownerCollection: (key: string) => string | null;
8
+ export declare function assertAddress(address: string, what: string): void;
9
+ /** The address's last part — what a code-owned record keeps in `page_locales.slug`. */
10
+ export declare const lastPart: (address: string) => string;
11
+ export type CodeAddress = {
12
+ /** The owner key stored in `pages.fixed`. */
13
+ key: string;
14
+ /** locale → address, in every supported locale: a locale the slug or path omits takes the default's,
15
+ * as the entry derivation does (collectionSegment) — code owns an address in every locale. */
16
+ segments: Record<string, string>;
17
+ title: string;
18
+ /** The fixed page registration rendered at the address; null for a collection with no index page (a folder). */
19
+ template: string | null;
20
+ /** The collection whose address this is; null for a plain fixed page. */
21
+ collection: string | null;
22
+ };
23
+ type PageLike = {
24
+ schema: {
25
+ kind: string;
26
+ name: string;
27
+ title?: string;
28
+ slug?: string | Record<string, string>;
29
+ };
30
+ };
31
+ type CollectionLike = {
32
+ name: string;
33
+ title: string;
34
+ path?: string | Record<string, string>;
35
+ };
36
+ export declare function codeAddresses(input: {
37
+ locales: LocaleSet;
38
+ pages: ReadonlyArray<PageLike>;
39
+ collections: ReadonlyArray<CollectionLike>;
40
+ }): CodeAddress[];
41
+ export declare const addressMap: (list: CodeAddress[]) => Addresses;
42
+ export {};
@@ -0,0 +1,51 @@
1
+ // Code-owned addresses (spec 2026-09-29 nested paths): what the registry
2
+ // claims in the URL space, per locale, keyed by the owner a `pages.fixed`
3
+ // column names. A fixed page's slug and a collection's path may have
4
+ // several parts (`company/news`); each part is a segment under the ASCII
5
+ // rule, so SEGMENT_RE stays the only gate. Pure: defineConfig, the
6
+ // materializer, the stores, the site and the admin tree all read it here.
7
+ import { assertSegment, fixedPageSegments } from "./paths.js";
8
+ export const collectionKey = (name) => `collection:${name}`;
9
+ export const pageKey = (name) => `page:${name}`;
10
+ /** The collection an owner key names, or null for a fixed page. */
11
+ export const ownerCollection = (key) => (key.startsWith("collection:") ? key.slice("collection:".length) : null);
12
+ export function assertAddress(address, what) {
13
+ const parts = address.split("/");
14
+ if (parts.some((p) => p === "")) {
15
+ throw new Error(`smoodly: ${what} "${address}" has an empty part — write segments joined by single slashes, like "company/news".`);
16
+ }
17
+ for (const part of parts)
18
+ assertSegment(part, what);
19
+ }
20
+ /** The address's last part — what a code-owned record keeps in `page_locales.slug`. */
21
+ export const lastPart = (address) => address.slice(address.lastIndexOf("/") + 1);
22
+ export function codeAddresses(input) {
23
+ const def = input.locales.default;
24
+ const byDefault = new Map();
25
+ const out = [];
26
+ for (const p of input.pages) {
27
+ if (p.schema.kind !== "page" || p.schema.slug === undefined)
28
+ continue;
29
+ const segments = fixedPageSegments(p.schema.slug, input.locales);
30
+ const spec = { key: pageKey(p.schema.name), segments, title: p.schema.title ?? p.schema.name, template: p.schema.name, collection: null };
31
+ byDefault.set(segments[def], spec);
32
+ out.push(spec);
33
+ }
34
+ for (const c of input.collections) {
35
+ if (c.path === undefined)
36
+ continue;
37
+ const segments = fixedPageSegments(c.path, input.locales);
38
+ const index = byDefault.get(segments[def]);
39
+ if (index) {
40
+ // defineConfig proved the page sits on the collection's address in
41
+ // every locale: that record is the collection's, owned by it.
42
+ index.key = collectionKey(c.name);
43
+ index.collection = c.name;
44
+ index.title = c.title;
45
+ continue;
46
+ }
47
+ out.push({ key: collectionKey(c.name), segments, title: c.title, template: null, collection: c.name });
48
+ }
49
+ return out;
50
+ }
51
+ export const addressMap = (list) => Object.fromEntries(list.map((a) => [a.key, a.segments]));
@@ -907,8 +907,10 @@ export function EditorView({ ops, registry, id }) {
907
907
  else
908
908
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
909
909
  }, onEscape: inline.end }));
910
- })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error" || globalEdit.saveState === "refused") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
911
- !isFixedNode(record, registry) &&
910
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error" || globalEdit.saveState === "refused") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [],
911
+ // Code-owned, or no longer in code (only deleting is open then): either way the URL is not the editor's.
912
+ fixed: isFixedNode(record, registry) || !!record.fixed, onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
913
+ !record.fixed &&
912
914
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
913
915
  ? () => { void doRemoveLocale(); }
914
916
  : undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && findingsAt.get(selected.id)?.filter((f) => f.kind === "zone-disallowed").map((f) => (_jsx("p", { className: "sm-outline-note", children: driftMessage(f) }, f.path))), selected && selectedSection && (_jsx(InspectorFields, { groups: activeGroups, values: selected, render: ({ key, ns, descriptor }, { emptyLabel, scope }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(selected, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, errors: ns === "css" ? undefined : misfitErrorsIn(findingsAt.get(selected.id) ?? [], selected.id, ns), refOptions: ns === "fields" || ns === "settings" ? refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor) : undefined, inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
@@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from "react";
11
11
  import { updatedLine } from "../ui/format.js";
12
12
  import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
13
13
  import { confirmLiveUrl } from "../shell/live-url.js";
14
+ import { codeAddressNote } from "../shell/pages-tree.js";
14
15
  import { RouteLink } from "../shell/RouteLink.js";
15
16
  import { useAdminHref } from "../shell/base.js";
16
17
  import { tidySlug } from "../forms/slug-field.js";
@@ -47,7 +48,7 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
47
48
  // A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
48
49
  let redirect = true;
49
50
  if (key === "slug") {
50
- const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
51
+ const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }, codeAddressNote(path ?? null, registry, locale));
51
52
  if (!live.ok) {
52
53
  setDraft((d) => ({ ...d, slug: row.slug }));
53
54
  return;
@@ -22,8 +22,24 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
22
22
  // A new canvas URL (a locale switch, a rename) means new geometry — the
23
23
  // previous page's rects are stale the instant the iframe starts loading
24
24
  // a different route, so clear them alongside resetting geometry.
25
- useEffect(() => { setGeometry("waiting"); setLoaded(false); setRects([]); setEmptyZones([]); setFields([]); setCrashed([]); }, [iframeSrc]);
25
+ // A refresh posted before this canvas's bridge has spoken reaches the loading document and is lost, while the
26
+ // server may have rendered the page before the save it announces (a fill saved as the frame loads): it is sent
27
+ // again on the bridge's first message.
28
+ const heard = useRef(false);
29
+ const missedRefresh = useRef(false);
30
+ useEffect(() => {
31
+ setGeometry("waiting");
32
+ setLoaded(false);
33
+ setRects([]);
34
+ setEmptyZones([]);
35
+ setFields([]);
36
+ setCrashed([]);
37
+ heard.current = false;
38
+ missedRefresh.current = false;
39
+ }, [iframeSrc]);
26
40
  const postToCanvas = useCallback((m) => {
41
+ if (m.kind === "refresh" && !heard.current)
42
+ missedRefresh.current = true;
27
43
  iframeRef.current?.contentWindow?.postMessage(m, "*");
28
44
  }, [iframeRef]);
29
45
  useEffect(() => {
@@ -34,6 +50,13 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
34
50
  if (e.source !== iframeRef.current?.contentWindow)
35
51
  return;
36
52
  setGeometry("ok"); // any bridge message: Smoodly rendered the canvas with its bridge
53
+ if (!heard.current) {
54
+ heard.current = true;
55
+ if (missedRefresh.current) {
56
+ missedRefresh.current = false;
57
+ postToCanvas({ source: "smoodly", kind: "refresh" });
58
+ }
59
+ }
37
60
  if (m.kind === "rects") {
38
61
  setRects(m.nodes);
39
62
  setEmptyZones(m.emptyZones);
@@ -53,7 +76,7 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
53
76
  };
54
77
  window.addEventListener("message", onMessage);
55
78
  return () => window.removeEventListener("message", onMessage);
56
- }, [iframeRef]);
79
+ }, [iframeRef, postToCanvas]);
57
80
  useEffect(() => {
58
81
  if (!iframeSrc || !loaded || geometry !== "waiting")
59
82
  return;
@@ -1,29 +1,14 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
2
  import type { PageRecord, PageStore } from "../store.ts";
3
- export type FixedNode = {
4
- /** The node's segment per locale it exists in. A fixed page always
5
- * carries every supported locale — a slug map that omits one falls
6
- * back to the default locale's segment, same as a string slug. A
7
- * collection mount's segments are the collection's path exactly:
8
- * every supported locale for a string path, exactly its keys for a
9
- * per-locale map. */
10
- segments: Record<string, string>;
11
- title: string;
12
- /** The fixed page registration's name; null for a plain mount (a folder). */
13
- template: string | null;
14
- /** The collection whose `path` this node is; null for a plain fixed page. */
15
- collection: string | null;
16
- };
17
- export declare function fixedNodeSpecs(config: ResolvedConfig): FixedNode[];
18
- /** The spec a ROOT record materializes, or null for an editor-owned node.
19
- * Judged by the default-locale slug only — a root that exists in Finnish
20
- * alone is never a fixed node, whatever its Finnish slug. */
3
+ import { type CodeAddress } from "../addresses.ts";
4
+ export type FixedNode = CodeAddress;
5
+ export declare const fixedNodeSpecs: (config: ResolvedConfig) => FixedNode[];
6
+ /** The spec that owns a record, or null for an editor's page and for a record no registration owns any more. */
21
7
  export declare function fixedNodeOf(record: {
22
- parentId: string | null;
23
- locales: Record<string, {
24
- slug: string;
25
- }>;
8
+ fixed?: string | null;
26
9
  }, config: ResolvedConfig): FixedNode | null;
27
- /** Create every fixed node that has no root record yet, give every fixed
28
- * node the locales it lacks, then list. */
10
+ /** Create every code-owned record that has none yet and give each the
11
+ * locales it lacks, then list. Code wins an address (spec 2026-09-29
12
+ * anchored paths §4): an editor's page already there changes nothing
13
+ * here — the index serves the record, the Pages list flags the page. */
29
14
  export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
@@ -1,97 +1,61 @@
1
- import { fixedPageSegments, perLocaleSegments } from "../paths.js";
2
- export function fixedNodeSpecs(config) {
3
- const { default: def, supported } = config.locales;
4
- const perLocale = (v) => perLocaleSegments(v, supported);
5
- const specs = new Map();
6
- for (const p of config.registry.pages ?? []) {
7
- if (p.schema.kind !== "page" || p.schema.slug === undefined)
8
- continue;
9
- // Fixed pages exist in every supported locale (spec 2026-09-06 §3): a
10
- // slug map that omits a locale falls back to the default locale's
11
- // segment. One helper fills it for the boot check (config.ts), here
12
- // and fixedPathFor (localize.ts), so the three cannot drift.
13
- const segments = fixedPageSegments(p.schema.slug, config.locales);
14
- specs.set(segments[def], {
15
- segments,
16
- title: p.schema.title ?? p.schema.name,
17
- template: p.schema.name,
18
- collection: null,
19
- });
20
- }
21
- for (const c of config.registry.collections ?? []) {
22
- if (c.path === undefined)
23
- continue;
24
- const segments = perLocale(c.path);
25
- const indexPage = specs.get(segments[def]);
26
- if (indexPage) {
27
- // defineConfig proved the page sits at the collection's segment in
28
- // every locale: that page IS the mount — the collection's index page.
29
- indexPage.collection = c.name;
30
- continue;
31
- }
32
- specs.set(segments[def], { segments, title: c.title, template: null, collection: c.name });
33
- }
34
- return [...specs.values()];
35
- }
36
- /** The spec a ROOT record materializes, or null for an editor-owned node.
37
- * Judged by the default-locale slug only — a root that exists in Finnish
38
- * alone is never a fixed node, whatever its Finnish slug. */
1
+ import { codeAddresses, lastPart } from "../addresses.js";
2
+ export const fixedNodeSpecs = (config) => codeAddresses({ locales: config.locales, pages: config.registry.pages ?? [], collections: config.registry.collections ?? [] });
3
+ /** The spec that owns a record, or null for an editor's page and for a record no registration owns any more. */
39
4
  export function fixedNodeOf(record, config) {
40
- if (record.parentId !== null)
41
- return null;
42
- const def = config.locales.default;
43
- const slug = record.locales[def]?.slug;
44
- if (slug === undefined)
45
- return null;
46
- return fixedNodeSpecs(config).find((s) => s.segments[def] === slug) ?? null;
5
+ return record.fixed ? fixedNodeSpecs(config).find((s) => s.key === record.fixed) ?? null : null;
47
6
  }
48
- /** Create every fixed node that has no root record yet, give every fixed
49
- * node the locales it lacks, then list. */
7
+ /** Create every code-owned record that has none yet and give each the
8
+ * locales it lacks, then list. Code wins an address (spec 2026-09-29
9
+ * anchored paths §4): an editor's page already there changes nothing
10
+ * here — the index serves the record, the Pages list flags the page. */
50
11
  export async function ensureFixedNodes(pages, config) {
51
12
  const def = config.locales.default;
52
13
  const all = await pages.listPages();
53
- const roots = all.filter((p) => p.parentId === null);
54
- let attempted = false;
14
+ let wrote = false;
55
15
  for (const spec of fixedNodeSpecs(config)) {
56
- const slug = spec.segments[def];
57
- let record = roots.find((p) => p.locales[def]?.slug === slug);
16
+ let record = all.find((p) => p.fixed === spec.key);
17
+ const defAddress = spec.segments[def];
58
18
  if (!record) {
59
- attempted = true;
19
+ wrote = true;
60
20
  try {
61
- record = await pages.createPage({ locale: def, slug, template: spec.template, title: spec.title });
21
+ record = await pages.createPage({ locale: def, slug: lastPart(defAddress), template: spec.template, title: spec.title, fixed: spec.key });
62
22
  }
63
23
  catch (e) {
64
24
  // Two admins loading at once race on this very create; the loser's
65
- // slugTaken is the other one winning, not a failure, so it skips
66
- // silently — no warn. Anything else is worth saying out loud.
25
+ // fixedTaken is the other one winning. Anything else is said out loud.
67
26
  const message = e instanceof Error ? e.message : String(e);
68
- if (!message.includes("already exists")) {
69
- console.warn(`smoodly: could not materialize "${slug}": ${message}`);
70
- }
27
+ if (!message.includes("already exists"))
28
+ console.warn(`smoodly: could not materialize "${defAddress}": ${message}`);
71
29
  continue;
72
30
  }
73
31
  }
74
- // Every locale the registration names — including one added to the
75
- // config after the record was first materialized.
76
- for (const [locale, segment] of Object.entries(spec.segments)) {
32
+ else if (record.template === null && spec.template !== null) {
33
+ // The collection gained its index page. A racing admin's hasTemplate is that admin winning.
34
+ wrote = true;
35
+ try {
36
+ record = await pages.setTemplate(record.id, spec.template);
37
+ }
38
+ catch (e) {
39
+ const message = e instanceof Error ? e.message : String(e);
40
+ if (!message.includes("already has a page type"))
41
+ console.warn(`smoodly: could not give "${defAddress}" its page type: ${message}`);
42
+ }
43
+ }
44
+ // Every locale the registration names — including one added to the config after the record was first materialized.
45
+ for (const [locale, address] of Object.entries(spec.segments)) {
77
46
  if (record.locales[locale])
78
47
  continue;
79
- attempted = true;
48
+ wrote = true;
80
49
  try {
81
- record = await pages.addLocale(record.id, locale, { from: def, slug: segment });
50
+ record = await pages.addLocale(record.id, locale, { from: def, slug: lastPart(address) });
82
51
  }
83
52
  catch (e) {
84
- // One locale's row that cannot land — a segment or path an editor
85
- // already claimed there — must not take the node or the whole list
86
- // down: warn, skip that locale, keep materializing the rest. Only a
87
- // concurrent add of the SAME locale is a race, and it skips silently.
53
+ // Only a concurrent add of the SAME locale is a race; it skips silently.
88
54
  const message = e instanceof Error ? e.message : String(e);
89
- if (!message.includes("already exists in locale")) {
90
- console.warn(`smoodly: could not materialize "${slug}" in "${locale}": ${message}`);
91
- }
55
+ if (!message.includes("already exists in locale"))
56
+ console.warn(`smoodly: could not materialize "${address}" in "${locale}": ${message}`);
92
57
  }
93
58
  }
94
59
  }
95
- // A create was tried, so the pre-race list is stale either way.
96
- return attempted ? pages.listPages() : all;
60
+ return wrote ? pages.listPages() : all;
97
61
  }
@@ -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
  }