smoodly 0.0.33 → 0.0.35

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 (82) hide show
  1. package/README.md +845 -1301
  2. package/dist/addresses.d.ts +42 -0
  3. package/dist/addresses.js +51 -0
  4. package/dist/admin/editor/EditorView.js +14 -5
  5. package/dist/admin/editor/PageSettings.js +2 -1
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/editor/useCanvasBridge.js +25 -2
  9. package/dist/admin/fixed-nodes.d.ts +9 -24
  10. package/dist/admin/fixed-nodes.js +36 -72
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/health.js +9 -0
  13. package/dist/admin/next/create-admin.d.ts +4 -3
  14. package/dist/admin/next/create-admin.js +14 -3
  15. package/dist/admin/ops-impl.js +121 -12
  16. package/dist/admin/redirect-path.d.ts +3 -0
  17. package/dist/admin/redirect-path.js +9 -0
  18. package/dist/admin/serialize.d.ts +3 -0
  19. package/dist/admin/serialize.js +1 -0
  20. package/dist/admin/shell/AddRedirectModal.js +2 -2
  21. package/dist/admin/shell/EntryForm.js +3 -2
  22. package/dist/admin/shell/GlobalForm.js +3 -2
  23. package/dist/admin/shell/PagesList.js +29 -12
  24. package/dist/admin/shell/live-url.d.ts +3 -1
  25. package/dist/admin/shell/live-url.js +5 -2
  26. package/dist/admin/shell/pages-tree.d.ts +19 -14
  27. package/dist/admin/shell/pages-tree.js +49 -16
  28. package/dist/admin/shell/router.d.ts +5 -0
  29. package/dist/admin/shell/router.js +13 -0
  30. package/dist/admin/ui/theme.js +1 -0
  31. package/dist/anchors.d.ts +42 -0
  32. package/dist/anchors.js +72 -0
  33. package/dist/cli/export.js +3 -1
  34. package/dist/cli/import.d.ts +1 -0
  35. package/dist/cli/import.js +13 -2
  36. package/dist/cli/space-io.d.ts +1 -2
  37. package/dist/cli/space-io.js +5 -5
  38. package/dist/collections.js +5 -0
  39. package/dist/config.d.ts +6 -0
  40. package/dist/config.js +53 -18
  41. package/dist/current-space.d.ts +4 -0
  42. package/dist/current-space.js +9 -0
  43. package/dist/entry-store.js +4 -2
  44. package/dist/env.d.ts +4 -3
  45. package/dist/env.js +15 -17
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.js +3 -1
  48. package/dist/next/canvas-slot.d.ts +7 -0
  49. package/dist/next/canvas-slot.js +9 -0
  50. package/dist/next/index.d.ts +2 -0
  51. package/dist/next/index.js +2 -0
  52. package/dist/next/page-renderer.d.ts +2 -0
  53. package/dist/next/page-renderer.js +14 -10
  54. package/dist/next/preview-pill.d.ts +9 -0
  55. package/dist/next/preview-pill.js +56 -0
  56. package/dist/next/preview-route.d.ts +1 -0
  57. package/dist/next/preview-route.js +30 -0
  58. package/dist/path-index.d.ts +11 -0
  59. package/dist/path-index.js +52 -25
  60. package/dist/paths.d.ts +25 -12
  61. package/dist/paths.js +59 -21
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/site.d.ts +7 -3
  65. package/dist/site.js +26 -15
  66. package/dist/space-transfer.d.ts +2 -0
  67. package/dist/space-transfer.js +8 -1
  68. package/dist/sql-space.d.ts +14 -0
  69. package/dist/sql-space.js +51 -23
  70. package/dist/sql.d.ts +5 -0
  71. package/dist/sql.js +51 -9
  72. package/dist/store.d.ts +15 -1
  73. package/dist/store.js +27 -6
  74. package/dist/supabase-entry-store.d.ts +0 -1
  75. package/dist/supabase-entry-store.js +6 -11
  76. package/dist/supabase-path-index.d.ts +12 -1
  77. package/dist/supabase-path-index.js +56 -19
  78. package/dist/supabase-store.d.ts +2 -0
  79. package/dist/supabase-store.js +30 -9
  80. package/migrations/20260929120000_smoodly_v5.sql +56 -0
  81. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  82. package/package.json +1 -5
@@ -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]));
@@ -64,6 +64,7 @@ import { PublishControl } from "../ui/PublishControl.js";
64
64
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
65
65
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
66
66
  import { draftToken } from "../../draft-token.js";
67
+ import { useUnloadGuard } from "../shell/router.js";
67
68
  import { isStale, staleInfoOf } from "../shell/stale.js";
68
69
  import { StaleBanner } from "../ui/StaleBanner.js";
69
70
  import { DriftNotice } from "../ui/DriftNotice.js";
@@ -150,9 +151,14 @@ export function EditorView({ ops, registry, id }) {
150
151
  const r = await ops.pages.saveDraft(id, target, t, token());
151
152
  if (r.ok) {
152
153
  // The badge and Publish follow each save without a re-get: saveDraft answers with the version id and the new token.
153
- setRecord((rec) => rec?.locales[target]
154
- ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } }
155
- : rec);
154
+ // The ref moves with the ack, not with the next render: a save that follows an unmounted view's
155
+ // (close()) must carry this token, and no render will ever run to refresh it (review I3).
156
+ const rec = recordRef.current;
157
+ if (rec?.locales[target]) {
158
+ const next = { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } };
159
+ recordRef.current = next;
160
+ setRecord(next);
161
+ }
156
162
  setDirty(false);
157
163
  presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
158
164
  }
@@ -166,6 +172,7 @@ export function EditorView({ ops, registry, id }) {
166
172
  // autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
167
173
  // through the same inline-session deferral as the page's.
168
174
  const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
175
+ useUnloadGuard(dirty || saveState !== "saved" || globalDirty || globalEdit.saveState !== "saved");
169
176
  const patchGlobal = (name, path, value) => {
170
177
  globalEdit.patch(name, path, value);
171
178
  setGlobalDirty(true);
@@ -907,8 +914,10 @@ export function EditorView({ ops, registry, id }) {
907
914
  else
908
915
  mutate((t) => patchNodePath(t, session.node, session.path, doc));
909
916
  }, 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) &&
917
+ })()] }) })] }), _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] ?? [],
918
+ // Code-owned, or no longer in code (only deleting is open then): either way the URL is not the editor's.
919
+ 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 &&
920
+ !record.fixed &&
912
921
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
913
922
  ? () => { void doRemoveLocale(); }
914
923
  : 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;
@@ -12,6 +12,11 @@ export type DraftAutosave = {
12
12
  subscribe(listener: () => void): () => void;
13
13
  /** Cancel pending timers; call on unmount. */
14
14
  dispose(): void;
15
+ /** Unmount: save a pending edit now, cancel the timers, and retry nothing
16
+ * afterwards (spec 2026-09-29 pre-install fixes §4). */
17
+ close(): Promise<boolean>;
18
+ /** Undo `close()` when the hook's effect runs again (React StrictMode mounts, cleans up, mounts). */
19
+ reopen(): void;
15
20
  /** Leave `stale` after the record was reloaded: timers cleared, the reloaded value counts as saved. */
16
21
  reset(): void;
17
22
  };
@@ -26,7 +31,7 @@ export declare function createDraftAutosave<T>(opts: {
26
31
  export type DraftAutosaveOptions<T> = Parameters<typeof createDraftAutosave<T>>[0];
27
32
  /** React glue: one controller per mount, always calling the latest
28
33
  * `read`/`persist`/`onSaved` (their identities change on every render —
29
- * `ops` and `slug` are props), timers cleared on unmount. */
34
+ * `ops` and `slug` are props), a pending edit saved on unmount. */
30
35
  export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
31
36
  saveState: SaveState;
32
37
  mark: () => void;
@@ -12,13 +12,24 @@ export function createDraftAutosave(opts) {
12
12
  let revision = 0;
13
13
  let savedRevision = 0;
14
14
  let state = "saved";
15
+ let closed = false;
16
+ /** The save on the wire, if any: close() waits for it instead of racing it with a second save. */
17
+ let inflight = null;
15
18
  const listeners = new Set();
16
19
  const setState = (next) => {
17
20
  state = next;
18
21
  for (const l of listeners)
19
22
  l();
20
23
  };
21
- const save = async () => {
24
+ const save = () => {
25
+ const run = send();
26
+ inflight = run;
27
+ const clear = () => { if (inflight === run)
28
+ inflight = null; };
29
+ run.then(clear, clear);
30
+ return run;
31
+ };
32
+ const send = async () => {
22
33
  const current = opts.read();
23
34
  if (current === null)
24
35
  return true;
@@ -60,6 +71,10 @@ export function createDraftAutosave(opts) {
60
71
  }
61
72
  else {
62
73
  // Keep local state; retry with a doubling, capped delay.
74
+ if (closed) {
75
+ setState("error");
76
+ return false;
77
+ }
63
78
  setState("error");
64
79
  const delay = retryDelay;
65
80
  retryDelay = Math.min(delay * 2, retryCapMs);
@@ -102,6 +117,28 @@ export function createDraftAutosave(opts) {
102
117
  retryTimer = null;
103
118
  }
104
119
  },
120
+ close() {
121
+ closed = true;
122
+ if (debounceTimer) {
123
+ clearTimeout(debounceTimer);
124
+ debounceTimer = null;
125
+ }
126
+ if (retryTimer) {
127
+ clearTimeout(retryTimer);
128
+ retryTimer = null;
129
+ }
130
+ // A save in flight holds the token the next one must carry, and only its ack moves it (review I3):
131
+ // wait, then send whatever the ack did not cover.
132
+ const remainder = () => {
133
+ if (state === "stale")
134
+ return Promise.resolve(false);
135
+ if (revision === savedRevision)
136
+ return Promise.resolve(true);
137
+ return save();
138
+ };
139
+ return inflight ? inflight.then(remainder) : remainder();
140
+ },
141
+ reopen() { closed = false; },
105
142
  reset() {
106
143
  if (debounceTimer) {
107
144
  clearTimeout(debounceTimer);
@@ -119,7 +156,7 @@ export function createDraftAutosave(opts) {
119
156
  }
120
157
  /** React glue: one controller per mount, always calling the latest
121
158
  * `read`/`persist`/`onSaved` (their identities change on every render —
122
- * `ops` and `slug` are props), timers cleared on unmount. */
159
+ * `ops` and `slug` are props), a pending edit saved on unmount. */
123
160
  export function useDraftAutosave(opts) {
124
161
  const latest = useRef(opts);
125
162
  latest.current = opts;
@@ -131,7 +168,10 @@ export function useDraftAutosave(opts) {
131
168
  retryBaseMs: opts.retryBaseMs,
132
169
  retryCapMs: opts.retryCapMs,
133
170
  }));
134
- useEffect(() => () => controller.dispose(), [controller]);
171
+ useEffect(() => {
172
+ controller.reopen();
173
+ return () => { void controller.close(); };
174
+ }, [controller]);
135
175
  const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
136
176
  return { saveState, mark: controller.mark, flush: controller.flush, reset: controller.reset };
137
177
  }
@@ -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,3 +1,6 @@
1
+ // The list widget's logic as pure functions over plain arrays and objects,
2
+ // so it is tested without a DOM. FieldWidget renders; this file decides.
3
+ import { optionsOf } from "../../fields.js";
1
4
  /** An empty item: an object of its subfields' defaults, else the default. */
2
5
  export function emptyValue(d) {
3
6
  if (d.type === "object") {
@@ -9,7 +12,13 @@ export function emptyValue(d) {
9
12
  }
10
13
  return out;
11
14
  }
12
- return d.default;
15
+ if (d.default !== undefined)
16
+ return d.default;
17
+ // A required choice shows its first option (FieldWidget renders no blank
18
+ // one), so it stores it too — a new item never looks set while it is empty.
19
+ if ((d.type === "select" || d.type === "radio") && d.optional !== true)
20
+ return optionsOf(d.options)[0]?.value;
21
+ return undefined;
13
22
  }
14
23
  export function addItem(items, list) {
15
24
  if (typeof list.max === "number" && items.length >= list.max)
@@ -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
  }