smoodly 0.0.29 → 0.0.31
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 +75 -9
- package/dist/admin/editor/CanvasWarning.d.ts +5 -0
- package/dist/admin/editor/CanvasWarning.js +10 -0
- package/dist/admin/editor/EditorView.js +47 -12
- package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
- package/dist/admin/editor/canvas-diagnosis.js +106 -0
- package/dist/admin/editor/crash-copy.d.ts +7 -0
- package/dist/admin/editor/crash-copy.js +13 -0
- package/dist/admin/editor/protocol.d.ts +2 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
- package/dist/admin/editor/useCanvasBridge.js +14 -7
- package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/next/bridge.js +4 -2
- package/dist/admin/next/index.d.ts +0 -1
- package/dist/admin/next/index.js +6 -1
- package/dist/admin/ui/ActionMenu.d.ts +20 -0
- package/dist/admin/ui/ActionMenu.js +33 -7
- package/dist/custom/CustomSection.d.ts +1 -0
- package/dist/custom/CustomSection.js +4 -1
- package/dist/marks.d.ts +12 -0
- package/dist/marks.js +14 -0
- package/dist/next/canvas-boundary.d.ts +16 -0
- package/dist/next/canvas-boundary.js +25 -0
- package/dist/next/page-renderer.d.ts +3 -1
- package/dist/next/page-renderer.js +33 -4
- package/dist/pairing.js +10 -3
- package/dist/render.d.ts +12 -1
- package/dist/render.js +9 -3
- package/dist/section-crash.d.ts +11 -0
- package/dist/section-crash.js +16 -0
- package/dist/section-guard.d.ts +27 -0
- package/dist/section-guard.js +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -252,7 +252,7 @@ Implemented:
|
|
|
252
252
|
is stored as entries, spec 2026-09-07), not only through the generated
|
|
253
253
|
views.
|
|
254
254
|
|
|
255
|
-
- Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
|
|
255
|
+
- Admin login: `supabaseAdminAuth` (from `smoodly/admin`) + `gateAdminOp` around the ops seam;
|
|
256
256
|
`LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
|
|
257
257
|
Env: `smoodlyEnv()` / `createSmoodlyClient()`.
|
|
258
258
|
|
|
@@ -477,6 +477,26 @@ Implemented:
|
|
|
477
477
|
what (a lock in the lists, a line under the top bar) and announces
|
|
478
478
|
saves so a clean view refreshes and a dirty one freezes early. Schema
|
|
479
479
|
version 2 adds the draft-pointer foreign keys and the channel policies.
|
|
480
|
+
Only the editor's own edits freeze a view (2026-09-27): two editors
|
|
481
|
+
opening a fresh page at once both fill its locked hero, and the fill
|
|
482
|
+
that lands second used to freeze its view under "Your unsaved changes
|
|
483
|
+
here will be discarded" with nothing changed; now that view loads the
|
|
484
|
+
other's quietly, on the refusal or the `saved` broadcast. This was the
|
|
485
|
+
local-only timeout of "the guard alone" (CI's slower sign-in let the
|
|
486
|
+
first fill land first). Tests: `concurrency.spec.ts` ("two editors
|
|
487
|
+
opening a new page at once", with the channel cut and on; the fill
|
|
488
|
+
save is held so the race is deterministic).
|
|
489
|
+
- ActionMenu near the screen's edge (2026-09-27): a row menu close to
|
|
490
|
+
the bottom opened below the viewport, where a fixed element cannot be
|
|
491
|
+
scrolled to, and the scroll event from bringing a row into view
|
|
492
|
+
arrived a frame after the click and closed the menu it had opened.
|
|
493
|
+
It now opens upward by the Combobox's `opensUp`, and closes only on a
|
|
494
|
+
scroll that moved its button. This was the Members test's flake (about
|
|
495
|
+
1 in 8 runs; the list was long because every concurrency run left
|
|
496
|
+
its second member behind — `concurrency.spec.ts` removes it in
|
|
497
|
+
`afterAll` since 2026-09-28, and `members.spec.ts` its invited Auth
|
|
498
|
+
user, which Remove member leaves in place). Tests: `admin-action-menu`, `members.spec.ts` (a
|
|
499
|
+
late scroll event, a real move, a row at the bottom of the screen).
|
|
480
500
|
- The security pass over the database boundary (spec 2026-09-26 security
|
|
481
501
|
pass): the presence channel admits a signed-in member of the topic's
|
|
482
502
|
space (`smoodly_presence_member()`, the socket carries the session
|
|
@@ -522,6 +542,50 @@ Implemented:
|
|
|
522
542
|
`admin-unknown-node`, the store contracts (`scanTrees`,
|
|
523
543
|
`scanEntries`, the pinning tests), `entry-store.supabase` (the
|
|
524
544
|
localized fix across two schemas), `examples/site/e2e/health.spec.ts`.
|
|
545
|
+
- Shadowed-page detector (spec 2026-09-27 shadowed-page detector): a
|
|
546
|
+
canvas whose bridge never speaks within 4 s of the frame's load is
|
|
547
|
+
diagnosed — the editor fetches the canvas URL once and
|
|
548
|
+
`admin/editor/canvas-diagnosis.ts` names another route, a route file
|
|
549
|
+
that drops `searchParams`, a crash, a 404, a redirect, a refusal, a
|
|
550
|
+
framing header or a script error, as an editor sentence and a "For
|
|
551
|
+
your developer" line (`editor/CanvasWarning.tsx`, in place of
|
|
552
|
+
"Selection unavailable"). The renderer writes
|
|
553
|
+
`<meta name="smoodly-draft">` in draft mode only. No reserved-path
|
|
554
|
+
list (OQ 23 closed). Tests: `admin-canvas-diagnosis`,
|
|
555
|
+
`admin-canvas-warning`, `next-page-renderer` (the marker),
|
|
556
|
+
`admin-bridge`, `examples/site/e2e/editor.spec.ts`.
|
|
557
|
+
- cacheComponents unsupported (spec 2026-09-27 cache components): under
|
|
558
|
+
Next's Cache Components the catch-all is served from a fallback shell
|
|
559
|
+
that commits 200, so 404s and 308s are lost; the renderer warns once
|
|
560
|
+
per renderer when `process.env.__NEXT_CACHE_COMPONENTS` is on, and the
|
|
561
|
+
Next.js guide says why. OQ 19 closed. Tests: `next-page-renderer`
|
|
562
|
+
("cacheComponents").
|
|
563
|
+
- Section guard (spec 2026-09-27 section guard): `Zone` calls a
|
|
564
|
+
plain-function section view inside try/catch (an async one through
|
|
565
|
+
`SectionGuard`, in place), so a throw in a section's own code leaves it
|
|
566
|
+
out and the page renders — nothing live, a placeholder in the canvas
|
|
567
|
+
and under `next dev`; the adapter's `rethrow` (Next: `unstable_rethrow`)
|
|
568
|
+
lets control flow through. In editor mode the Next adapter also wraps
|
|
569
|
+
each section in `<Suspense>` + the client `CanvasBoundary`, the bridge
|
|
570
|
+
reports crashed sections, the page editor lists them, and Publish asks
|
|
571
|
+
first. A healthy page's HTML is unchanged. Tests: `section-crash`,
|
|
572
|
+
`render` ("the section guard"), `next-canvas-boundary`,
|
|
573
|
+
`next-page-renderer`, `admin-crash-copy`, `admin-canvas-diagnosis`,
|
|
574
|
+
`examples/site/e2e/sections.spec.ts`.
|
|
575
|
+
- No admin on the live site (2026-09-27): `supabaseAdminAuth` moved from
|
|
576
|
+
`smoodly/admin/next` to `smoodly/admin` (its `AuthConfig` type too).
|
|
577
|
+
`smoodly/server.ts`, which every site route imports, took it from the
|
|
578
|
+
`admin/next` barrel beside `createSmoodlyAdmin`, so the admin shell was
|
|
579
|
+
a client module of every site route. Confirmed by removing the import
|
|
580
|
+
and rebuilding; the starter's home page, production build, went from
|
|
581
|
+
1,616,602 to 628,849 bytes of JavaScript (8 chunks either way, the
|
|
582
|
+
1,050,601-byte chunk holding `AdminApp` gone). The rest of the page's
|
|
583
|
+
JavaScript is the site's own: of a 62,848-byte chunk, next-intl is
|
|
584
|
+
~34 KB, `next/link` and `next/image` most of the rest, and the
|
|
585
|
+
editor-only modules ~3.4 KB (`EditorBridge` ~2.5 KB, the canvas net
|
|
586
|
+
~0.9 KB) — too little to lazy-load. Tests: `site-entries` (every entry
|
|
587
|
+
but `smoodly/admin/next` reaches no client module except those two),
|
|
588
|
+
`examples/site/e2e/site-bundle.spec.ts` (CI only: production chunks).
|
|
525
589
|
|
|
526
590
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
527
591
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
@@ -549,6 +613,16 @@ after both are on the registry. From the repo root:
|
|
|
549
613
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
550
614
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
551
615
|
|
|
616
|
+
## Follow-ups (logged 2026-09-27, shadowed-page detector spec)
|
|
617
|
+
|
|
618
|
+
- A section's crash on a live page after data changed outside the page
|
|
619
|
+
editor (an entry or a global item edited in its form): the guard
|
|
620
|
+
catches a throw in the section's own view, but one inside a component
|
|
621
|
+
it renders, in a richtext element's view or in a `'use client'`
|
|
622
|
+
section still fails the page, and no canvas previews those forms.
|
|
623
|
+
Catching it live needs `<Suspense>` (spec 2026-09-27 section guard,
|
|
624
|
+
measured). Revisit if a real site hits it.
|
|
625
|
+
|
|
552
626
|
## Follow-ups (logged 2026-09-27, editor safety spec)
|
|
553
627
|
|
|
554
628
|
- Scan paging: `health.scan` makes one bulk read per table (versions by
|
|
@@ -1329,14 +1403,6 @@ the toolbar instead of pushing the content.
|
|
|
1329
1403
|
the vocabulary once instead of per field, dedupe there.
|
|
1330
1404
|
- The entry form's column is narrower than the inspector, so the default
|
|
1331
1405
|
toolbar wraps to two rows there; the inspector shows it on one.
|
|
1332
|
-
- The example site's production bundle loads one shared chunk (~750 KB)
|
|
1333
|
-
on every site page holding the package's admin client modules — the
|
|
1334
|
-
shell, the Supabase client and now the TipTap widget — because
|
|
1335
|
-
`next/page-renderer.tsx` imports the editor bridge from
|
|
1336
|
-
`admin/next/bridge.tsx` and Turbopack bundles the package's client
|
|
1337
|
-
modules together. Measured on the 2026-09-08 build; whether the shell
|
|
1338
|
-
was already there before the widget is unverified. The fix is to give
|
|
1339
|
-
the bridge its own entry outside the admin tree and re-measure.
|
|
1340
1406
|
|
|
1341
1407
|
## Follow-ups (logged 2026-09-07, starter)
|
|
1342
1408
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The warning above a canvas Smoodly could not show (spec 2026-09-27
|
|
3
|
+
// shadowed-page detector §5): the editor's sentence, then the line for
|
|
4
|
+
// whoever fixes it, so the editor can forward it as is.
|
|
5
|
+
import { Banner } from "../ui/primitives.js";
|
|
6
|
+
import { canvasMessage } from "./canvas-diagnosis.js";
|
|
7
|
+
export function CanvasWarning({ cause, path }) {
|
|
8
|
+
const { editor, developer } = canvasMessage(cause, path);
|
|
9
|
+
return (_jsxs(Banner, { tone: "warn", children: [_jsx("p", { children: editor }), _jsxs("p", { children: [_jsx("strong", { children: "For your developer:" }), " ", developer] })] }));
|
|
10
|
+
}
|
|
@@ -50,6 +50,9 @@ import { GlobalInspector } from "./GlobalInspector.js";
|
|
|
50
50
|
import { localeFromUrl } from "./locale-url.js";
|
|
51
51
|
import { plusButtonsFor } from "./overlay-geometry.js";
|
|
52
52
|
import { useCanvasBridge } from "./useCanvasBridge.js";
|
|
53
|
+
import { useCanvasDiagnosis } from "./useCanvasDiagnosis.js";
|
|
54
|
+
import { CanvasWarning } from "./CanvasWarning.js";
|
|
55
|
+
import { crashLines, publishCrashConfirm } from "./crash-copy.js";
|
|
53
56
|
import { useInlineSession } from "./useInlineSession.js";
|
|
54
57
|
import { usePageLocale } from "./usePageLocale.js";
|
|
55
58
|
import { useGlobalEdit } from "./useGlobalEdit.js";
|
|
@@ -118,6 +121,10 @@ export function EditorView({ ops, registry, id }) {
|
|
|
118
121
|
/** A global edit since the item's last save ack; and any global edit on this canvas yet. */
|
|
119
122
|
const [globalDirty, setGlobalDirty] = useState(false);
|
|
120
123
|
const [globalTouched, setGlobalTouched] = useState(false);
|
|
124
|
+
/** The editor changed something on this canvas. The locked-zone fill on load is not such a change: when
|
|
125
|
+
* another editor's fill landed first, the refusal and their saved broadcast load their version quietly. */
|
|
126
|
+
const editedRef = useRef(false);
|
|
127
|
+
editedRef.current = touched || globalTouched;
|
|
121
128
|
// Global item name → row id, loaded once per editor mount: the picker's
|
|
122
129
|
// Global group and a bound locked zone need the id to place a node.
|
|
123
130
|
const [globalIds, setGlobalIds] = useState(null);
|
|
@@ -149,8 +156,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
149
156
|
setDirty(false);
|
|
150
157
|
presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
|
|
151
158
|
}
|
|
152
|
-
else if (isStale(r))
|
|
153
|
-
setStale(staleInfoOf(r));
|
|
159
|
+
else if (isStale(r) && editedRef.current)
|
|
160
|
+
setStale(staleInfoOf(r)); // untouched: the effect below reloads quietly
|
|
154
161
|
return r;
|
|
155
162
|
},
|
|
156
163
|
onSaved: () => inlineRef.current?.onSaved(),
|
|
@@ -188,7 +195,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
188
195
|
// Canvas-originated selection: the node is under the cursor already, so
|
|
189
196
|
// this path deliberately skips the scroll every other selection does.
|
|
190
197
|
// (`onCanvasSelect` is declared further down; the handler runs at event time.)
|
|
191
|
-
const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
|
|
198
|
+
const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas, onFrameLoad, crashed } = useCanvasBridge({
|
|
192
199
|
iframeRef, iframeSrc,
|
|
193
200
|
onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
|
|
194
201
|
onHover: (id, path) => {
|
|
@@ -197,6 +204,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
197
204
|
},
|
|
198
205
|
onRendered: () => inlineRef.current?.onRendered(),
|
|
199
206
|
});
|
|
207
|
+
// A canvas the bridge never spoke from is diagnosed and named (spec 2026-09-27 shadowed-page detector).
|
|
208
|
+
const diagnosis = useCanvasDiagnosis(iframeSrc, geometry);
|
|
200
209
|
// ── load: draft mode FIRST, then the record; only then point the iframe
|
|
201
210
|
// at the site route (an iframe loaded before the cookie exists would
|
|
202
211
|
// render the published tree).
|
|
@@ -334,6 +343,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
334
343
|
const name = globalNameOf(id);
|
|
335
344
|
return name ? registryRef.current.globals.find((s) => s.name === name) ?? null : null;
|
|
336
345
|
}, [globalOf]);
|
|
346
|
+
// The crashed sections' banner lines (spec 2026-09-27 section guard §6): the section's title from the
|
|
347
|
+
// registry, a placed global's item title, the node type when the registry has no name for it.
|
|
348
|
+
const crashTitleOf = (id) => {
|
|
349
|
+
const node = treeRef.current ? findNode(treeRef.current, id) : null;
|
|
350
|
+
if (!node)
|
|
351
|
+
return "Section";
|
|
352
|
+
return globalItemOf(id)?.title ?? sectionOf(node.type)?.title ?? node.type;
|
|
353
|
+
};
|
|
354
|
+
const crashedLines = crashLines(crashed, crashTitleOf);
|
|
337
355
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
338
356
|
const refTargets = useMemo(() => {
|
|
339
357
|
const out = {};
|
|
@@ -398,16 +416,26 @@ export function EditorView({ ops, registry, id }) {
|
|
|
398
416
|
}, [globalEdit.stale]);
|
|
399
417
|
/** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
|
|
400
418
|
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
401
|
-
|
|
419
|
+
/** Their version without the freeze: for a view with nothing of the editor's own to lose. */
|
|
420
|
+
const loadTheirsQuietly = useCallback(() => { reset(); keepSelectionRef.current = true; setReloadKey((k) => k + 1); }, [reset]);
|
|
421
|
+
// A refused save the editor never made — the locked-zone fill, when another editor's fill landed first.
|
|
422
|
+
// Decided by editedRef, not by `stale`: the refusal's setStale and the autosave's state can render apart.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (saveState === "stale" && !editedRef.current)
|
|
425
|
+
loadTheirsQuietly();
|
|
426
|
+
}, [saveState, loadTheirsQuietly]);
|
|
427
|
+
// Someone else saved this page (§5): clean or untouched → their version silently; the editor's own unsaved edits → the freeze at once.
|
|
402
428
|
useSavedElsewhere(target, (event) => {
|
|
403
429
|
if (stale)
|
|
404
430
|
return;
|
|
405
|
-
if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
|
|
406
|
-
setStale({ by: event.email, at: Date.now() });
|
|
407
|
-
else {
|
|
431
|
+
if (!(dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")) {
|
|
408
432
|
keepSelectionRef.current = true;
|
|
409
433
|
setReloadKey((k) => k + 1);
|
|
410
434
|
}
|
|
435
|
+
else if (editedRef.current)
|
|
436
|
+
setStale({ by: event.email, at: Date.now() });
|
|
437
|
+
else
|
|
438
|
+
loadTheirsQuietly(); // only the fill is pending: drop it
|
|
411
439
|
});
|
|
412
440
|
// Selecting from the outline must reveal the block. The canvas column is
|
|
413
441
|
// the scroller now (the iframe has no scrollbar of its own — it is sized to
|
|
@@ -512,12 +540,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
512
540
|
return;
|
|
513
541
|
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
514
542
|
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
|
|
515
|
-
|
|
516
|
-
|
|
543
|
+
// Saved like the fill on load, and like it not an edit of the editor's (editedRef).
|
|
544
|
+
if (filled !== tree) {
|
|
545
|
+
setTree(filled);
|
|
546
|
+
treeRef.current = filled;
|
|
547
|
+
mark();
|
|
548
|
+
}
|
|
517
549
|
// `tree` is deliberately not a dependency: this runs when the ids land
|
|
518
550
|
// or a new draft loads, not on every keystroke.
|
|
519
551
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
520
|
-
}, [globalIds, record,
|
|
552
|
+
}, [globalIds, record, mark, tree === null]);
|
|
521
553
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
522
554
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
523
555
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
@@ -602,6 +634,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
602
634
|
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
|
|
603
635
|
// ── publish ──────────────────────────────────────────────────────────
|
|
604
636
|
const doPublish = async () => {
|
|
637
|
+
// A section the preview shows as crashed (spec 2026-09-27 section guard §6): ask before it goes live.
|
|
638
|
+
if (crashedLines.length > 0 && !(await confirm(publishCrashConfirm(crashedLines))))
|
|
639
|
+
return;
|
|
605
640
|
setBanner(null);
|
|
606
641
|
setPageBusy("publish");
|
|
607
642
|
// Never publish behind a pending debounce.
|
|
@@ -856,8 +891,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
856
891
|
trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
|
|
857
892
|
},
|
|
858
893
|
{ label },
|
|
859
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner ||
|
|
860
|
-
setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
894
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
895
|
+
setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: onFrameLoad, style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
861
896
|
const { session } = inline.richtext;
|
|
862
897
|
const item = globalItemOf(session.node);
|
|
863
898
|
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** The meta Smoodly's page renderer writes in draft mode (§3). */
|
|
2
|
+
export declare const DRAFT_META_NAME = "smoodly-draft";
|
|
3
|
+
/** The attribute on the <style> the editor bridge server-renders. */
|
|
4
|
+
export declare const BRIDGE_ATTR = "data-smoodly-bridge";
|
|
5
|
+
export type CanvasProbe = {
|
|
6
|
+
kind: "failed";
|
|
7
|
+
} | {
|
|
8
|
+
kind: "response";
|
|
9
|
+
requested: string;
|
|
10
|
+
url: string;
|
|
11
|
+
status: number;
|
|
12
|
+
redirected: boolean;
|
|
13
|
+
html: string;
|
|
14
|
+
frameOptions: string | null;
|
|
15
|
+
csp: string | null;
|
|
16
|
+
};
|
|
17
|
+
export type CanvasCause = {
|
|
18
|
+
kind: "unreachable";
|
|
19
|
+
} | {
|
|
20
|
+
kind: "redirect";
|
|
21
|
+
to: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "notFound";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "crashed";
|
|
26
|
+
status: number;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "refused";
|
|
29
|
+
status: number;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "shadowed";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "noSearchParams";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "framing";
|
|
36
|
+
header: string;
|
|
37
|
+
value: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "script";
|
|
40
|
+
};
|
|
41
|
+
/** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
|
|
42
|
+
export declare function probeOf(requested: string, res: Response): Promise<CanvasProbe>;
|
|
43
|
+
/** The first matching cause, in the spec's order (§4). */
|
|
44
|
+
export declare function diagnoseCanvas(probe: CanvasProbe): CanvasCause;
|
|
45
|
+
/** The warning's two lines for a cause (§5, verbatim). */
|
|
46
|
+
export declare function canvasMessage(cause: CanvasCause, path: string): {
|
|
47
|
+
editor: string;
|
|
48
|
+
developer: string;
|
|
49
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Why the page editor's canvas stayed silent (spec 2026-09-27
|
|
2
|
+
// shadowed-page detector §3–§5). When the bridge never speaks, the editor
|
|
3
|
+
// fetches the canvas URL once; this module names the cause from the
|
|
4
|
+
// response and words it twice — a sentence for the editor, a line for
|
|
5
|
+
// whoever fixes it. Pure: no React, no store.
|
|
6
|
+
/** The meta Smoodly's page renderer writes in draft mode (§3). */
|
|
7
|
+
export const DRAFT_META_NAME = "smoodly-draft";
|
|
8
|
+
/** The attribute on the <style> the editor bridge server-renders. */
|
|
9
|
+
export const BRIDGE_ATTR = "data-smoodly-bridge";
|
|
10
|
+
/** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
|
|
11
|
+
export async function probeOf(requested, res) {
|
|
12
|
+
return {
|
|
13
|
+
kind: "response", requested, url: res.url || requested, status: res.status, redirected: res.redirected,
|
|
14
|
+
html: await res.text(),
|
|
15
|
+
frameOptions: res.headers.get("x-frame-options"),
|
|
16
|
+
csp: res.headers.get("content-security-policy"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** The first matching cause, in the spec's order (§4). */
|
|
20
|
+
export function diagnoseCanvas(probe) {
|
|
21
|
+
if (probe.kind === "failed")
|
|
22
|
+
return { kind: "unreachable" };
|
|
23
|
+
if (probe.redirected)
|
|
24
|
+
return { kind: "redirect", to: redirectTarget(probe.url, probe.requested) };
|
|
25
|
+
if (probe.status === 404)
|
|
26
|
+
return { kind: "notFound" };
|
|
27
|
+
if (probe.status >= 500)
|
|
28
|
+
return { kind: "crashed", status: probe.status };
|
|
29
|
+
if (probe.status < 200 || probe.status > 299)
|
|
30
|
+
return { kind: "refused", status: probe.status };
|
|
31
|
+
if (!probe.html.includes(`name="${DRAFT_META_NAME}"`))
|
|
32
|
+
return { kind: "shadowed" };
|
|
33
|
+
if (!probe.html.includes(`${BRIDGE_ATTR}=`))
|
|
34
|
+
return { kind: "noSearchParams" };
|
|
35
|
+
const framing = framingHeader(probe.frameOptions, probe.csp, new URL(probe.requested).origin);
|
|
36
|
+
if (framing)
|
|
37
|
+
return { kind: "framing", ...framing };
|
|
38
|
+
return { kind: "script" };
|
|
39
|
+
}
|
|
40
|
+
/** The redirect's end as a visitor would read it: path + search without the editor parameter, the full URL off-origin. */
|
|
41
|
+
function redirectTarget(url, requested) {
|
|
42
|
+
const u = new URL(url);
|
|
43
|
+
u.searchParams.delete("smoodly-editor");
|
|
44
|
+
return u.origin === new URL(requested).origin ? u.pathname + u.search : u.href;
|
|
45
|
+
}
|
|
46
|
+
/** The header that keeps the canvas iframe out, if any: X-Frame-Options DENY, or a frame-ancestors without the site's own origin. */
|
|
47
|
+
function framingHeader(frameOptions, csp, origin) {
|
|
48
|
+
if (frameOptions?.trim().toLowerCase() === "deny")
|
|
49
|
+
return { header: "X-Frame-Options", value: frameOptions };
|
|
50
|
+
const directive = csp?.split(";").map((d) => d.trim()).find((d) => /^frame-ancestors(\s|$)/i.test(d));
|
|
51
|
+
if (!directive)
|
|
52
|
+
return null;
|
|
53
|
+
const sources = directive.split(/\s+/).slice(1);
|
|
54
|
+
const allows = sources.some((s) => s.toLowerCase() === "'self'" || s === "*" || s.replace(/\/$/, "") === origin);
|
|
55
|
+
return allows ? null : { header: "Content-Security-Policy", value: directive };
|
|
56
|
+
}
|
|
57
|
+
/** The warning's two lines for a cause (§5, verbatim). */
|
|
58
|
+
export function canvasMessage(cause, path) {
|
|
59
|
+
switch (cause.kind) {
|
|
60
|
+
case "shadowed":
|
|
61
|
+
return {
|
|
62
|
+
editor: `This page can't be shown here — another route in the site's code answers ${path}.`,
|
|
63
|
+
developer: `A route other than Smoodly's catch-all answers ${path} (the response has no Smoodly draft marker). Rename the page, or move or remove that route.`,
|
|
64
|
+
};
|
|
65
|
+
case "noSearchParams":
|
|
66
|
+
return {
|
|
67
|
+
editor: "This page can't be shown in the editor — its route is missing a setting.",
|
|
68
|
+
developer: `Smoodly rendered ${path} without the editor bridge. The route file must pass searchParams: renderSmoodlyPath(path, { searchParams }).`,
|
|
69
|
+
};
|
|
70
|
+
case "crashed":
|
|
71
|
+
return {
|
|
72
|
+
editor: "This page failed to render.",
|
|
73
|
+
developer: `${path} answered ${cause.status}. The server log has the error — often the page shape, or a component inside a section, throwing.`,
|
|
74
|
+
};
|
|
75
|
+
case "notFound":
|
|
76
|
+
return {
|
|
77
|
+
editor: `Nothing on the site answers ${path}.`,
|
|
78
|
+
developer: `${path} answered 404. Check that the locale is in the app's routing and that the catch-all route file is in place.`,
|
|
79
|
+
};
|
|
80
|
+
case "redirect":
|
|
81
|
+
return {
|
|
82
|
+
editor: `${path} sends visitors to another address, so this page can't be shown.`,
|
|
83
|
+
developer: `${path} redirects to ${cause.to}. Check middleware and the i18n routing.`,
|
|
84
|
+
};
|
|
85
|
+
case "refused":
|
|
86
|
+
return {
|
|
87
|
+
editor: `The site refused to show ${path}.`,
|
|
88
|
+
developer: `${path} answered ${cause.status}. Middleware or the host refused the request; the draft-mode cookie must reach the page.`,
|
|
89
|
+
};
|
|
90
|
+
case "framing":
|
|
91
|
+
return {
|
|
92
|
+
editor: "The site doesn't allow its pages to be shown inside the editor.",
|
|
93
|
+
developer: `${path} sends ${cause.header}: ${cause.value}, which blocks the canvas iframe. Allow the site's own origin (SAMEORIGIN, frame-ancestors 'self').`,
|
|
94
|
+
};
|
|
95
|
+
case "script":
|
|
96
|
+
return {
|
|
97
|
+
editor: "The page loaded but didn't connect to the editor.",
|
|
98
|
+
developer: `${path} rendered with the editor bridge, but its script never reached the editor. Look for an error in the canvas's browser console.`,
|
|
99
|
+
};
|
|
100
|
+
case "unreachable":
|
|
101
|
+
return {
|
|
102
|
+
editor: "The site didn't answer.",
|
|
103
|
+
developer: `The request for ${path} failed before any response — is the site running? A redirect to another origin fails this way too.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CrashedSection } from "../../marks.ts";
|
|
2
|
+
export declare function crashLines(crashed: CrashedSection[], titleOf: (id: string) => string): string[];
|
|
3
|
+
export declare function publishCrashConfirm(lines: string[]): {
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
confirmLabel: string;
|
|
7
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function crashLines(crashed, titleOf) {
|
|
2
|
+
return crashed.map(({ id, live }) => live === "left-out"
|
|
3
|
+
? `“${titleOf(id)}” failed to render in the preview — on the live site it is left out.`
|
|
4
|
+
: `“${titleOf(id)}” failed to render in the preview — on the live site the whole page would fail to load.`);
|
|
5
|
+
}
|
|
6
|
+
export function publishCrashConfirm(lines) {
|
|
7
|
+
const n = lines.length;
|
|
8
|
+
return {
|
|
9
|
+
title: n === 1 ? "Publish with 1 broken section?" : `Publish with ${n} broken sections?`,
|
|
10
|
+
body: [...lines, `Fix or remove ${n === 1 ? "it" : "them"} in the editor, or publish anyway.`].join(" "),
|
|
11
|
+
confirmLabel: "Publish anyway",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CrashedSection } from "../../marks.ts";
|
|
1
2
|
export type BridgeRect = {
|
|
2
3
|
id: string;
|
|
3
4
|
x: number;
|
|
@@ -28,6 +29,7 @@ export type FromBridge = {
|
|
|
28
29
|
emptyZones: ZoneRect[];
|
|
29
30
|
fields: FieldRect[];
|
|
30
31
|
docH: number;
|
|
32
|
+
crashed: CrashedSection[];
|
|
31
33
|
}
|
|
32
34
|
/** A click: the node under it, the marked field under it, and where (iframe client coordinates). */
|
|
33
35
|
| {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RefObject } from "react";
|
|
2
2
|
import type { BridgeRect, FieldRect, ToBridge, ZoneRect } from "./protocol.ts";
|
|
3
|
+
import type { CrashedSection } from "../../marks.ts";
|
|
3
4
|
export type Geometry = "waiting" | "ok" | "missing";
|
|
4
5
|
export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }: {
|
|
5
6
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
@@ -16,4 +17,6 @@ export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHove
|
|
|
16
17
|
docH: number;
|
|
17
18
|
geometry: Geometry;
|
|
18
19
|
postToCanvas: (m: ToBridge) => void;
|
|
20
|
+
onFrameLoad: () => void;
|
|
21
|
+
crashed: CrashedSection[];
|
|
19
22
|
};
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
// The admin side of the editor⇄canvas protocol: the geometry the bridge
|
|
2
2
|
// reports (node rects, empty zones, document height), select/hover
|
|
3
3
|
// forwarded from the iframe, and `postToCanvas` for refresh/scroll.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// Silence is survivable — GEOMETRY_TIMEOUT_MS after the frame's load
|
|
5
|
+
// with no bridge message, `geometry` turns "missing" and the page editor
|
|
6
|
+
// diagnoses why (spec 2026-09-27 shadowed-page detector §2); the iframe
|
|
7
|
+
// and the outline keep working.
|
|
6
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
9
|
const GEOMETRY_TIMEOUT_MS = 4000;
|
|
8
10
|
export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }) {
|
|
9
11
|
const [rects, setRects] = useState([]);
|
|
10
12
|
const [emptyZones, setEmptyZones] = useState([]);
|
|
11
13
|
const [fields, setFields] = useState([]);
|
|
14
|
+
const [crashed, setCrashed] = useState([]);
|
|
12
15
|
const [docH, setDocH] = useState(900);
|
|
13
16
|
const [geometry, setGeometry] = useState("waiting");
|
|
17
|
+
// The wait starts at the frame's load, not its src: a slow first compile in `next dev` is not silence.
|
|
18
|
+
const [loaded, setLoaded] = useState(false);
|
|
14
19
|
// The listener registers once; the handlers are read through refs.
|
|
15
20
|
const handlers = useRef({ onSelect, onHover, onRendered });
|
|
16
21
|
handlers.current = { onSelect, onHover, onRendered };
|
|
17
22
|
// A new canvas URL (a locale switch, a rename) means new geometry — the
|
|
18
23
|
// previous page's rects are stale the instant the iframe starts loading
|
|
19
24
|
// a different route, so clear them alongside resetting geometry.
|
|
20
|
-
useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); setFields([]); }, [iframeSrc]);
|
|
25
|
+
useEffect(() => { setGeometry("waiting"); setLoaded(false); setRects([]); setEmptyZones([]); setFields([]); setCrashed([]); }, [iframeSrc]);
|
|
21
26
|
const postToCanvas = useCallback((m) => {
|
|
22
27
|
iframeRef.current?.contentWindow?.postMessage(m, "*");
|
|
23
28
|
}, [iframeRef]);
|
|
@@ -28,12 +33,13 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
|
|
|
28
33
|
return;
|
|
29
34
|
if (e.source !== iframeRef.current?.contentWindow)
|
|
30
35
|
return;
|
|
36
|
+
setGeometry("ok"); // any bridge message: Smoodly rendered the canvas with its bridge
|
|
31
37
|
if (m.kind === "rects") {
|
|
32
38
|
setRects(m.nodes);
|
|
33
39
|
setEmptyZones(m.emptyZones);
|
|
34
40
|
setFields(m.fields);
|
|
35
41
|
setDocH(m.docH);
|
|
36
|
-
|
|
42
|
+
setCrashed(m.crashed ?? []);
|
|
37
43
|
}
|
|
38
44
|
else if (m.kind === "select") {
|
|
39
45
|
handlers.current.onSelect(m.id, m.path, m.x, m.y);
|
|
@@ -49,10 +55,11 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
|
|
|
49
55
|
return () => window.removeEventListener("message", onMessage);
|
|
50
56
|
}, [iframeRef]);
|
|
51
57
|
useEffect(() => {
|
|
52
|
-
if (!iframeSrc || geometry !== "waiting")
|
|
58
|
+
if (!iframeSrc || !loaded || geometry !== "waiting")
|
|
53
59
|
return;
|
|
54
60
|
const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
|
|
55
61
|
return () => clearTimeout(t);
|
|
56
|
-
}, [iframeSrc, geometry]);
|
|
57
|
-
|
|
62
|
+
}, [iframeSrc, loaded, geometry]);
|
|
63
|
+
const onFrameLoad = useCallback(() => setLoaded(true), []);
|
|
64
|
+
return { rects, emptyZones, fields, docH, geometry, postToCanvas, onFrameLoad, crashed };
|
|
58
65
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type CanvasCause } from "./canvas-diagnosis.ts";
|
|
2
|
+
import type { Geometry } from "./useCanvasBridge.ts";
|
|
3
|
+
export type CanvasDiagnosis = {
|
|
4
|
+
cause: CanvasCause;
|
|
5
|
+
path: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function useCanvasDiagnosis(iframeSrc: string | null, geometry: Geometry): CanvasDiagnosis | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The page editor's diagnosis of a silent canvas (spec 2026-09-27
|
|
2
|
+
// shadowed-page detector §4): once the bridge's wait ends in "missing",
|
|
3
|
+
// fetch the canvas URL once — same origin, so the draft-mode cookie rides
|
|
4
|
+
// along and the server renders what the frame got — and name the cause.
|
|
5
|
+
// A new src, or the bridge speaking after all, drops the answer.
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { diagnoseCanvas, probeOf } from "./canvas-diagnosis.js";
|
|
8
|
+
export function useCanvasDiagnosis(iframeSrc, geometry) {
|
|
9
|
+
const [diagnosis, setDiagnosis] = useState(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
setDiagnosis(null);
|
|
12
|
+
if (!iframeSrc || geometry !== "missing")
|
|
13
|
+
return;
|
|
14
|
+
let live = true;
|
|
15
|
+
const requested = new URL(iframeSrc, window.location.href);
|
|
16
|
+
fetch(requested.href, { cache: "no-store" })
|
|
17
|
+
.then((res) => probeOf(requested.href, res))
|
|
18
|
+
.catch(() => ({ kind: "failed" }))
|
|
19
|
+
.then((probe) => { if (live)
|
|
20
|
+
setDiagnosis({ cause: diagnoseCanvas(probe), path: requested.pathname }); });
|
|
21
|
+
return () => { live = false; };
|
|
22
|
+
}, [iframeSrc, geometry]);
|
|
23
|
+
return diagnosis;
|
|
24
|
+
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
|
|
|
7
7
|
export { makeClientOps } from "./client-ops.ts";
|
|
8
8
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
9
9
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
10
|
+
export type { AuthConfig } from "./shell/session.ts";
|
|
10
11
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
11
12
|
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
|
|
12
13
|
export * from "./tree-ops.ts";
|
|
@@ -14,7 +14,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
14
14
|
// offsets map 1:1 onto the overlay.
|
|
15
15
|
import { useEffect } from "react";
|
|
16
16
|
import { useRouter } from "next/navigation";
|
|
17
|
-
import { BLANK_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
|
|
17
|
+
import { BLANK_ATTR, CRASHED_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, crashedOf, resolveMarkPath } from "../../marks.js";
|
|
18
18
|
const post = (m) => window.parent.postMessage(m, "*");
|
|
19
19
|
/** The height of an md-spaced section with nothing inside (3.5rem + 3.5rem). */
|
|
20
20
|
// The editor sizes the iframe to the reported height; the document's own
|
|
@@ -69,7 +69,9 @@ export function EditorBridge() {
|
|
|
69
69
|
}
|
|
70
70
|
// Rounded up from the fractional box, so the frame is never a hair too short.
|
|
71
71
|
const docH = Math.ceil(Math.max(document.documentElement.scrollHeight, document.documentElement.getBoundingClientRect().height));
|
|
72
|
-
|
|
72
|
+
// Crashed sections (spec 2026-09-27 section guard §6): the placeholders the guard and the canvas net left.
|
|
73
|
+
const crashed = crashedOf(all(`[${CRASHED_ATTR}]`));
|
|
74
|
+
post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH, crashed });
|
|
73
75
|
};
|
|
74
76
|
const raf = () => requestAnimationFrame(report);
|
|
75
77
|
const ro = new ResizeObserver(raf);
|
package/dist/admin/next/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// The admin route group's entry. createSmoodlyAdmin imports the "use
|
|
2
|
+
// client" shell, so any module that imports a value from here puts the
|
|
3
|
+
// whole admin in its route's JavaScript — whether it renders or not.
|
|
4
|
+
// Never import it from code the site's routes reach (smoodly/server.ts
|
|
5
|
+
// included); the auth glue lives in `smoodly/admin`, which has no client
|
|
6
|
+
// code.
|
|
1
7
|
export { createAdminOpHandler } from "./op-handler.js";
|
|
2
8
|
export { createSmoodlyAdmin } from "./create-admin.js";
|
|
3
|
-
export { supabaseAdminAuth } from "../supabase-auth.js";
|
|
@@ -4,6 +4,25 @@ export type ActionMenuItem = {
|
|
|
4
4
|
disabled?: boolean;
|
|
5
5
|
danger?: boolean;
|
|
6
6
|
};
|
|
7
|
+
/** The button's box in viewport pixels, `right` measured from the viewport's right edge. */
|
|
8
|
+
type Anchor = {
|
|
9
|
+
top: number;
|
|
10
|
+
bottom: number;
|
|
11
|
+
right: number;
|
|
12
|
+
};
|
|
13
|
+
export declare const anchorOf: (button: {
|
|
14
|
+
top: number;
|
|
15
|
+
bottom: number;
|
|
16
|
+
right: number;
|
|
17
|
+
}, viewportWidth: number) => Anchor;
|
|
18
|
+
/** Whether a scroll moved the button since the menu opened. */
|
|
19
|
+
export declare const anchorMoved: (open: Anchor, now: Anchor) => boolean;
|
|
20
|
+
/** 6px under the button from its right edge, or above it when it does not fit below and there is more room above. */
|
|
21
|
+
export declare function menuPlacement(anchor: Anchor, viewportHeight: number, height: number): {
|
|
22
|
+
right: number;
|
|
23
|
+
top?: number;
|
|
24
|
+
bottom?: number;
|
|
25
|
+
};
|
|
7
26
|
export declare function ActionMenu({ label, items, disabled, small }: {
|
|
8
27
|
label: string;
|
|
9
28
|
items: ActionMenuItem[];
|
|
@@ -11,3 +30,4 @@ export declare function ActionMenu({ label, items, disabled, small }: {
|
|
|
11
30
|
/** The smaller round button, next to a small Publish. */
|
|
12
31
|
small?: boolean;
|
|
13
32
|
}): import("react").JSX.Element;
|
|
33
|
+
export {};
|
|
@@ -8,14 +8,35 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// right edge (every control sits at its bar's or card's right). It is
|
|
9
9
|
// position:fixed from the button's rect (2026-09-26): inside a table cell
|
|
10
10
|
// an absolute child was clipped by the cell's overflow, so the Members
|
|
11
|
-
// row menu opened invisibly.
|
|
12
|
-
|
|
11
|
+
// row menu opened invisibly. Near the bottom of the screen it opens upward,
|
|
12
|
+
// the Combobox's rule (2026-09-27: a fixed menu cut off by the viewport
|
|
13
|
+
// cannot be scrolled into view). A scroll that moves the button closes it
|
|
14
|
+
// rather than leaving it behind — only one that moves it: scroll events
|
|
15
|
+
// arrive a frame late, so the one from bringing the row into view could
|
|
16
|
+
// land after the click and close the menu it had just opened.
|
|
17
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
18
|
+
import { opensUp } from "./combo-box.js";
|
|
13
19
|
import { cx } from "./primitives.js";
|
|
20
|
+
export const anchorOf = (button, viewportWidth) => ({ top: button.top, bottom: button.bottom, right: viewportWidth - button.right });
|
|
21
|
+
/** Whether a scroll moved the button since the menu opened. */
|
|
22
|
+
export const anchorMoved = (open, now) => open.top !== now.top || open.bottom !== now.bottom || open.right !== now.right;
|
|
23
|
+
/** 6px under the button from its right edge, or above it when it does not fit below and there is more room above. */
|
|
24
|
+
export function menuPlacement(anchor, viewportHeight, height) {
|
|
25
|
+
return opensUp({ below: viewportHeight - anchor.bottom - 6, above: anchor.top - 6, height })
|
|
26
|
+
? { right: anchor.right, bottom: viewportHeight - anchor.top + 6 }
|
|
27
|
+
: { right: anchor.right, top: anchor.bottom + 6 };
|
|
28
|
+
}
|
|
14
29
|
export function ActionMenu({ label, items, disabled, small = false }) {
|
|
15
|
-
/** Open: the
|
|
30
|
+
/** Open: the button's box when it opened; the popover is placed from it. */
|
|
16
31
|
const [open, setOpen] = useState(null);
|
|
32
|
+
const [place, setPlace] = useState(null);
|
|
17
33
|
const ref = useRef(null);
|
|
18
34
|
const button = useRef(null);
|
|
35
|
+
const menu = useRef(null);
|
|
36
|
+
// Measured before paint, so an upward menu never flashes below first.
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
setPlace(open && menu.current ? menuPlacement(open, window.innerHeight, menu.current.offsetHeight) : null);
|
|
39
|
+
}, [open]);
|
|
19
40
|
const toggle = () => {
|
|
20
41
|
if (open) {
|
|
21
42
|
setOpen(null);
|
|
@@ -23,7 +44,7 @@ export function ActionMenu({ label, items, disabled, small = false }) {
|
|
|
23
44
|
}
|
|
24
45
|
const r = button.current?.getBoundingClientRect();
|
|
25
46
|
if (r)
|
|
26
|
-
setOpen(
|
|
47
|
+
setOpen(anchorOf(r, window.innerWidth));
|
|
27
48
|
};
|
|
28
49
|
useEffect(() => {
|
|
29
50
|
if (!open)
|
|
@@ -33,14 +54,19 @@ export function ActionMenu({ label, items, disabled, small = false }) {
|
|
|
33
54
|
close(); };
|
|
34
55
|
const onKey = (e) => { if (e.key === "Escape")
|
|
35
56
|
close(); };
|
|
57
|
+
const onScroll = () => {
|
|
58
|
+
const r = button.current?.getBoundingClientRect();
|
|
59
|
+
if (!r || anchorMoved(open, anchorOf(r, window.innerWidth)))
|
|
60
|
+
close();
|
|
61
|
+
};
|
|
36
62
|
document.addEventListener("pointerdown", onDown);
|
|
37
63
|
document.addEventListener("keydown", onKey);
|
|
38
|
-
document.addEventListener("scroll",
|
|
64
|
+
document.addEventListener("scroll", onScroll, true);
|
|
39
65
|
return () => {
|
|
40
66
|
document.removeEventListener("pointerdown", onDown);
|
|
41
67
|
document.removeEventListener("keydown", onKey);
|
|
42
|
-
document.removeEventListener("scroll",
|
|
68
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
43
69
|
};
|
|
44
70
|
}, [open]);
|
|
45
|
-
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", style: { top: open.
|
|
71
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { ref: menu, role: "menu", className: "sm-picker sm-picker--menu", style: place ?? { top: open.bottom + 6, right: open.right, visibility: "hidden" }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
46
72
|
}
|
|
@@ -19,6 +19,7 @@ export type CustomSectionProps = {
|
|
|
19
19
|
export type CustomSectionComponent = ((props: CustomSectionProps) => ReactElement) & {
|
|
20
20
|
schema: {
|
|
21
21
|
name: string;
|
|
22
|
+
title: string;
|
|
22
23
|
};
|
|
23
24
|
};
|
|
24
25
|
export declare function createCustomSection({ RichText: Bound, breakpoint, elements }: CustomOptions & {
|
|
@@ -4,6 +4,7 @@ import { RichText } from "../richtext-render.js";
|
|
|
4
4
|
import { FIELD_ATTR, ITEM_ATTR } from "../marks.js";
|
|
5
5
|
import { CUSTOM_NODE_TYPE, DEFAULT_BREAKPOINT } from "./schema.js";
|
|
6
6
|
import { customCss } from "./css.js";
|
|
7
|
+
import { RENDER_VIEW } from "../section-guard.js";
|
|
7
8
|
const isRow = (r) => typeof r === "object" && r !== null && Array.isArray(r.columns);
|
|
8
9
|
export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREAKPOINT, elements = [] }) {
|
|
9
10
|
const Body = Bound ?? (({ doc }) => _jsx(RichText, { doc: doc, elements: elements }));
|
|
@@ -15,6 +16,8 @@ export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREA
|
|
|
15
16
|
: styles;
|
|
16
17
|
return (_jsx(Section, { styles: withLayout, "data-custom": "", children: list.map((row, i) => (_jsx("div", { className: row.reverse ? "smd-row smd-row--reverse" : "smd-row", "data-row": i, [ITEM_ATTR]: `rows.${i}`, children: row.columns.map((doc, j) => (_jsx("div", { className: "smd-col", [FIELD_ATTR]: `columns.${j}`, children: _jsx(Body, { doc: doc }) }, j))) }, i))) }));
|
|
17
18
|
}
|
|
18
|
-
CustomSection.schema = { name: CUSTOM_NODE_TYPE };
|
|
19
|
+
CustomSection.schema = { name: CUSTOM_NODE_TYPE, title: "Custom section" };
|
|
20
|
+
// Smoodly's own plain view: the guard may call it (spec 2026-09-27 section guard §1).
|
|
21
|
+
CustomSection[RENDER_VIEW] = CustomSection;
|
|
19
22
|
return CustomSection;
|
|
20
23
|
}
|
package/dist/marks.d.ts
CHANGED
|
@@ -53,4 +53,16 @@ export type Scope<T> = Marks<T> & {
|
|
|
53
53
|
export declare function marks<S extends {
|
|
54
54
|
__f?: BuilderMap;
|
|
55
55
|
}>(_schema: S): Marks<NamespaceValues<NonNullable<S["__f"]>>>;
|
|
56
|
+
/** A crashed section's placeholder (spec 2026-09-27 section guard §5): `section` — the guard caught
|
|
57
|
+
* it and the live site leaves it out; `page` — only the canvas net caught it, the live page would fail. */
|
|
58
|
+
export declare const CRASHED_ATTR = "data-smd-crashed";
|
|
59
|
+
/** One crashed section as the bridge reports it. */
|
|
60
|
+
export type CrashedSection = {
|
|
61
|
+
id: string;
|
|
62
|
+
live: "left-out" | "page-fails";
|
|
63
|
+
};
|
|
64
|
+
/** The crashed sections among the marked elements, in document order — what the bridge posts. */
|
|
65
|
+
export declare function crashedOf(els: ReadonlyArray<{
|
|
66
|
+
getAttribute(name: string): string | null;
|
|
67
|
+
}>): CrashedSection[];
|
|
56
68
|
export {};
|
package/dist/marks.js
CHANGED
|
@@ -44,3 +44,17 @@ function level() {
|
|
|
44
44
|
export function marks(_schema) {
|
|
45
45
|
return level();
|
|
46
46
|
}
|
|
47
|
+
/** A crashed section's placeholder (spec 2026-09-27 section guard §5): `section` — the guard caught
|
|
48
|
+
* it and the live site leaves it out; `page` — only the canvas net caught it, the live page would fail. */
|
|
49
|
+
export const CRASHED_ATTR = "data-smd-crashed";
|
|
50
|
+
/** The crashed sections among the marked elements, in document order — what the bridge posts. */
|
|
51
|
+
export function crashedOf(els) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const el of els) {
|
|
54
|
+
const id = el.getAttribute(NODE_ATTR);
|
|
55
|
+
const mark = el.getAttribute(CRASHED_ATTR);
|
|
56
|
+
if (id && (mark === "section" || mark === "page"))
|
|
57
|
+
out.push({ id, live: mark === "section" ? "left-out" : "page-fails" });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Component, type ReactNode } from "react";
|
|
2
|
+
type Props = {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
};
|
|
7
|
+
type State = {
|
|
8
|
+
failed: boolean;
|
|
9
|
+
error: unknown;
|
|
10
|
+
};
|
|
11
|
+
export declare class CanvasBoundary extends Component<Props, State> {
|
|
12
|
+
state: State;
|
|
13
|
+
static getDerivedStateFromError(error: unknown): State;
|
|
14
|
+
render(): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The Next adapter's canvas net (spec 2026-09-27 section guard §5): in
|
|
4
|
+
// editor mode each section sits in <Suspense> and this boundary, so a
|
|
5
|
+
// throw the guard cannot catch — in a component the section renders, or
|
|
6
|
+
// in a 'use client' section — fails only that section: Server Components
|
|
7
|
+
// hand it to the browser to retry, and this catches it there. Never
|
|
8
|
+
// rendered outside editor mode. Imports nothing from `next`, but it is
|
|
9
|
+
// Next's mechanism, so it lives with the adapter.
|
|
10
|
+
import { Component } from "react";
|
|
11
|
+
import { CrashPlaceholder } from "../section-crash.js";
|
|
12
|
+
export class CanvasBoundary extends Component {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(...arguments);
|
|
15
|
+
this.state = { failed: false, error: undefined };
|
|
16
|
+
}
|
|
17
|
+
static getDerivedStateFromError(error) {
|
|
18
|
+
return { failed: true, error };
|
|
19
|
+
}
|
|
20
|
+
render() {
|
|
21
|
+
return this.state.failed
|
|
22
|
+
? _jsx(CrashPlaceholder, { id: this.props.id, title: this.props.title, error: this.state.error, live: "page" })
|
|
23
|
+
: this.props.children;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ComponentType, type ReactElement } from "react";
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
3
|
import type { Descriptor } from "../fields.ts";
|
|
4
4
|
import { type PairedPage } from "../render.tsx";
|
|
5
5
|
import type { GlobalSchema } from "../global.ts";
|
|
6
6
|
import { type SmoodlySite } from "../site.ts";
|
|
7
|
+
/** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
|
|
8
|
+
export declare const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it \u2014 a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
|
|
7
9
|
/** A registered section, as renderPage's registry needs it. */
|
|
8
10
|
type SectionEntry = ComponentType<any> & {
|
|
9
11
|
schema: {
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The site-route seam (spec 2026-09-05 §5): the two route files call
|
|
3
|
+
// renderSmoodlyPath; everything else — request gating, draft mode, the
|
|
4
|
+
// cache keyed by (locale, path) and tagged by id, 404s, the editor
|
|
5
|
+
// bridge — lives here, over the framework-free site layer. Bound once by
|
|
6
|
+
// the app, exactly like createAdminOpHandler.
|
|
7
|
+
import { Suspense } from "react";
|
|
2
8
|
import { unstable_cache } from "next/cache";
|
|
3
9
|
import { draftMode } from "next/headers";
|
|
4
|
-
import { notFound, permanentRedirect } from "next/navigation";
|
|
10
|
+
import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
|
|
5
11
|
import { connection } from "next/server";
|
|
6
12
|
import { EditorBridge } from "../admin/next/bridge.js";
|
|
13
|
+
import { CanvasBoundary } from "./canvas-boundary.js";
|
|
14
|
+
import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
|
|
7
15
|
import { fixedPathFor } from "../localize.js";
|
|
8
16
|
import { joinPath } from "../paths.js";
|
|
9
17
|
import { renderPage } from "../render.js";
|
|
10
18
|
import { entryTag, pageTag, globalTag } from "../revalidate.js";
|
|
11
19
|
import { pageContextOf } from "../site.js";
|
|
12
20
|
import { metadataFrom } from "./meta.js";
|
|
21
|
+
/** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
|
|
22
|
+
export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
|
|
23
|
+
/** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
|
|
24
|
+
* browser, where CanvasBoundary catches it. Defined here, not in the client file: a function exported
|
|
25
|
+
* from a "use client" module is a reference on the server and cannot be called. */
|
|
26
|
+
const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasBoundary, { id: section.id, title: section.title, children: child }) }));
|
|
13
27
|
export function createPageRenderer(options) {
|
|
14
28
|
const { site } = options;
|
|
15
29
|
const href = options.href ?? ((path) => path);
|
|
@@ -48,7 +62,7 @@ export function createPageRenderer(options) {
|
|
|
48
62
|
notFound();
|
|
49
63
|
}
|
|
50
64
|
const page = pageContextOf(hit, href);
|
|
51
|
-
return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page });
|
|
65
|
+
return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page, rethrow: unstable_rethrow, editorWrap: canvasWrap });
|
|
52
66
|
}
|
|
53
67
|
const View = entryPages.get(hit.collection);
|
|
54
68
|
if (!View) {
|
|
@@ -67,10 +81,22 @@ export function createPageRenderer(options) {
|
|
|
67
81
|
languages[locale] = href(alt.path, locale);
|
|
68
82
|
return { alternates: { languages } };
|
|
69
83
|
}
|
|
84
|
+
// Next's cacheComponents serves the catch-all from a fallback shell that
|
|
85
|
+
// commits 200 before the page renders, so 404s and 308s are lost (spec
|
|
86
|
+
// 2026-09-27 cache components). The variable is Next's own, inlined at
|
|
87
|
+
// build: a warning, never an error, and once per renderer.
|
|
88
|
+
let cacheComponentsWarned = false;
|
|
89
|
+
function warnCacheComponents() {
|
|
90
|
+
if (cacheComponentsWarned || String(process.env.__NEXT_CACHE_COMPONENTS) !== "true")
|
|
91
|
+
return;
|
|
92
|
+
cacheComponentsWarned = true;
|
|
93
|
+
console.warn(CACHE_COMPONENTS_WARNING);
|
|
94
|
+
}
|
|
70
95
|
async function page(pathFor, opts, accept) {
|
|
96
|
+
warnCacheComponents(); // before connection(), so a build's prerender prints it too
|
|
71
97
|
// A CMS route is request-rendered by construction: without this a
|
|
72
98
|
// route with no dynamic segment would be prerendered against the
|
|
73
|
-
// database at build time (DESIGN.md §3
|
|
99
|
+
// database at build time (DESIGN.md §3).
|
|
74
100
|
await connection();
|
|
75
101
|
const locale = opts?.locale ?? site.locales.default;
|
|
76
102
|
const path = pathFor(locale);
|
|
@@ -86,7 +112,10 @@ export function createPageRenderer(options) {
|
|
|
86
112
|
const hit = target ? await load(target, locale, path, draft, version) : null;
|
|
87
113
|
if (!hit || !accept(hit))
|
|
88
114
|
notFound();
|
|
89
|
-
|
|
115
|
+
// The draft marker tells the page editor's canvas diagnosis that Smoodly
|
|
116
|
+
// rendered this response (spec 2026-09-27 shadowed-page detector §3);
|
|
117
|
+
// React hoists the meta into the head. Never outside draft mode.
|
|
118
|
+
return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
|
|
90
119
|
}
|
|
91
120
|
async function metadata(pathFor, opts, accept) {
|
|
92
121
|
// generateMetadata runs alongside the page render, so it gates too.
|
package/dist/pairing.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { defaultsOf } from "./field-defaults.js";
|
|
3
|
+
import { isPlainView, RENDER_VIEW } from "./section-guard.js";
|
|
3
4
|
function pair(schema, View) {
|
|
4
5
|
const defaults = defaultsOf(schema.settings);
|
|
5
|
-
const
|
|
6
|
+
const viewProps = (props) => {
|
|
6
7
|
const { settings: overrides, styles, ...fields } = props ?? {};
|
|
7
|
-
|
|
8
|
+
return { ...fields, settings: { ...defaults, ...overrides }, styles: styles ?? {} };
|
|
9
|
+
};
|
|
10
|
+
const Component = (props) => {
|
|
8
11
|
const V = View;
|
|
9
|
-
return _jsx(V, { ...
|
|
12
|
+
return _jsx(V, { ...viewProps(props) });
|
|
10
13
|
};
|
|
11
14
|
Component.schema = schema;
|
|
15
|
+
// The guard's direct call (spec 2026-09-27 section guard §1): only a plain-function view may be called.
|
|
16
|
+
if (isPlainView(View)) {
|
|
17
|
+
Component[RENDER_VIEW] = (props) => View(viewProps(props));
|
|
18
|
+
}
|
|
12
19
|
return Component;
|
|
13
20
|
}
|
|
14
21
|
export function section(schema, View) {
|
package/dist/render.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ComponentType, type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import type { PageAlternate } from "./paths.ts";
|
|
3
3
|
import type { PageSchema } from "./page.ts";
|
|
4
4
|
import type { BuilderMap, NamespaceValues } from "./schema.ts";
|
|
@@ -70,8 +70,14 @@ export declare const EMPTY_PAGE_CONTEXT: PageContext;
|
|
|
70
70
|
type AnyPaired = ComponentType<any> & {
|
|
71
71
|
schema: {
|
|
72
72
|
name: string;
|
|
73
|
+
title?: string;
|
|
73
74
|
};
|
|
74
75
|
};
|
|
76
|
+
/** The adapter's canvas net (spec 2026-09-27 section guard §5): called per section, in editor mode only. */
|
|
77
|
+
export type EditorWrap = (child: ReactNode, section: {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
}) => ReactNode;
|
|
75
81
|
export type ZoneContent = {
|
|
76
82
|
readonly name: string;
|
|
77
83
|
readonly nodes: TreeNode[];
|
|
@@ -79,6 +85,9 @@ export type ZoneContent = {
|
|
|
79
85
|
/** The config's CSS values behind the styles set (spec 2026-09-14 §2). */
|
|
80
86
|
readonly styleValues: StyleValues;
|
|
81
87
|
readonly editor: boolean;
|
|
88
|
+
/** Control flow the guard must let through — the adapter's (Next: unstable_rethrow; spec 2026-09-27 section guard §2). */
|
|
89
|
+
readonly rethrow?: (error: unknown) => void;
|
|
90
|
+
readonly editorWrap?: EditorWrap;
|
|
82
91
|
};
|
|
83
92
|
export declare function Zone({ of: content }: {
|
|
84
93
|
of: ZoneContent;
|
|
@@ -102,5 +111,7 @@ export type Registry = {
|
|
|
102
111
|
export declare function renderPage<Zones extends Record<string, unknown>>(Page: PairedPage<Zones>, tree: PageTree, registry: Registry, options?: {
|
|
103
112
|
editor?: boolean;
|
|
104
113
|
page?: PageContext;
|
|
114
|
+
rethrow?: (error: unknown) => void;
|
|
115
|
+
editorWrap?: EditorWrap;
|
|
105
116
|
}): ReactElement;
|
|
106
117
|
export {};
|
package/dist/render.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from "react";
|
|
2
3
|
import { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "./marks.js";
|
|
3
4
|
import { SMD_KEY } from "./section-wrapper.js";
|
|
4
5
|
import { DEFAULT_STYLE_VALUES } from "./styles.js";
|
|
5
6
|
import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
|
|
7
|
+
import { renderGuarded } from "./section-guard.js";
|
|
8
|
+
import { PLACEHOLDER_STYLE } from "./section-crash.js";
|
|
6
9
|
/** The `page` prop of a view rendered outside the site (a test, a preview
|
|
7
10
|
* of a shape): a real object, so a view never guards an optional prop, but
|
|
8
11
|
* no position in the tree. Frozen: one singleton shared by every such
|
|
@@ -26,14 +29,17 @@ export function Zone({ of: content }) {
|
|
|
26
29
|
// Editor mode draws a box the bridge can measure, so the node is selectable in place
|
|
27
30
|
// (spec 2026-09-27 editor safety §5); the site renders nothing, as before.
|
|
28
31
|
if (content.editor) {
|
|
29
|
-
return (_jsxs("div", { [NODE_ATTR]: node.id, style:
|
|
32
|
+
return (_jsxs("div", { [NODE_ATTR]: node.id, style: PLACEHOLDER_STYLE, children: ["Unknown section `", node.type, "`"] }, node.id));
|
|
30
33
|
}
|
|
31
34
|
console.warn(`smoodly: no registered component for node type "${node.type}" — skipping.`);
|
|
32
35
|
return null;
|
|
33
36
|
}
|
|
34
37
|
// The node id, the css and the values ride the styles under one reserved key; the wrapper reads and strips it.
|
|
35
38
|
const styles = { ...(node.styles ?? {}), [SMD_KEY]: { node: node.id, css: typeof node.css === "string" ? node.css : undefined, values: content.styleValues } };
|
|
36
|
-
|
|
39
|
+
// Through the guard (spec 2026-09-27 section guard): a throw in the section's own code leaves it out, not the page.
|
|
40
|
+
const title = Component.schema.title ?? node.type;
|
|
41
|
+
const section = renderGuarded(Component, { ...node.fields, settings: node.settings ?? {}, styles }, { id: node.id, type: node.type, title, editor: content.editor, rethrow: content.rethrow });
|
|
42
|
+
return (_jsx(Fragment, { children: content.editor && content.editorWrap ? content.editorWrap(section, { id: node.id, title }) : section }, node.id));
|
|
37
43
|
}) }));
|
|
38
44
|
}
|
|
39
45
|
export function page(schema, View) {
|
|
@@ -54,7 +60,7 @@ export function renderPage(Page, tree, registry, options) {
|
|
|
54
60
|
const styleValues = registry.styles ?? DEFAULT_STYLE_VALUES;
|
|
55
61
|
const zones = Object.fromEntries(Object.keys(Page.schema.zones).map((zone) => [
|
|
56
62
|
zone,
|
|
57
|
-
{ name: zone, nodes: tree.zones?.[zone] ?? [], components, styleValues, editor: options?.editor ?? false },
|
|
63
|
+
{ name: zone, nodes: tree.zones?.[zone] ?? [], components, styleValues, editor: options?.editor ?? false, rethrow: options?.rethrow, editorWrap: options?.editorWrap },
|
|
58
64
|
]));
|
|
59
65
|
return _jsx(Page, { zones: zones, page: (options?.page ?? EMPTY_PAGE_CONTEXT) });
|
|
60
66
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
/** The canvas placeholders' look — the unknown section's too (spec 2026-09-27 editor safety §5). */
|
|
3
|
+
export declare const PLACEHOLDER_STYLE: CSSProperties;
|
|
4
|
+
/** Why it crashed, for the placeholder: the message, or the digest when a production build stripped the message. */
|
|
5
|
+
export declare function crashMessage(error: unknown): string;
|
|
6
|
+
export declare function CrashPlaceholder({ id, title, error, live }: {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
error: unknown;
|
|
10
|
+
live: "section" | "page";
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { CRASHED_ATTR, NODE_ATTR } from "./marks.js";
|
|
3
|
+
/** The canvas placeholders' look — the unknown section's too (spec 2026-09-27 editor safety §5). */
|
|
4
|
+
export const PLACEHOLDER_STYLE = {
|
|
5
|
+
padding: "24px 16px", border: "1px dashed #b54708", color: "#b54708", font: "500 14px/1.4 system-ui, sans-serif", textAlign: "center",
|
|
6
|
+
};
|
|
7
|
+
/** Why it crashed, for the placeholder: the message, or the digest when a production build stripped the message. */
|
|
8
|
+
export function crashMessage(error) {
|
|
9
|
+
const digest = typeof error === "object" && error !== null ? error.digest : undefined;
|
|
10
|
+
if (typeof digest === "string")
|
|
11
|
+
return `the server log has the error (digest ${digest})`;
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
export function CrashPlaceholder({ id, title, error, live }) {
|
|
15
|
+
return (_jsx("div", { [NODE_ATTR]: id, [CRASHED_ATTR]: live, style: PLACEHOLDER_STYLE, children: `Section “${title}” failed to render — ${crashMessage(error)}` }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
/** The paired component's own view call, set by pair() and the custom section — only for a plain-function view. */
|
|
3
|
+
export declare const RENDER_VIEW: unique symbol;
|
|
4
|
+
/** A view the guard may call: a plain function — not a client reference or memo/forwardRef ($$typeof), not a class. */
|
|
5
|
+
export declare function isPlainView(View: unknown): boolean;
|
|
6
|
+
export type GuardContext = {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
title: string;
|
|
10
|
+
editor: boolean;
|
|
11
|
+
rethrow?: (error: unknown) => void;
|
|
12
|
+
};
|
|
13
|
+
type Settled = {
|
|
14
|
+
ok: true;
|
|
15
|
+
value: unknown;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
error: unknown;
|
|
19
|
+
};
|
|
20
|
+
/** A section through the guard when its component offers the view call; any other component as an element, unguarded. */
|
|
21
|
+
export declare function renderGuarded(Component: ComponentType<any>, props: Record<string, unknown>, ctx: GuardContext): ReactNode;
|
|
22
|
+
/** An async section, awaited in place — no <Suspense>, so it renders where it always did. */
|
|
23
|
+
export declare function SectionGuard({ settled, ctx }: {
|
|
24
|
+
settled: Promise<Settled>;
|
|
25
|
+
ctx: GuardContext;
|
|
26
|
+
}): Promise<ReactNode>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { CrashPlaceholder } from "./section-crash.js";
|
|
3
|
+
/** The paired component's own view call, set by pair() and the custom section — only for a plain-function view. */
|
|
4
|
+
export const RENDER_VIEW = Symbol("smoodly.renderView");
|
|
5
|
+
/** A view the guard may call: a plain function — not a client reference or memo/forwardRef ($$typeof), not a class. */
|
|
6
|
+
export function isPlainView(View) {
|
|
7
|
+
if (typeof View !== "function")
|
|
8
|
+
return false;
|
|
9
|
+
const v = View;
|
|
10
|
+
return v.$$typeof === undefined && !v.prototype?.isReactComponent;
|
|
11
|
+
}
|
|
12
|
+
/** The placeholder shows in the canvas, and under `next dev` so a developer never misses their own crash. */
|
|
13
|
+
const placeholderShown = (editor) => editor || process.env.NODE_ENV === "development";
|
|
14
|
+
function failed(error, ctx) {
|
|
15
|
+
ctx.rethrow?.(error); // notFound(), redirects and bailouts are control flow, not crashes
|
|
16
|
+
console.error(`smoodly: section "${ctx.type}" (node ${ctx.id}) failed to render and is left out of the page.`, error);
|
|
17
|
+
return placeholderShown(ctx.editor) ? _jsx(CrashPlaceholder, { id: ctx.id, title: ctx.title, error: error, live: "section" }) : null;
|
|
18
|
+
}
|
|
19
|
+
const isThenable = (v) => typeof v === "object" && v !== null && typeof v.then === "function";
|
|
20
|
+
/** A section through the guard when its component offers the view call; any other component as an element, unguarded. */
|
|
21
|
+
export function renderGuarded(Component, props, ctx) {
|
|
22
|
+
const render = Component[RENDER_VIEW];
|
|
23
|
+
if (!render)
|
|
24
|
+
return _jsx(Component, { ...props });
|
|
25
|
+
try {
|
|
26
|
+
const out = render(props);
|
|
27
|
+
if (!isThenable(out))
|
|
28
|
+
return out;
|
|
29
|
+
// Settled up front, so a rejection is never unhandled while React gets to the guard.
|
|
30
|
+
const settled = Promise.resolve(out).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
31
|
+
return _jsx(SectionGuard, { settled: settled, ctx: ctx });
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return failed(error, ctx);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** An async section, awaited in place — no <Suspense>, so it renders where it always did. */
|
|
38
|
+
export async function SectionGuard({ settled, ctx }) {
|
|
39
|
+
const result = await settled;
|
|
40
|
+
return result.ok ? result.value : failed(result.error, ctx);
|
|
41
|
+
}
|
package/package.json
CHANGED