smoodly 0.0.27 → 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.
Files changed (56) hide show
  1. package/README.md +32 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +4 -1
  4. package/dist/admin/editor/PageInspector.d.ts +4 -1
  5. package/dist/admin/editor/PageInspector.js +2 -2
  6. package/dist/admin/editor/PageSettings.d.ts +4 -1
  7. package/dist/admin/editor/PageSettings.js +16 -8
  8. package/dist/admin/ops-impl.js +86 -7
  9. package/dist/admin/ops.d.ts +40 -1
  10. package/dist/admin/redirect-path.d.ts +6 -0
  11. package/dist/admin/redirect-path.js +33 -0
  12. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  13. package/dist/admin/shell/AddRedirectModal.js +29 -0
  14. package/dist/admin/shell/AdminApp.js +2 -3
  15. package/dist/admin/shell/EntryForm.js +13 -5
  16. package/dist/admin/shell/IconRail.d.ts +1 -1
  17. package/dist/admin/shell/IconRail.js +3 -2
  18. package/dist/admin/shell/PagesList.js +6 -4
  19. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  20. package/dist/admin/shell/RedirectsPage.js +75 -0
  21. package/dist/admin/shell/SettingsView.d.ts +4 -2
  22. package/dist/admin/shell/SettingsView.js +18 -14
  23. package/dist/admin/shell/live-url.d.ts +10 -2
  24. package/dist/admin/shell/live-url.js +9 -4
  25. package/dist/admin/shell/members-list.js +1 -1
  26. package/dist/admin/shell/redirects-list.d.ts +12 -0
  27. package/dist/admin/shell/redirects-list.js +15 -0
  28. package/dist/admin/shell/settings.d.ts +12 -3
  29. package/dist/admin/shell/settings.js +13 -9
  30. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  31. package/dist/admin/ui/ConfirmDialog.js +4 -3
  32. package/dist/admin/ui/confirm.d.ts +13 -2
  33. package/dist/admin/ui/confirm.js +9 -5
  34. package/dist/admin/ui/theme.js +3 -0
  35. package/dist/entry-store.d.ts +18 -6
  36. package/dist/entry-store.js +13 -8
  37. package/dist/next/page-renderer.js +28 -9
  38. package/dist/path-index.d.ts +28 -4
  39. package/dist/path-index.js +37 -4
  40. package/dist/paths.d.ts +16 -0
  41. package/dist/paths.js +9 -0
  42. package/dist/schema-version.d.ts +1 -1
  43. package/dist/schema-version.js +1 -1
  44. package/dist/site.d.ts +4 -1
  45. package/dist/site.js +3 -1
  46. package/dist/sql.js +19 -1
  47. package/dist/store.d.ts +30 -5
  48. package/dist/store.js +24 -8
  49. package/dist/supabase-entry-store.d.ts +7 -2
  50. package/dist/supabase-entry-store.js +12 -8
  51. package/dist/supabase-path-index.d.ts +10 -3
  52. package/dist/supabase-path-index.js +50 -17
  53. package/dist/supabase-store.d.ts +13 -2
  54. package/dist/supabase-store.js +15 -8
  55. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  56. package/package.json +1 -1
package/README.md CHANGED
@@ -490,6 +490,17 @@ Implemented:
490
490
  the public keys"); admin ops — `test/admin-auth.test.ts`,
491
491
  `test/admin-auth.supabase.test.ts`, `test/admin-ops.test.ts` ("members
492
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`.
493
504
 
494
505
  Not yet: canvas drag-and-drop (list fields, the outline, manual
495
506
  collections and the pages tree drag since 2026-09-09 — spec
@@ -517,6 +528,27 @@ after both are on the registry. From the repo root:
517
528
  `prepublishOnly` rebuilds each package. The Claude Code skill
518
529
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
519
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
+
520
552
  ## Follow-ups (logged 2026-09-26, security pass)
521
553
 
522
554
  - The bridge posts with `"*"` on both sides. Safe today: the admin
@@ -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]);
@@ -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, 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], 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
- export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
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
- if (key === "slug" && !(await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }))) {
36
- setDraft((d) => ({ ...d, slug: row.slug }));
37
- return;
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({ [key]: next }));
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
  }
@@ -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 renamed = await pages.rename(id, patch, by);
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 moved = await pages.move(id, to);
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
- return ok({ record, locale, fields, parentLocales, urls });
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 moved = await entries.move(collection, id, to);
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
  },
@@ -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): Promise<OpResult<EntryRecord>>;
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;
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Add a redirect by hand (spec 2026-09-26 redirects §5–§6): the old
4
+ // address, checked as typed with redirect-path.ts so the message shows
5
+ // before the op repeats the check, and the target — one type-ahead over
6
+ // every page and routed entry live in the locale, "Title · /path".
7
+ import { useState } from "react";
8
+ import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "../redirect-path.js";
9
+ import { Combobox } from "../ui/Combobox.js";
10
+ import { Modal } from "../ui/Modal.js";
11
+ import { Banner, Button, Field } from "../ui/primitives.js";
12
+ import { parseTargetValue, targetOptions } from "./redirects-list.js";
13
+ export function AddRedirectModal({ locale, registry, targets, onClose, onAdd }) {
14
+ const [path, setPath] = useState("");
15
+ const [target, setTarget] = useState(null);
16
+ const [error, setError] = useState(null);
17
+ const [busy, setBusy] = useState(false);
18
+ const problem = redirectPathProblem(path, { adminPath: registry.adminPath, multilingual: registry.locales.supported.length > 1 });
19
+ const picked = target ? targets.find((t) => t.target.kind === target.kind && t.target.id === target.id) : undefined;
20
+ const submit = async (e) => {
21
+ e.preventDefault();
22
+ if (!target || problem)
23
+ return;
24
+ setBusy(true);
25
+ setError(await onAdd(path, target));
26
+ setBusy(false);
27
+ };
28
+ return (_jsx(Modal, { title: "Add redirect", width: 480, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Old address", error: path !== "" && problem ? REDIRECT_PATH_MESSAGE[problem] : undefined, children: _jsx("input", { className: "sm-input sm-input--mono", autoFocus: true, placeholder: "/old-address", value: path, onChange: (e) => setPath(e.target.value) }) }), _jsx(Field, { label: "Redirects to", asDiv: true, hint: picked ? `${picked.title} · ${picked.path}` : `A page or entry that is live in ${locale}.`, children: _jsx(Combobox, { label: "Redirects to", options: targetOptions(targets), placeholder: "Type a title or an address", emptyText: `Nothing is live in ${locale} yet.`, onPick: (value) => setTarget(parseTargetValue(value)) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy || !target || path === "" || problem !== null, children: busy ? "Saving…" : "Save redirect" })] })] }) }));
29
+ }
@@ -28,7 +28,6 @@ import { ConfirmHost } from "../ui/ConfirmDialog.js";
28
28
  import { useRoute } from "./router.js";
29
29
  import { AdminPathProvider } from "./base.js";
30
30
  import { mountMismatch } from "../../admin-path.js";
31
- import { can } from "../../permissions.js";
32
31
  import { ErrorPane } from "./ErrorPane.js";
33
32
  import { MediaScreen } from "./MediaScreen.js";
34
33
  import { SettingsView } from "./SettingsView.js";
@@ -139,10 +138,10 @@ export function AdminApp({ registry, op, auth, content }) {
139
138
  else if (head === "globals" && tail)
140
139
  main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
141
140
  else if (head === "settings")
142
- main = _jsx(SettingsView, { ops: ops, tail: tail, role: role, userId: userId });
141
+ main = _jsx(SettingsView, { ops: ops, registry: registry, tail: tail, role: role, userId: userId });
143
142
  else if (head === "media")
144
143
  main = _jsx(MediaScreen, {});
145
144
  else
146
145
  main = _jsx(ErrorPane, { message: "Not found." });
147
- return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
146
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
148
147
  }