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.
- package/README.md +845 -1301
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +14 -5
- package/dist/admin/editor/PageSettings.js +2 -1
- package/dist/admin/editor/autosave.d.ts +6 -1
- package/dist/admin/editor/autosave.js +43 -3
- 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/forms/list.js +10 -1
- 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/EntryForm.js +3 -2
- package/dist/admin/shell/GlobalForm.js +3 -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/shell/router.d.ts +5 -0
- package/dist/admin/shell/router.js +13 -0
- 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/canvas-slot.d.ts +7 -0
- package/dist/next/canvas-slot.js +9 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/page-renderer.d.ts +2 -0
- package/dist/next/page-renderer.js +14 -10
- package/dist/next/preview-pill.d.ts +9 -0
- package/dist/next/preview-pill.js +56 -0
- package/dist/next/preview-route.d.ts +1 -0
- package/dist/next/preview-route.js +30 -0
- 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-space.d.ts +14 -0
- package/dist/sql-space.js +51 -23
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +51 -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/migrations/20260929135523_smoodly_v6.sql +200 -0
- 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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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] ?? [],
|
|
911
|
-
|
|
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),
|
|
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 =
|
|
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),
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
23
|
-
locales: Record<string, {
|
|
24
|
-
slug: string;
|
|
25
|
-
}>;
|
|
8
|
+
fixed?: string | null;
|
|
26
9
|
}, config: ResolvedConfig): FixedNode | null;
|
|
27
|
-
/** Create every
|
|
28
|
-
*
|
|
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 {
|
|
2
|
-
export
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
*
|
|
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
|
-
|
|
54
|
-
let attempted = false;
|
|
14
|
+
let wrote = false;
|
|
55
15
|
for (const spec of fixedNodeSpecs(config)) {
|
|
56
|
-
|
|
57
|
-
|
|
16
|
+
let record = all.find((p) => p.fixed === spec.key);
|
|
17
|
+
const defAddress = spec.segments[def];
|
|
58
18
|
if (!record) {
|
|
59
|
-
|
|
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
|
-
//
|
|
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 "${
|
|
70
|
-
}
|
|
27
|
+
if (!message.includes("already exists"))
|
|
28
|
+
console.warn(`smoodly: could not materialize "${defAddress}": ${message}`);
|
|
71
29
|
continue;
|
|
72
30
|
}
|
|
73
31
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
48
|
+
wrote = true;
|
|
80
49
|
try {
|
|
81
|
-
record = await pages.addLocale(record.id, locale, { from: def, slug:
|
|
50
|
+
record = await pages.addLocale(record.id, locale, { from: def, slug: lastPart(address) });
|
|
82
51
|
}
|
|
83
52
|
catch (e) {
|
|
84
|
-
//
|
|
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 "${
|
|
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
|
-
|
|
96
|
-
return attempted ? pages.listPages() : all;
|
|
60
|
+
return wrote ? pages.listPages() : all;
|
|
97
61
|
}
|
package/dist/admin/forms/list.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/admin/health.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
-
|
|
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
|
}
|