smoodly 0.0.33 → 0.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -17
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +4 -2
- package/dist/admin/editor/PageSettings.js +2 -1
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +121 -12
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/ui/theme.js +1 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/next/page-renderer.js +2 -2
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +25 -12
- package/dist/paths.js +59 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql.js +35 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/package.json +1 -1
package/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-
|
|
631
|
-
|
|
632
|
-
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
`
|
|
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] ?? [],
|
|
911
|
-
|
|
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
|
-
|
|
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/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
|
}
|