smoodly 0.0.26 → 0.0.28
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 +70 -6
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +5 -2
- package/dist/admin/editor/PageInspector.d.ts +4 -1
- package/dist/admin/editor/PageInspector.js +2 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +16 -8
- package/dist/admin/ops-impl.js +86 -7
- package/dist/admin/ops.d.ts +40 -1
- package/dist/admin/redirect-path.d.ts +6 -0
- package/dist/admin/redirect-path.js +33 -0
- package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
- package/dist/admin/shell/AddRedirectModal.js +29 -0
- package/dist/admin/shell/AdminApp.js +24 -10
- package/dist/admin/shell/EntryForm.js +14 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +6 -4
- package/dist/admin/shell/RedirectsPage.d.ts +11 -0
- package/dist/admin/shell/RedirectsPage.js +75 -0
- package/dist/admin/shell/SettingsView.d.ts +4 -2
- package/dist/admin/shell/SettingsView.js +18 -14
- package/dist/admin/shell/live-url.d.ts +10 -2
- package/dist/admin/shell/live-url.js +9 -4
- package/dist/admin/shell/members-list.js +1 -1
- package/dist/admin/shell/presence-realtime.d.ts +1 -1
- package/dist/admin/shell/presence-realtime.js +19 -9
- package/dist/admin/shell/presence.d.ts +18 -0
- package/dist/admin/shell/presence.js +13 -0
- package/dist/admin/shell/redirects-list.d.ts +12 -0
- package/dist/admin/shell/redirects-list.js +15 -0
- package/dist/admin/shell/settings.d.ts +12 -3
- package/dist/admin/shell/settings.js +13 -9
- package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +4 -3
- package/dist/admin/ui/confirm.d.ts +13 -2
- package/dist/admin/ui/confirm.js +9 -5
- package/dist/admin/ui/primitives.d.ts +4 -1
- package/dist/admin/ui/primitives.js +4 -2
- package/dist/admin/ui/theme.js +3 -0
- package/dist/entry-store.d.ts +18 -6
- package/dist/entry-store.js +13 -8
- package/dist/next/page-renderer.js +28 -9
- package/dist/path-index.d.ts +28 -4
- package/dist/path-index.js +37 -4
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +9 -0
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +4 -1
- package/dist/site.js +3 -1
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +37 -10
- package/dist/sql.js +24 -5
- package/dist/store.d.ts +30 -5
- package/dist/store.js +24 -8
- package/dist/supabase-entry-store.d.ts +7 -2
- package/dist/supabase-entry-store.js +12 -8
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +13 -2
- package/dist/supabase-store.js +15 -8
- package/migrations/20260926140000_smoodly_v3.sql +38 -0
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -477,6 +477,30 @@ 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
|
+
- The security pass over the database boundary (spec 2026-09-26 security
|
|
481
|
+
pass): the presence channel admits a signed-in member of the topic's
|
|
482
|
+
space (`smoodly_presence_member()`, the socket carries the session
|
|
483
|
+
token through the client's `accessToken` callback, re-sent on refresh);
|
|
484
|
+
every function pins `search_path`; the three mutation RPCs are revoked
|
|
485
|
+
from `anon`/`public`. Schema version 3. The threat table, one test per
|
|
486
|
+
cell: content, `refs`/`members`, `spaces`/`smoodly_meta` and storage
|
|
487
|
+
columns — `test/isolation.supabase.test.ts` ("space isolation", "read
|
|
488
|
+
keys", "cloud state rows") and `test/rls.supabase.test.ts`; presence
|
|
489
|
+
and RPC columns — `isolation.supabase.test.ts` ("signed-in users and
|
|
490
|
+
the public keys"); admin ops — `test/admin-auth.test.ts`,
|
|
491
|
+
`test/admin-auth.supabase.test.ts`, `test/admin-ops.test.ts` ("members
|
|
492
|
+
ops").
|
|
493
|
+
- Redirects (spec 2026-09-26 redirects): a `paths` row with `redirect`
|
|
494
|
+
set is an old address of its target (schema version 4). A published
|
|
495
|
+
rename or move keeps the old addresses as redirects unless the editor
|
|
496
|
+
unticks "Redirect the old address" on the live-URL confirm; the live
|
|
497
|
+
URL wins an address; nothing chains; `renderSmoodlyPath` answers a
|
|
498
|
+
redirect row with `permanentRedirect` through `href`. Settings ›
|
|
499
|
+
Redirects (every member) lists, adds by hand (validated old address,
|
|
500
|
+
target type-ahead) and removes. Tests: `test/path-index-contract.ts`
|
|
501
|
+
(the rule table, both adapters), the store contracts, `admin-ops`
|
|
502
|
+
("redirects on rename and move", "redirects ops"),
|
|
503
|
+
`next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
|
|
480
504
|
|
|
481
505
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
482
506
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
@@ -504,6 +528,50 @@ after both are on the registry. From the repo root:
|
|
|
504
528
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
505
529
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
506
530
|
|
|
531
|
+
## Follow-ups (logged 2026-09-26, redirects spec)
|
|
532
|
+
|
|
533
|
+
- Destinations outside Smoodly, extension and query-string addresses,
|
|
534
|
+
code-owned renames (a collection `path`, a fixed slug): the guide's
|
|
535
|
+
`next.config` recipe; a `redirects` table is OQ 34.
|
|
536
|
+
- The content API's `getPage("/old")` returns null for a redirect row
|
|
537
|
+
(spec §7); a `follow` option is a one-line addition if a site asks.
|
|
538
|
+
- A redirect row has no audit columns (who, when); add them with a
|
|
539
|
+
need. The Redirects page is not paged.
|
|
540
|
+
- The browser suite covers rename, the unticked box, no chains, the
|
|
541
|
+
Settings tab's list and the manual add/remove; a parent's MOVE
|
|
542
|
+
redirecting a child's URL is proven at the store contract and ops
|
|
543
|
+
level only (no tree drag in the suite yet).
|
|
544
|
+
- `remove` on the Supabase index deletes by target, one statement per
|
|
545
|
+
target (the RPC now spares redirect rows); fine at subtree sizes.
|
|
546
|
+
- Pressing Enter in the page Settings tab's slug input commits once,
|
|
547
|
+
then the confirm's autofocus blurs the input and `commit` runs again,
|
|
548
|
+
so a second identical question queues behind the first and its Cancel
|
|
549
|
+
restores the input from a stale row closure (pre-existing, seen on the
|
|
550
|
+
redirects walk). A re-entrancy guard on `commit` fixes it.
|
|
551
|
+
|
|
552
|
+
## Follow-ups (logged 2026-09-26, security pass)
|
|
553
|
+
|
|
554
|
+
- The bridge posts with `"*"` on both sides. Safe today: the admin
|
|
555
|
+
accepts a message only when `e.source` is its own iframe's window, and
|
|
556
|
+
the canvas acts on two kinds, refresh and scroll. The target-origin
|
|
557
|
+
pass belongs to the content-surface sweep (spec §Out of scope).
|
|
558
|
+
- The invite's `redirectTo` is a client-supplied string bounded by the
|
|
559
|
+
auth project's redirect allow-list; a same-origin check in the op is
|
|
560
|
+
one line when wanted.
|
|
561
|
+
- Every op call with a junk token costs one `auth.getUser` against the
|
|
562
|
+
auth project; rate limiting is the session-model spec's (OQ 20).
|
|
563
|
+
- The content-surface sweep: `f.link` values reach section components
|
|
564
|
+
unfiltered, uploads allow SVG and PDF on a public bucket (SVG served
|
|
565
|
+
with `?download=`), the custom CSS refusal list, richtext hrefs.
|
|
566
|
+
- A refused private-channel join surfaces as `CHANNEL_ERROR` (about 80
|
|
567
|
+
ms on the local stack); the suite asserts "not SUBSCRIBED".
|
|
568
|
+
- The isolation suite's `join` helper still uses a bare
|
|
569
|
+
`realtime.setAuth(key)`: its positive cases resolve on the first
|
|
570
|
+
SUBSCRIBED, before realtime-js re-asks the default `accessToken`
|
|
571
|
+
callback and the server closes the channel. Fine for proving the
|
|
572
|
+
policy; a client that must stay joined passes `accessToken` (what
|
|
573
|
+
`connectRealtime` does since 3ed8931).
|
|
574
|
+
|
|
507
575
|
## Follow-ups (logged 2026-09-26, concurrency spec)
|
|
508
576
|
|
|
509
577
|
- A hard lock with takeover, if a real team asks: presence has the facts.
|
|
@@ -522,10 +590,6 @@ after both are on the registry. From the repo root:
|
|
|
522
590
|
lock outlives a closed tab by that much.
|
|
523
591
|
- The live suites wipe `members` and the content tables; a pane walk after
|
|
524
592
|
them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
|
|
525
|
-
- `e2e/helpers.ts`'s `expectSaved` passes at once when the indicator still
|
|
526
|
-
reads "Saved" from before the edit (the debounce is 800 ms), so a step that
|
|
527
|
-
needs the save landed can race it; `concurrency.spec.ts` waits for the
|
|
528
|
-
save's server response instead. Fold that into the helper for every spec.
|
|
529
593
|
|
|
530
594
|
## Follow-ups (logged 2026-09-26, browser suite)
|
|
531
595
|
|
|
@@ -1844,8 +1908,8 @@ the toolbar instead of pushing the content.
|
|
|
1844
1908
|
- Once the policy-based published read exists, decide whether it becomes the canonical headless read path and the generated per-collection views turn optional (cloud has no views).
|
|
1845
1909
|
- Add an index on `pages(published_version_id)`: the `page_versions` read policy's `exists` probe runs on every anon/read-key read and has no supporting index.
|
|
1846
1910
|
- A malformed `space_id`/`key_id` claim raises `22P02` inside the policy: fails closed but surfaces as a 500. Only reachable from a validly-signed key, so it is a cloud key-minting guard, not a request-path one.
|
|
1847
|
-
- Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row).
|
|
1848
|
-
- `memberOf` and `SupabaseMemberStore` read `members` (and `spaces`) with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
|
|
1911
|
+
- **Verified and kept 2026-09-26 (security pass §3)** — Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row).
|
|
1912
|
+
- **Verified and kept 2026-09-26 (security pass §3)** — `memberOf` and `SupabaseMemberStore` read `members` (and `spaces`) with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
|
|
1849
1913
|
- The `sb_secret_` branch of `clientOptions` (secret key as the sole `apikey`, no bearer) was exercised live on 2026-09-05 against a hosted project: PostgREST reads with the service-role template and the GoTrue admin API (`listUsers`, `createUser`) both accept it; a password sign-in with the matching `sb_publishable_` key returns a session. Unit tests still cover the shape only; the local stack stays on legacy JWT keys.
|
|
1850
1914
|
- The migration is not re-appliable to a pre-rename (`project_id`) database: `create table if not exists` leaves it untouched and the `space_id` policies then fail. No installs predate the rename; fold into DESIGN.md OQ #2 (schema upgrades).
|
|
1851
1915
|
- `examples/site/smoodly/server.ts` should carry `import "server-only"` now that it also exports `publicAuth` — a client-side import would fail loudly at build instead of at runtime.
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -4,6 +4,7 @@ const GROUPS = {
|
|
|
4
4
|
globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
|
|
5
5
|
assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
|
|
6
6
|
members: ["list", "me", "invite", "setRole", "remove"],
|
|
7
|
+
redirects: ["list", "targets", "add", "remove"],
|
|
7
8
|
schema: ["status"],
|
|
8
9
|
preview: ["enable", "disable"],
|
|
9
10
|
};
|
|
@@ -83,6 +83,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
83
83
|
const [folder, setFolder] = useState(false);
|
|
84
84
|
const [reloadKey, setReloadKey] = useState(0);
|
|
85
85
|
const [paths, setPaths] = useState({});
|
|
86
|
+
const [redirects, setRedirects] = useState({});
|
|
86
87
|
const [urls, setUrls] = useState({});
|
|
87
88
|
// The locales the parent exists in — "Add locale" offers only these.
|
|
88
89
|
const [parentLocales, setParentLocales] = useState([]);
|
|
@@ -249,6 +250,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
249
250
|
}
|
|
250
251
|
setRecord(result.data.record);
|
|
251
252
|
setPaths(result.data.paths);
|
|
253
|
+
setRedirects(result.data.redirects);
|
|
252
254
|
setUrls(result.data.urls);
|
|
253
255
|
setParentLocales(result.data.parentLocales);
|
|
254
256
|
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
@@ -287,6 +289,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
287
289
|
return;
|
|
288
290
|
setRecord(result.data.record);
|
|
289
291
|
setPaths(result.data.paths);
|
|
292
|
+
setRedirects(result.data.redirects);
|
|
290
293
|
setUrls(result.data.urls);
|
|
291
294
|
setParentLocales(result.data.parentLocales);
|
|
292
295
|
}, [ops, id, locale]);
|
|
@@ -829,7 +832,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
829
832
|
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); } })),
|
|
830
833
|
},
|
|
831
834
|
{ label },
|
|
832
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), 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 }), 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 || geometry === "missing") && (_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 ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
835
|
+
] }) }), 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 }), 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 || geometry === "missing") && (_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 ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
833
836
|
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 && (() => {
|
|
834
837
|
const { session } = inline.richtext;
|
|
835
838
|
const item = globalItemOf(session.node);
|
|
@@ -845,7 +848,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
845
848
|
else
|
|
846
849
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
847
850
|
}, onEscape: inline.end }));
|
|
848
|
-
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [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") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _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], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
851
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [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") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
849
852
|
!isFixedNode(record, registry) &&
|
|
850
853
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
851
854
|
? () => { void doRemoveLocale(); }
|
|
@@ -3,7 +3,7 @@ import type { PageRecord } from "../../store.ts";
|
|
|
3
3
|
import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
4
4
|
export type PageTab = "SEO & Social" | "Settings";
|
|
5
5
|
export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
|
|
6
|
-
export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
|
|
6
|
+
export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }: {
|
|
7
7
|
record: PageRecord;
|
|
8
8
|
locale: string;
|
|
9
9
|
registry: AdminRegistry;
|
|
@@ -11,9 +11,12 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
|
|
|
11
11
|
/** The page's path in this locale (the `paths` index). */
|
|
12
12
|
path: string | undefined;
|
|
13
13
|
fixed: boolean;
|
|
14
|
+
/** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
|
|
15
|
+
redirects?: string[];
|
|
14
16
|
onRename(patch: {
|
|
15
17
|
title?: string;
|
|
16
18
|
slug?: string;
|
|
19
|
+
redirect?: boolean;
|
|
17
20
|
}): Promise<string | null>;
|
|
18
21
|
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
19
22
|
onRemoveLocale?: () => void;
|
|
@@ -13,12 +13,12 @@ import { SeoSettings } from "./SeoSettings.js";
|
|
|
13
13
|
export function pageTabsFor(template) {
|
|
14
14
|
return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
|
|
15
15
|
}
|
|
16
|
-
export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
|
|
16
|
+
export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }) {
|
|
17
17
|
const template = registry.pages.find((p) => p.name === record.template);
|
|
18
18
|
const tabs = pageTabsFor(template);
|
|
19
19
|
const [tab, setTab] = useState(tabs[0]);
|
|
20
20
|
useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
21
21
|
const active = tabs.includes(tab) ? tab : tabs[0];
|
|
22
22
|
const title = record.locales[locale]?.title ?? "";
|
|
23
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
23
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
24
24
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
|
|
3
|
+
export declare function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }: {
|
|
4
4
|
record: PageRecord;
|
|
5
5
|
locale: string;
|
|
6
6
|
registry: AdminRegistry;
|
|
@@ -8,8 +8,11 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
|
|
|
8
8
|
path: string | undefined;
|
|
9
9
|
fixed: boolean;
|
|
10
10
|
/** Resolves to an error message, or null when the rename landed. */
|
|
11
|
+
/** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
|
|
12
|
+
redirects?: string[];
|
|
11
13
|
onRename(patch: {
|
|
12
14
|
title?: string;
|
|
13
15
|
slug?: string;
|
|
16
|
+
redirect?: boolean;
|
|
14
17
|
}): Promise<string | null>;
|
|
15
18
|
}): import("react").JSX.Element | null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
|
|
4
4
|
// §6): the ACTIVE locale's title and slug, saved through pages.rename,
|
|
5
5
|
// and "Remove from this locale". The switcher in the canvas meta row
|
|
@@ -11,10 +11,13 @@ import { useEffect, 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
|
-
|
|
14
|
+
import { RouteLink } from "../shell/RouteLink.js";
|
|
15
|
+
import { useAdminHref } from "../shell/base.js";
|
|
16
|
+
export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
|
|
15
17
|
const row = record.locales[locale];
|
|
16
18
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
17
19
|
const [error, setError] = useState(null);
|
|
20
|
+
const adminHref = useAdminHref();
|
|
18
21
|
const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
19
22
|
// The record changes identity on every rename; re-seed the inputs from it.
|
|
20
23
|
useEffect(() => {
|
|
@@ -31,12 +34,17 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
31
34
|
setError(`A ${key} is required — nothing falls back to another locale.`);
|
|
32
35
|
return;
|
|
33
36
|
}
|
|
34
|
-
// A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
// A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
|
|
38
|
+
let redirect = true;
|
|
39
|
+
if (key === "slug") {
|
|
40
|
+
const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
|
|
41
|
+
if (!live.ok) {
|
|
42
|
+
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
redirect = live.redirect;
|
|
38
46
|
}
|
|
39
|
-
setError(await onRename({
|
|
47
|
+
setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
|
|
40
48
|
};
|
|
41
49
|
const onKey = (key) => (e) => {
|
|
42
50
|
if (e.key === "Enter") {
|
|
@@ -44,5 +52,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
44
52
|
void commit(key);
|
|
45
53
|
}
|
|
46
54
|
};
|
|
47
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
55
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), redirects && redirects.length > 0 && (_jsx(Field, { label: "Redirects from", asDiv: true, hint: _jsxs(_Fragment, { children: ["Old addresses that send visitors here. Manage them in ", _jsx(RouteLink, { href: adminHref("/settings/redirects"), children: "Settings \u203A Redirects" }), "."] }), children: _jsx("div", { className: "sm-redirects-from", children: redirects.map((p) => _jsx("code", { children: p }, p)) }) })), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
48
56
|
}
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -11,6 +11,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
|
11
11
|
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
12
12
|
import { withDefaults } from "../field-defaults.js";
|
|
13
13
|
import { elementSchemas } from "../richtext-walk.js";
|
|
14
|
+
import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
|
|
14
15
|
import { SCHEMA_VERSION } from "../schema-version.js";
|
|
15
16
|
import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
|
|
16
17
|
const ok = (data) => ({ ok: true, data });
|
|
@@ -65,6 +66,8 @@ const classify = (e) => {
|
|
|
65
66
|
return err("not_found", message);
|
|
66
67
|
if (message.includes("cannot take a slug"))
|
|
67
68
|
return err("validation", message);
|
|
69
|
+
if (message.includes("is a live address"))
|
|
70
|
+
return err("validation", message);
|
|
68
71
|
// Ahead of the generic "already exists" check below: the singleton
|
|
69
72
|
// refusal's message ("...already exists — it is a singleton.") contains
|
|
70
73
|
// "already exists" as a substring, so it must classify here first or it
|
|
@@ -208,6 +211,22 @@ export function createAdminOps(deps) {
|
|
|
208
211
|
const def = config.locales.default;
|
|
209
212
|
/** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
|
|
210
213
|
const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
|
|
214
|
+
/** "kind:id" → title in the locale (+ collection for an entry): every page with a template, every entry of a routed collection. */
|
|
215
|
+
const targetTitles = async (locale) => {
|
|
216
|
+
const out = new Map();
|
|
217
|
+
for (const p of await pages.listPages()) {
|
|
218
|
+
if (p.template === null)
|
|
219
|
+
continue;
|
|
220
|
+
out.set(`page:${p.id}`, { title: p.locales[locale]?.title ?? nameOf(p), collection: null });
|
|
221
|
+
}
|
|
222
|
+
for (const c of config.registry.collections ?? []) {
|
|
223
|
+
if (!c.path)
|
|
224
|
+
continue;
|
|
225
|
+
for (const e of await entries.list(c.name))
|
|
226
|
+
out.set(`entry:${e.id}`, { title: entryTitleIn(e, locale, c), collection: c.name });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
211
230
|
const assets = deps.assets;
|
|
212
231
|
const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
|
|
213
232
|
const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
|
|
@@ -292,12 +311,13 @@ export function createAdminOps(deps) {
|
|
|
292
311
|
const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
|
|
293
312
|
const paths = await pages.pathsOf(id);
|
|
294
313
|
const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
|
|
314
|
+
const redirects = await pages.redirectsOf(id);
|
|
295
315
|
// A locale can only be added where the parent already has it; a
|
|
296
316
|
// root has no parent, so every supported locale is open.
|
|
297
317
|
const parentLocales = record.parentId === null
|
|
298
318
|
? config.locales.supported
|
|
299
319
|
: Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
|
|
300
|
-
return ok({ record, locale, draft, paths, urls, parentLocales });
|
|
320
|
+
return ok({ record, locale, draft, paths, urls, redirects, parentLocales });
|
|
301
321
|
}),
|
|
302
322
|
create: (input) => guard(async () => {
|
|
303
323
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -494,7 +514,8 @@ export function createAdminOps(deps) {
|
|
|
494
514
|
return unsupported;
|
|
495
515
|
if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
|
|
496
516
|
return slugTaken();
|
|
497
|
-
const
|
|
517
|
+
const { redirect, ...rest } = patch;
|
|
518
|
+
const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
|
|
498
519
|
// Every descendant's path changed with this segment; the parent
|
|
499
520
|
// lists this node's title and path.
|
|
500
521
|
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
@@ -521,7 +542,8 @@ export function createAdminOps(deps) {
|
|
|
521
542
|
if (blocked)
|
|
522
543
|
return blocked;
|
|
523
544
|
}
|
|
524
|
-
const
|
|
545
|
+
const { redirect, ...dest } = to;
|
|
546
|
+
const moved = await pages.move(id, dest, { redirect: redirect ?? true });
|
|
525
547
|
expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
|
|
526
548
|
expireTree();
|
|
527
549
|
return ok(moved);
|
|
@@ -607,7 +629,8 @@ export function createAdminOps(deps) {
|
|
|
607
629
|
: Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
|
|
608
630
|
const paths = await entries.pathsOf(collection, id);
|
|
609
631
|
const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
|
|
610
|
-
|
|
632
|
+
const redirects = await entries.redirectsOf(collection, id);
|
|
633
|
+
return ok({ record, locale, fields, parentLocales, urls, redirects });
|
|
611
634
|
}),
|
|
612
635
|
create: (collection, input) => guard(async () => {
|
|
613
636
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -620,7 +643,7 @@ export function createAdminOps(deps) {
|
|
|
620
643
|
expireCollection(collection);
|
|
621
644
|
return ok(created);
|
|
622
645
|
}),
|
|
623
|
-
save: (collection, id, locale, fields, expected) => guard(async () => {
|
|
646
|
+
save: (collection, id, locale, fields, expected, options) => guard(async () => {
|
|
624
647
|
const unsupported = supportedLocale(locale);
|
|
625
648
|
if (unsupported)
|
|
626
649
|
return unsupported;
|
|
@@ -630,7 +653,7 @@ export function createAdminOps(deps) {
|
|
|
630
653
|
const descriptors = collectionFields(collection);
|
|
631
654
|
if (!descriptors)
|
|
632
655
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
633
|
-
const entry = await entries.update(collection, id, locale, fields, by, token);
|
|
656
|
+
const entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
|
|
634
657
|
await fanOut(id);
|
|
635
658
|
expireCollection(collection);
|
|
636
659
|
return ok(entry);
|
|
@@ -707,7 +730,8 @@ export function createAdminOps(deps) {
|
|
|
707
730
|
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
708
731
|
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
|
|
709
732
|
move: (collection, id, to) => guard(async () => {
|
|
710
|
-
const
|
|
733
|
+
const { redirect, ...dest } = to;
|
|
734
|
+
const moved = await entries.move(collection, id, dest, { redirect: redirect ?? true });
|
|
711
735
|
if (effects?.revalidate) {
|
|
712
736
|
for (const entryId of await entrySubtreeIds(collection, id))
|
|
713
737
|
effects.revalidate(entryTag(entryId));
|
|
@@ -973,6 +997,61 @@ export function createAdminOps(deps) {
|
|
|
973
997
|
return ok(null);
|
|
974
998
|
}),
|
|
975
999
|
},
|
|
1000
|
+
redirects: {
|
|
1001
|
+
list: (locale) => guard(async () => {
|
|
1002
|
+
const unsupported = supportedLocale(locale);
|
|
1003
|
+
if (unsupported)
|
|
1004
|
+
return unsupported;
|
|
1005
|
+
const rows = await pages.listRedirects(locale);
|
|
1006
|
+
const titles = await targetTitles(locale);
|
|
1007
|
+
const items = [];
|
|
1008
|
+
for (const r of rows) {
|
|
1009
|
+
const t = titles.get(`${r.kind}:${r.id}`);
|
|
1010
|
+
const currentPath = (await pages.targetPaths({ kind: r.kind, id: r.id }))[locale] ?? null;
|
|
1011
|
+
items.push({ locale, path: r.path, target: { kind: r.kind, id: r.id }, collection: t?.collection ?? null, title: t?.title ?? r.id, currentPath, url: currentPath ? href(currentPath, locale) : null });
|
|
1012
|
+
}
|
|
1013
|
+
return ok(items.sort((a, b) => a.path.localeCompare(b.path)));
|
|
1014
|
+
}),
|
|
1015
|
+
targets: (locale) => guard(async () => {
|
|
1016
|
+
const unsupported = supportedLocale(locale);
|
|
1017
|
+
if (unsupported)
|
|
1018
|
+
return unsupported;
|
|
1019
|
+
const titles = await targetTitles(locale);
|
|
1020
|
+
const out = [];
|
|
1021
|
+
for (const r of await pages.listLive(locale)) {
|
|
1022
|
+
const t = titles.get(`${r.kind}:${r.id}`);
|
|
1023
|
+
if (!t)
|
|
1024
|
+
continue; // a folder, or an entry outside a routed collection
|
|
1025
|
+
out.push({ target: { kind: r.kind, id: r.id }, collection: t.collection, title: t.title, path: r.path });
|
|
1026
|
+
}
|
|
1027
|
+
return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
|
|
1028
|
+
}),
|
|
1029
|
+
add: (locale, path, target) => guard(async () => {
|
|
1030
|
+
const unsupported = supportedLocale(locale);
|
|
1031
|
+
if (unsupported)
|
|
1032
|
+
return unsupported;
|
|
1033
|
+
const problem = redirectPathProblem(path, { adminPath: config.admin.path, multilingual: config.locales.supported.length > 1 });
|
|
1034
|
+
if (problem)
|
|
1035
|
+
return err("validation", REDIRECT_PATH_MESSAGE[problem]);
|
|
1036
|
+
const titles = await targetTitles(locale);
|
|
1037
|
+
if (!titles.has(`${target.kind}:${target.id}`))
|
|
1038
|
+
return err("not_found", `smoodly: no ${target.kind} "${target.id}" to redirect to.`);
|
|
1039
|
+
const holder = await pages.resolvePath(locale, path);
|
|
1040
|
+
if (holder && !holder.redirect) {
|
|
1041
|
+
const owner = titles.get(`${holder.kind}:${holder.id}`)?.title ?? holder.id;
|
|
1042
|
+
return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
|
|
1043
|
+
}
|
|
1044
|
+
await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
|
|
1045
|
+
return ok(null);
|
|
1046
|
+
}),
|
|
1047
|
+
remove: (locale, path) => guard(async () => {
|
|
1048
|
+
const unsupported = supportedLocale(locale);
|
|
1049
|
+
if (unsupported)
|
|
1050
|
+
return unsupported;
|
|
1051
|
+
await pages.removeRedirect(locale, path);
|
|
1052
|
+
return ok(null);
|
|
1053
|
+
}),
|
|
1054
|
+
},
|
|
976
1055
|
schema: {
|
|
977
1056
|
status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
|
|
978
1057
|
},
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts
|
|
|
7
7
|
import type { MemberRole } from "../permissions.ts";
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
9
|
import type { DraftToken } from "../draft-token.ts";
|
|
10
|
+
import type { PathTarget } from "../paths.ts";
|
|
10
11
|
export type OpError = {
|
|
11
12
|
ok: false;
|
|
12
13
|
code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
|
|
@@ -44,6 +45,8 @@ export type PageGetResult = {
|
|
|
44
45
|
paths: Record<string, string>;
|
|
45
46
|
/** locale → site URL, `href` applied server-side (the canvas and "View live" use these). */
|
|
46
47
|
urls: Record<string, string>;
|
|
48
|
+
/** locale → the page's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
|
|
49
|
+
redirects: Record<string, string[]>;
|
|
47
50
|
/** The locales the PARENT exists in — a locale can only be added here
|
|
48
51
|
* once the parent has it (DESIGN.md, Localization), so the editor
|
|
49
52
|
* disables the rest with a reason. Every supported locale for a root
|
|
@@ -65,6 +68,8 @@ export type EntryGetResult = {
|
|
|
65
68
|
fields: Record<string, unknown> | null;
|
|
66
69
|
/** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
|
|
67
70
|
urls: Record<string, string>;
|
|
71
|
+
/** locale → the entry's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
|
|
72
|
+
redirects: Record<string, string[]>;
|
|
68
73
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
69
74
|
parentLocales: string[];
|
|
70
75
|
};
|
|
@@ -109,6 +114,23 @@ export type SchemaStatus = {
|
|
|
109
114
|
database: number | null;
|
|
110
115
|
required: number;
|
|
111
116
|
};
|
|
117
|
+
/** A redirect row with its target joined (spec 2026-09-26 redirects §6): `currentPath`/`url` null when the target has no live path in the locale. */
|
|
118
|
+
export type RedirectItem = {
|
|
119
|
+
locale: string;
|
|
120
|
+
path: string;
|
|
121
|
+
target: PathTarget;
|
|
122
|
+
collection: string | null;
|
|
123
|
+
title: string;
|
|
124
|
+
currentPath: string | null;
|
|
125
|
+
url: string | null;
|
|
126
|
+
};
|
|
127
|
+
/** A page or entry live in the locale, for the Add dialog's type-ahead. */
|
|
128
|
+
export type RedirectTargetItem = {
|
|
129
|
+
target: PathTarget;
|
|
130
|
+
collection: string | null;
|
|
131
|
+
title: string;
|
|
132
|
+
path: string;
|
|
133
|
+
};
|
|
112
134
|
export type AdminOps = {
|
|
113
135
|
pages: {
|
|
114
136
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -127,14 +149,17 @@ export type AdminOps = {
|
|
|
127
149
|
/** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
|
|
128
150
|
discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
129
151
|
delete(id: string): Promise<OpResult<null>>;
|
|
152
|
+
/** `redirect` (default true) keeps a published row's old address as a redirect (spec 2026-09-26 redirects §1). */
|
|
130
153
|
rename(id: string, patch: {
|
|
131
154
|
locale: string;
|
|
132
155
|
slug?: string;
|
|
133
156
|
title?: string;
|
|
157
|
+
redirect?: boolean;
|
|
134
158
|
}): Promise<OpResult<PageRecord>>;
|
|
135
159
|
move(id: string, to: {
|
|
136
160
|
parentId: string | null;
|
|
137
161
|
index?: number;
|
|
162
|
+
redirect?: boolean;
|
|
138
163
|
}): Promise<OpResult<PageRecord>>;
|
|
139
164
|
/** "Add page here": a folder becomes a page. */
|
|
140
165
|
setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
|
|
@@ -159,7 +184,9 @@ export type AdminOps = {
|
|
|
159
184
|
get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
|
|
160
185
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
161
186
|
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
162
|
-
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null
|
|
187
|
+
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
|
|
188
|
+
redirect?: boolean;
|
|
189
|
+
}): Promise<OpResult<EntryRecord>>;
|
|
163
190
|
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
164
191
|
/** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
|
|
165
192
|
discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
@@ -167,6 +194,7 @@ export type AdminOps = {
|
|
|
167
194
|
move(collection: string, id: string, to: {
|
|
168
195
|
parentId: string | null;
|
|
169
196
|
index?: number;
|
|
197
|
+
redirect?: boolean;
|
|
170
198
|
}): Promise<OpResult<EntryRecord>>;
|
|
171
199
|
/** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
|
|
172
200
|
addLocale(collection: string, id: string, locale: string, options: {
|
|
@@ -225,6 +253,17 @@ export type AdminOps = {
|
|
|
225
253
|
/** Admins only, self-host only; the same two guards. The Auth account stays. */
|
|
226
254
|
remove(userId: string): Promise<OpResult<null>>;
|
|
227
255
|
};
|
|
256
|
+
/** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
|
|
257
|
+
redirects: {
|
|
258
|
+
/** The locale's redirect rows with their targets' titles and current URLs, sorted by old address. */
|
|
259
|
+
list(locale: string): Promise<OpResult<RedirectItem[]>>;
|
|
260
|
+
/** Every page with a template and every routed entry live in the locale, for the Add dialog's type-ahead. */
|
|
261
|
+
targets(locale: string): Promise<OpResult<RedirectTargetItem[]>>;
|
|
262
|
+
/** Validates the old address (redirect-path.ts), refuses a live address naming its owner, `not_found` for an unknown target. */
|
|
263
|
+
add(locale: string, path: string, target: PathTarget): Promise<OpResult<null>>;
|
|
264
|
+
/** Deletes a redirect row; a live row is never touched. */
|
|
265
|
+
remove(locale: string, path: string): Promise<OpResult<null>>;
|
|
266
|
+
};
|
|
228
267
|
schema: {
|
|
229
268
|
/** Every member. The shell calls it once on mount and blocks when behind. */
|
|
230
269
|
status(): Promise<OpResult<SchemaStatus>>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type RedirectPathProblem = "empty" | "leading-slash" | "root" | "trailing-slash" | "double-slash" | "whitespace" | "query-or-hash" | "admin" | "extension";
|
|
2
|
+
export declare const REDIRECT_PATH_MESSAGE: Record<RedirectPathProblem, string>;
|
|
3
|
+
export declare function redirectPathProblem(path: string, opts: {
|
|
4
|
+
adminPath: string;
|
|
5
|
+
multilingual: boolean;
|
|
6
|
+
}): RedirectPathProblem | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const REDIRECT_PATH_MESSAGE = {
|
|
2
|
+
empty: "Enter the old address.",
|
|
3
|
+
"leading-slash": "Start with a slash: /old-address.",
|
|
4
|
+
root: "The site root cannot redirect.",
|
|
5
|
+
"trailing-slash": "Leave out the trailing slash.",
|
|
6
|
+
"double-slash": "An address cannot have an empty part.",
|
|
7
|
+
whitespace: "An address cannot contain spaces.",
|
|
8
|
+
"query-or-hash": "Leave out the query string and the hash: they are not part of the address.",
|
|
9
|
+
admin: "That is the admin's address.",
|
|
10
|
+
extension: "An address with a file extension never reaches Smoodly on a multilingual site — redirect it in next.config instead.",
|
|
11
|
+
};
|
|
12
|
+
export function redirectPathProblem(path, opts) {
|
|
13
|
+
if (path === "")
|
|
14
|
+
return "empty";
|
|
15
|
+
if (/\s/.test(path))
|
|
16
|
+
return "whitespace";
|
|
17
|
+
if (/[?#]/.test(path))
|
|
18
|
+
return "query-or-hash";
|
|
19
|
+
if (!path.startsWith("/"))
|
|
20
|
+
return "leading-slash";
|
|
21
|
+
if (path === "/")
|
|
22
|
+
return "root";
|
|
23
|
+
if (path.endsWith("/"))
|
|
24
|
+
return "trailing-slash";
|
|
25
|
+
if (path.includes("//"))
|
|
26
|
+
return "double-slash";
|
|
27
|
+
if (path === opts.adminPath || path.startsWith(`${opts.adminPath}/`))
|
|
28
|
+
return "admin";
|
|
29
|
+
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
30
|
+
if (opts.multilingual && /\.[A-Za-z0-9]+$/.test(last))
|
|
31
|
+
return "extension";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PathTarget } from "../../paths.ts";
|
|
2
|
+
import type { RedirectTargetItem } from "../ops.ts";
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export declare function AddRedirectModal({ locale, registry, targets, onClose, onAdd }: {
|
|
5
|
+
locale: string;
|
|
6
|
+
registry: AdminRegistry;
|
|
7
|
+
targets: RedirectTargetItem[];
|
|
8
|
+
onClose(): void;
|
|
9
|
+
/** Resolves to an error message, or null when the redirect landed (the caller closes). */
|
|
10
|
+
onAdd(path: string, target: PathTarget): Promise<string | null>;
|
|
11
|
+
}): import("react").JSX.Element;
|