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.
Files changed (66) hide show
  1. package/README.md +70 -6
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +5 -2
  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 +24 -10
  15. package/dist/admin/shell/EntryForm.js +14 -6
  16. package/dist/admin/shell/GlobalForm.js +1 -1
  17. package/dist/admin/shell/IconRail.d.ts +1 -1
  18. package/dist/admin/shell/IconRail.js +3 -2
  19. package/dist/admin/shell/PagesList.js +6 -4
  20. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  21. package/dist/admin/shell/RedirectsPage.js +75 -0
  22. package/dist/admin/shell/SettingsView.d.ts +4 -2
  23. package/dist/admin/shell/SettingsView.js +18 -14
  24. package/dist/admin/shell/live-url.d.ts +10 -2
  25. package/dist/admin/shell/live-url.js +9 -4
  26. package/dist/admin/shell/members-list.js +1 -1
  27. package/dist/admin/shell/presence-realtime.d.ts +1 -1
  28. package/dist/admin/shell/presence-realtime.js +19 -9
  29. package/dist/admin/shell/presence.d.ts +18 -0
  30. package/dist/admin/shell/presence.js +13 -0
  31. package/dist/admin/shell/redirects-list.d.ts +12 -0
  32. package/dist/admin/shell/redirects-list.js +15 -0
  33. package/dist/admin/shell/settings.d.ts +12 -3
  34. package/dist/admin/shell/settings.js +13 -9
  35. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  36. package/dist/admin/ui/ConfirmDialog.js +4 -3
  37. package/dist/admin/ui/confirm.d.ts +13 -2
  38. package/dist/admin/ui/confirm.js +9 -5
  39. package/dist/admin/ui/primitives.d.ts +4 -1
  40. package/dist/admin/ui/primitives.js +4 -2
  41. package/dist/admin/ui/theme.js +3 -0
  42. package/dist/entry-store.d.ts +18 -6
  43. package/dist/entry-store.js +13 -8
  44. package/dist/next/page-renderer.js +28 -9
  45. package/dist/path-index.d.ts +28 -4
  46. package/dist/path-index.js +37 -4
  47. package/dist/paths.d.ts +16 -0
  48. package/dist/paths.js +9 -0
  49. package/dist/schema-version.d.ts +1 -1
  50. package/dist/schema-version.js +1 -1
  51. package/dist/site.d.ts +4 -1
  52. package/dist/site.js +3 -1
  53. package/dist/sql-space.d.ts +6 -0
  54. package/dist/sql-space.js +37 -10
  55. package/dist/sql.js +24 -5
  56. package/dist/store.d.ts +30 -5
  57. package/dist/store.js +24 -8
  58. package/dist/supabase-entry-store.d.ts +7 -2
  59. package/dist/supabase-entry-store.js +12 -8
  60. package/dist/supabase-path-index.d.ts +10 -3
  61. package/dist/supabase-path-index.js +50 -17
  62. package/dist/supabase-store.d.ts +13 -2
  63. package/dist/supabase-store.js +15 -8
  64. package/migrations/20260926140000_smoodly_v3.sql +38 -0
  65. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  66. package/package.json +1 -1
@@ -0,0 +1,15 @@
1
+ export const targetOptions = (targets) => targets.map((t) => ({ value: `${t.target.kind}:${t.target.id}`, label: `${t.title} · ${t.path}` }));
2
+ export function parseTargetValue(value) {
3
+ const m = /^(page|entry):(.+)$/.exec(value);
4
+ return m ? { kind: m[1], id: m[2] } : null;
5
+ }
6
+ /** Admin-relative (the caller applies adminHref). */
7
+ export const editorHref = (item, locale) => item.target.kind === "page"
8
+ ? `/pages/${encodeURIComponent(item.target.id)}?locale=${encodeURIComponent(locale)}`
9
+ : `/${item.collection}/${encodeURIComponent(item.target.id)}?locale=${encodeURIComponent(locale)}`;
10
+ export const REMOVE_REDIRECT_CONFIRM = {
11
+ title: "Remove this redirect?",
12
+ body: "Visitors who open the old address get a Not found page. Browsers that already followed the redirect may keep going to the new address for a while.",
13
+ confirmLabel: "Remove redirect",
14
+ destructive: true,
15
+ };
@@ -1,9 +1,18 @@
1
+ import { type MemberRole } from "../../permissions.ts";
1
2
  export declare const SETTINGS_PAGES: readonly [{
2
3
  readonly id: "members";
3
4
  readonly title: "Members";
5
+ readonly capability: "settings";
6
+ }, {
7
+ readonly id: "redirects";
8
+ readonly title: "Redirects";
9
+ readonly capability: "content.write";
4
10
  }];
5
- export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
6
- export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
7
- /** The page for the segment after /settings: the default for none, null for an unknown one. */
11
+ export type SettingsPage = (typeof SETTINGS_PAGES)[number];
12
+ export type SettingsPageId = SettingsPage["id"];
13
+ /** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
14
+ export declare const visibleSettingsPages: (role: MemberRole) => readonly SettingsPage[];
15
+ export declare const defaultSettingsPage: (role: MemberRole) => SettingsPageId;
16
+ /** The page for the segment after /settings; null for none or an unknown one. */
8
17
  export declare function settingsPage(tail: string | undefined): SettingsPageId | null;
9
18
  export declare function settingsTitle(page: SettingsPageId): string;
@@ -1,15 +1,19 @@
1
- // The Settings area's pages (spec 2026-09-25 §5; narrowed 2026-09-25 to
2
- // Members alone — Roles and API Keys dropped, see DESIGN.md §2): ids are
3
- // URL segments under /settings, titles are what the secondary nav and
4
- // each top bar show. Pure, so routing is testable without React.
1
+ // The Settings area's pages (spec 2026-09-25 §5; Roles and API Keys
2
+ // dropped 2026-09-25, Redirects added 2026-09-26 §6): ids are URL
3
+ // segments under /settings, titles are what the secondary nav and each
4
+ // top bar show, and each page names the capability that opens it —
5
+ // Members for admins, Redirects for every member, as renaming is. Pure,
6
+ // so routing is testable without React.
7
+ import { can } from "../../permissions.js";
5
8
  export const SETTINGS_PAGES = [
6
- { id: "members", title: "Members" },
9
+ { id: "members", title: "Members", capability: "settings" },
10
+ { id: "redirects", title: "Redirects", capability: "content.write" },
7
11
  ];
8
- export const DEFAULT_SETTINGS_PAGE = "members";
9
- /** The page for the segment after /settings: the default for none, null for an unknown one. */
12
+ /** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
13
+ export const visibleSettingsPages = (role) => SETTINGS_PAGES.filter((p) => can(role, p.capability));
14
+ export const defaultSettingsPage = (role) => visibleSettingsPages(role)[0].id;
15
+ /** The page for the segment after /settings; null for none or an unknown one. */
10
16
  export function settingsPage(tail) {
11
- if (tail === undefined)
12
- return DEFAULT_SETTINGS_PAGE;
13
17
  return SETTINGS_PAGES.some((p) => p.id === tail) ? tail : null;
14
18
  }
15
19
  export function settingsTitle(page) {
@@ -2,7 +2,7 @@ import { type ReactElement } from "react";
2
2
  import { type ConfirmOptions } from "./confirm.ts";
3
3
  export declare function ConfirmDialog({ options, onAnswer }: {
4
4
  options: ConfirmOptions;
5
- onAnswer(yes: boolean): void;
5
+ onAnswer(yes: boolean, checked?: boolean): void;
6
6
  }): ReactElement;
7
7
  /** Renders the pending question, once, from the shell. */
8
8
  export declare function ConfirmHost(): ReactElement | null;
@@ -10,16 +10,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  // <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
11
11
  // from the shell; the dialog itself is a plain component so a test can
12
12
  // render it statically.
13
- import { useId, useSyncExternalStore } from "react";
13
+ import { useId, useState, useSyncExternalStore } from "react";
14
14
  import { DialogCard } from "./Dialog.js";
15
15
  import { Button } from "./primitives.js";
16
16
  import { pendingConfirm, subscribeConfirm } from "./confirm.js";
17
17
  export function ConfirmDialog({ options, onAnswer }) {
18
- const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject } = options;
18
+ const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject, option } = options;
19
+ const [checked, setChecked] = useState(option?.checked ?? false);
19
20
  const uid = useId();
20
21
  const bodyId = `sm-confirm-body${uid}`;
21
22
  const cancel = () => onAnswer(false);
22
- return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
+ return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true, checked), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), option && (_jsxs("label", { className: "sm-choice sm-confirm__option", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => setChecked(e.target.checked) }), _jsx("span", { children: option.label })] })), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
23
24
  }
24
25
  /** Renders the pending question, once, from the shell. */
25
26
  export function ConfirmHost() {
@@ -17,16 +17,27 @@ export type ConfirmOptions = {
17
17
  destructive?: boolean;
18
18
  /** The context chip repeating the subject; omit when there is no single subject. */
19
19
  subject?: ConfirmSubject;
20
+ /** A yes/no rider under the body — "Redirect the old address" (spec 2026-09-26 redirects §6); `checked` is its initial state. */
21
+ option?: {
22
+ label: string;
23
+ checked: boolean;
24
+ };
25
+ };
26
+ export type ConfirmAnswer = {
27
+ yes: boolean;
28
+ checked: boolean;
20
29
  };
21
30
  export type ConfirmRequest = {
22
31
  id: number;
23
32
  options: ConfirmOptions;
24
- answer(yes: boolean): void;
33
+ answer(yes: boolean, checked?: boolean): void;
25
34
  };
26
35
  /** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
27
36
  export declare const DISCARD_CONFIRM: Omit<ConfirmOptions, "subject">;
37
+ /** Ask; resolves with the answer and the option's final state once the dialog closes. */
38
+ export declare function confirmWithOption(options: ConfirmOptions): Promise<ConfirmAnswer>;
28
39
  /** Ask; resolves with the answer once the dialog closes. */
29
- export declare function confirm(options: ConfirmOptions): Promise<boolean>;
40
+ export declare const confirm: (options: ConfirmOptions) => Promise<boolean>;
30
41
  /** The question being asked, if any — the head of the queue. */
31
42
  export declare const pendingConfirm: () => ConfirmRequest | null;
32
43
  /** For useSyncExternalStore: fires on every queue change. */
@@ -20,29 +20,33 @@ let queue = [];
20
20
  let seq = 0;
21
21
  const notify = () => { for (const fn of listeners)
22
22
  fn(); };
23
- /** Ask; resolves with the answer once the dialog closes. */
24
- export function confirm(options) {
23
+ /** Ask; resolves with the answer and the option's final state once the dialog closes. */
24
+ export function confirmWithOption(options) {
25
+ const initial = options.option?.checked ?? false;
25
26
  if (listeners.size === 0) {
26
27
  const text = [options.title, options.body].filter(Boolean).join("\n\n");
27
- return Promise.resolve(typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false);
28
+ const yes = typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false;
29
+ return Promise.resolve({ yes, checked: initial });
28
30
  }
29
31
  return new Promise((resolve) => {
30
32
  const id = ++seq;
31
33
  const request = {
32
34
  id,
33
35
  options,
34
- answer(yes) {
36
+ answer(yes, checked = initial) {
35
37
  if (!queue.some((r) => r.id === id))
36
38
  return; // answered already
37
39
  queue = queue.filter((r) => r.id !== id);
38
40
  notify();
39
- resolve(yes);
41
+ resolve({ yes, checked });
40
42
  },
41
43
  };
42
44
  queue = [...queue, request];
43
45
  notify();
44
46
  });
45
47
  }
48
+ /** Ask; resolves with the answer once the dialog closes. */
49
+ export const confirm = (options) => confirmWithOption(options).then((a) => a.yes);
46
50
  /** The question being asked, if any — the head of the queue. */
47
51
  export const pendingConfirm = () => queue[0] ?? null;
48
52
  /** For useSyncExternalStore: fires on every queue change. */
@@ -120,8 +120,11 @@ export declare function StatusBadge({ status }: {
120
120
  status: string;
121
121
  }): import("react").JSX.Element;
122
122
  /** The autosave indicator: a dot and one uppercase word. */
123
- export declare function SaveIndicator({ state }: {
123
+ /** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
124
+ * store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
125
+ export declare function SaveIndicator({ state, pending }: {
124
126
  state: SaveState;
127
+ pending?: boolean;
125
128
  }): import("react").JSX.Element;
126
129
  /** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
127
130
  export declare function SectionLabel({ children, action, caps, style }: {
@@ -124,14 +124,16 @@ export function StatusBadge({ status }) {
124
124
  }, children: status }));
125
125
  }
126
126
  /** The autosave indicator: a dot and one uppercase word. */
127
- export function SaveIndicator({ state }) {
127
+ /** `pending`: an edit is waiting on the autosave debounce — shown as Saving, so "Saved" always means the
128
+ * store holds what is on screen (the browser suite's `expectSaved` relies on exactly that). */
129
+ export function SaveIndicator({ state, pending = false }) {
128
130
  const map = {
129
131
  saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
130
132
  saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
131
133
  error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
132
134
  stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
133
135
  };
134
- const { label, dot, color } = map[state];
136
+ const { label, dot, color } = map[pending && state === "saved" ? "saving" : state];
135
137
  return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
136
138
  }
137
139
  /** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
@@ -1152,6 +1152,9 @@ body:has(.sm-admin){margin:0}
1152
1152
  }
1153
1153
  .sm-dialog__context strong{font-weight:500;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:var(--text-md)}
1154
1154
  .sm-dialog__context code{font-family:var(--font-mono);flex:none}
1155
+ .sm-confirm__option{margin-top:4px;font-size:var(--text-md);color:var(--text)}
1156
+ .sm-redirects-from{display:flex;flex-direction:column;gap:4px}
1157
+ .sm-redirects-from code{font-size:var(--text-sm)}
1155
1158
  /* A form dialog's fields: white fields on the white card get the tile tone instead. */
1156
1159
  .sm-dialog__form{display:flex;flex-direction:column;gap:14px}
1157
1160
  .sm-dialog__form .sm-input,.sm-dialog__form .sm-select,.sm-dialog__form .sm-inputwrap{background:var(--surface)}
@@ -2,9 +2,9 @@ import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
2
  import type { GlobalSchema } from "./global.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import { MemoryRefIndex } from "./ref-index.ts";
5
- import { type PathIndex } from "./path-index.ts";
5
+ import { type PathHit, type PathIndex } from "./path-index.ts";
6
6
  import { type DraftToken } from "./draft-token.ts";
7
- import { type LocaleSet, type PathTarget } from "./paths.ts";
7
+ import { type LocaleSet } from "./paths.ts";
8
8
  export type EntryLocale = {
9
9
  /** The entry's segment in this locale; null claims no URL. */
10
10
  slug: string | null;
@@ -107,7 +107,10 @@ export interface EntryStore {
107
107
  }): Promise<Record<string, Record<string, unknown>>>;
108
108
  /** Replaces the merged fields for `locale`: shared keys on the node
109
109
  * (every locale sees them), localized keys and the slug on the row. */
110
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
110
+ /** `redirect` (default true) keeps a published row's old address as a redirect when the slug changes (spec 2026-09-26 redirects §1). */
111
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
112
+ redirect?: boolean;
113
+ }): Promise<EntryRecord>;
111
114
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
112
115
  /** The live row goes back to what is published: the published snapshot's fields
113
116
  * (shared and localized, split as on save — the slug is never versioned) written over
@@ -143,6 +146,8 @@ export interface EntryStore {
143
146
  move(collection: string, id: string, to: {
144
147
  parentId: string | null;
145
148
  index?: number;
149
+ }, options?: {
150
+ redirect?: boolean;
146
151
  }): Promise<EntryRecord>;
147
152
  /** Rewrite manual order to 1..N for the given ids, one batched pass. */
148
153
  reorder(collection: string, orderedIds: string[]): Promise<void>;
@@ -153,8 +158,10 @@ export interface EntryStore {
153
158
  sourceKind: string;
154
159
  sourceId: string;
155
160
  }[]>;
156
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
161
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
157
162
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
163
+ /** locale → this entry's old addresses. */
164
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
158
165
  }
159
166
  export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
160
167
  /** A global item is a collection with exactly one entry (spec 2026-09-07). */
@@ -271,7 +278,9 @@ export declare class MemoryEntryStore implements EntryStore {
271
278
  getMany(collection: string, ids: string[], locale: string, options?: {
272
279
  status?: "published";
273
280
  }): Promise<Record<string, Record<string, unknown>>>;
274
- update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
281
+ update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null, options?: {
282
+ redirect?: boolean;
283
+ }): Promise<EntryRecord>;
275
284
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
276
285
  discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
277
286
  addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
@@ -282,6 +291,8 @@ export declare class MemoryEntryStore implements EntryStore {
282
291
  move(collection: string, id: string, to: {
283
292
  parentId: string | null;
284
293
  index?: number;
294
+ }, options?: {
295
+ redirect?: boolean;
285
296
  }): Promise<EntryRecord>;
286
297
  delete(collection: string, id: string): Promise<void>;
287
298
  reorder(collection: string, orderedIds: string[]): Promise<void>;
@@ -290,6 +301,7 @@ export declare class MemoryEntryStore implements EntryStore {
290
301
  sourceKind: string;
291
302
  sourceId: string;
292
303
  }[]>;
293
- resolvePath(locale: string, path: string): Promise<PathTarget | null>;
304
+ resolvePath(locale: string, path: string): Promise<PathHit | null>;
294
305
  pathsOf(collection: string, id: string): Promise<{}>;
306
+ redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
295
307
  }
@@ -13,10 +13,11 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { groupRedirects } from "./store.js";
16
17
  import { assertFresh } from "./draft-token.js";
17
18
  import { globalEntryId } from "./global-id.js";
18
19
  import { DEFAULT_SPACE_ID } from "./sql-space.js";
19
- import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
20
+ import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, rewriteTargets, } from "./paths.js";
20
21
  /** The field types whose value is an array: a scalar `where` on one is
21
22
  * containment, and any-of on one is refused by the site layer (both
22
23
  * adapters read the text form of the whole array, never its elements). */
@@ -270,8 +271,8 @@ export class MemoryEntryStore {
270
271
  }
271
272
  return sets;
272
273
  }
273
- async writePaths(collection, nodes) {
274
- await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
274
+ async writePaths(collection, nodes, redirect = true) {
275
+ await this.paths.rewrite(rewriteTargets("entry", nodes, redirect), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
275
276
  }
276
277
  writeRefs(entry) {
277
278
  this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection), this.elements));
@@ -396,7 +397,7 @@ export class MemoryEntryStore {
396
397
  }
397
398
  return out;
398
399
  }
399
- async update(collection, id, locale, fields, by, expected) {
400
+ async update(collection, id, locale, fields, by, expected, options) {
400
401
  const schema = this.schema(collection);
401
402
  const entry = this.must(collection, id);
402
403
  const row = entryLocaleRow(entry, locale);
@@ -411,7 +412,7 @@ export class MemoryEntryStore {
411
412
  row.fields = localized;
412
413
  row.slug = slug;
413
414
  try {
414
- await this.writePaths(collection, this.subtree(entry));
415
+ await this.writePaths(collection, this.subtree(entry), options?.redirect ?? true);
415
416
  }
416
417
  catch (e) {
417
418
  entry.fields = before.fields;
@@ -505,7 +506,7 @@ export class MemoryEntryStore {
505
506
  throw entryErrors.localeInUse(locale, blockers);
506
507
  delete entry.locales[locale];
507
508
  try {
508
- await this.writePaths(collection, [entry]);
509
+ await this.writePaths(collection, [entry], false);
509
510
  }
510
511
  catch (e) {
511
512
  entry.locales[locale] = row;
@@ -546,7 +547,7 @@ export class MemoryEntryStore {
546
547
  const v = this.versions.get(versionId);
547
548
  return v ? structuredClone(v) : null;
548
549
  }
549
- async move(collection, id, to) {
550
+ async move(collection, id, to, options) {
550
551
  if (isGlobalSchema(this.schema(collection)))
551
552
  throw entryErrors.owned(collection, "it has no tree position");
552
553
  const entry = this.must(collection, id);
@@ -572,7 +573,7 @@ export class MemoryEntryStore {
572
573
  const previous = new Map(siblings.map((s) => [s.id, s.sort]));
573
574
  siblings.forEach((s, i) => (s.sort = i + 1));
574
575
  try {
575
- await this.writePaths(collection, subtree);
576
+ await this.writePaths(collection, subtree, options?.redirect ?? true);
576
577
  }
577
578
  catch (e) {
578
579
  Object.assign(entry, before);
@@ -621,4 +622,8 @@ export class MemoryEntryStore {
621
622
  pathsOf(collection, id) {
622
623
  return this.row(collection, id) ? this.paths.pathsOf({ kind: "entry", id }) : Promise.resolve({});
623
624
  }
625
+ async redirectsOf(collection, id) {
626
+ this.schema(collection);
627
+ return groupRedirects(await this.paths.redirectsOf({ kind: "entry", id }));
628
+ }
624
629
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { unstable_cache } from "next/cache";
3
3
  import { draftMode } from "next/headers";
4
- import { notFound } from "next/navigation";
4
+ import { notFound, permanentRedirect } from "next/navigation";
5
5
  import { connection } from "next/server";
6
6
  import { EditorBridge } from "../admin/next/bridge.js";
7
7
  import { fixedPathFor } from "../localize.js";
@@ -15,18 +15,31 @@ export function createPageRenderer(options) {
15
15
  const href = options.href ?? ((path) => path);
16
16
  const shapes = new Map(options.pages.filter((p) => p.schema.kind === "page").map((p) => [p.schema.name, p]));
17
17
  const entryPages = new Map(options.pages.filter((p) => p.schema.kind === "entryPage" && p.schema.collection).map((p) => [p.schema.collection, p]));
18
- /** locate (uncached: the id it returns IS the cache tag) then load —
19
- * cached per (locale, path) outside draft mode. */
20
- async function load(locale, path, draft, version) {
21
- const target = await site.locate(locale, path);
22
- if (!target)
23
- return null;
18
+ /** The hit for a located target (locate is uncached: the id it returns
19
+ * IS the cache tag): load — cached per (locale, path) outside draft mode. */
20
+ async function load(target, locale, path, draft, version) {
24
21
  if (draft)
25
22
  return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
26
23
  return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
27
24
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
28
25
  })();
29
26
  }
27
+ /** A redirect row (spec 2026-09-26 redirects §7): the node's CURRENT path in this locale, through href, the query kept; no path there is a 404. */
28
+ async function redirectTo(target, locale, params) {
29
+ const current = (await site.targetPaths(target))[locale];
30
+ if (!current)
31
+ notFound();
32
+ const query = new URLSearchParams();
33
+ for (const [k, v] of Object.entries(params ?? {})) {
34
+ if (typeof v === "string")
35
+ query.append(k, v);
36
+ else
37
+ for (const item of v ?? [])
38
+ query.append(k, item);
39
+ }
40
+ const qs = query.toString();
41
+ permanentRedirect(href(current, locale) + (qs ? `?${qs}` : ""));
42
+ }
30
43
  function render(hit, editor, locale) {
31
44
  if (hit.kind === "page") {
32
45
  const Page = shapes.get(hit.template);
@@ -67,7 +80,10 @@ export function createPageRenderer(options) {
67
80
  const editor = draft && params?.["smoodly-editor"] === "1";
68
81
  const versionParam = params?.["smoodly-version"];
69
82
  const version = draft && typeof versionParam === "string" && versionParam !== "" ? versionParam : undefined;
70
- const hit = await load(locale, path, draft, version);
83
+ const target = await site.locate(locale, path);
84
+ if (target?.redirect)
85
+ await redirectTo(target, locale, params);
86
+ const hit = target ? await load(target, locale, path, draft, version) : null;
71
87
  if (!hit || !accept(hit))
72
88
  notFound();
73
89
  return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
@@ -78,7 +94,10 @@ export function createPageRenderer(options) {
78
94
  const locale = opts?.locale ?? site.locales.default;
79
95
  const path = pathFor(locale);
80
96
  const { isEnabled: draft } = await draftMode();
81
- const hit = await load(locale, path, draft);
97
+ const target = await site.locate(locale, path);
98
+ if (!target || target.redirect)
99
+ return {};
100
+ const hit = await load(target, locale, path, draft);
82
101
  if (!hit || !accept(hit))
83
102
  return {};
84
103
  return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta, { title: hit.title }) : {}), ...alternates(hit) };
@@ -1,16 +1,40 @@
1
1
  import type { PathRow, PathTarget } from "./paths.ts";
2
2
  export type { PathRow, PathTarget } from "./paths.ts";
3
+ /** A rewrite target; `redirectLocales` names the locales whose old live rows stay as redirects (none = drop them). */
4
+ export type RewriteTarget = PathTarget & {
5
+ redirectLocales?: readonly string[];
6
+ };
7
+ /** What lives at an address: the target, and whether the row is a redirect to it. */
8
+ export type PathHit = PathTarget & {
9
+ redirect: boolean;
10
+ };
3
11
  export interface PathIndex {
4
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
12
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
13
+ /** Every row of the targets, redirects included (a deleted node has no old addresses). */
5
14
  remove(targets: PathTarget[]): Promise<void>;
6
- resolve(locale: string, path: string): Promise<PathTarget | null>;
15
+ resolve(locale: string, path: string): Promise<PathHit | null>;
16
+ /** locale → the target's LIVE path. */
7
17
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
18
+ /** The target's redirect rows. */
19
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
20
+ listLive(locale: string): Promise<PathRow[]>;
21
+ listRedirects(locale: string): Promise<PathRow[]>;
22
+ /** A redirect by hand: refused (pathLiveError) at a live address; another redirect row there is replaced. */
23
+ addRedirect(row: PathRow): Promise<void>;
24
+ /** Deletes a redirect row; a live row at the address is left alone. */
25
+ removeRedirect(locale: string, path: string): Promise<void>;
8
26
  }
9
27
  export declare const pathTakenError: (locale: string, path: string) => Error;
28
+ export declare const pathLiveError: (locale: string, path: string) => Error;
10
29
  export declare class MemoryPathIndex implements PathIndex {
11
30
  private rows;
12
- rewrite(targets: PathTarget[], rows: PathRow[]): Promise<void>;
31
+ rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
13
32
  remove(targets: PathTarget[]): Promise<void>;
14
- resolve(locale: string, path: string): Promise<PathTarget | null>;
33
+ resolve(locale: string, path: string): Promise<PathHit | null>;
15
34
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
35
+ redirectsOf(target: PathTarget): Promise<PathRow[]>;
36
+ listLive(locale: string): Promise<PathRow[]>;
37
+ listRedirects(locale: string): Promise<PathRow[]>;
38
+ addRedirect(row: PathRow): Promise<void>;
39
+ removeRedirect(locale: string, path: string): Promise<void>;
16
40
  }
@@ -1,12 +1,27 @@
1
1
  const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id;
2
2
  const key = (locale, path) => `${locale} ${path}`;
3
3
  export const pathTakenError = (locale, path) => new Error(`smoodly: the path "${path}" (${locale}) is already taken.`);
4
+ export const pathLiveError = (locale, path) => new Error(`smoodly: "${path}" (${locale}) is a live address.`);
5
+ const strip = ({ redirect: _redirect, ...row }) => row;
4
6
  export class MemoryPathIndex {
5
7
  constructor() {
6
8
  this.rows = [];
7
9
  }
8
10
  async rewrite(targets, rows) {
9
- const kept = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
11
+ const ofTarget = (r) => targets.find((t) => sameTarget(t, r));
12
+ const newKeys = new Set(rows.map((r) => key(r.locale, r.path)));
13
+ // 1. redirect rows at the new live addresses go, whoever they point at (live wins, rename-back reclaims)
14
+ let kept = this.rows.filter((r) => !(r.redirect && newKeys.has(key(r.locale, r.path))));
15
+ // 2. the targets' old live rows: flipped to redirects in the named locales unless the address stays live
16
+ kept = kept.map((r) => {
17
+ const t = !r.redirect ? ofTarget(r) : undefined;
18
+ return t && t.redirectLocales?.includes(r.locale) && !newKeys.has(key(r.locale, r.path)) ? { ...r, redirect: true } : r;
19
+ });
20
+ // 3. the targets' remaining live rows go — and so do their redirect rows in a locale the
21
+ // target has no new live row in (a removed translation: nothing could resolve them)
22
+ const liveLocales = new Set(rows.map((r) => `${r.kind} ${r.id} ${r.locale}`));
23
+ kept = kept.filter((r) => !ofTarget(r) || (r.redirect && liveLocales.has(`${r.kind} ${r.id} ${r.locale}`)));
24
+ // 4. the new live rows: a collision now is with another target's live row
10
25
  const taken = new Set(kept.map((r) => key(r.locale, r.path)));
11
26
  for (const row of rows) {
12
27
  const k = key(row.locale, row.path);
@@ -14,20 +29,38 @@ export class MemoryPathIndex {
14
29
  throw pathTakenError(row.locale, row.path);
15
30
  taken.add(k);
16
31
  }
17
- this.rows = [...kept, ...rows.map((r) => ({ ...r }))];
32
+ this.rows = [...kept, ...rows.map((r) => ({ ...r, redirect: false }))];
18
33
  }
19
34
  async remove(targets) {
20
35
  this.rows = this.rows.filter((r) => !targets.some((t) => sameTarget(t, r)));
21
36
  }
22
37
  async resolve(locale, path) {
23
38
  const row = this.rows.find((r) => r.locale === locale && r.path === path);
24
- return row ? { kind: row.kind, id: row.id } : null;
39
+ return row ? { kind: row.kind, id: row.id, redirect: row.redirect } : null;
25
40
  }
26
41
  async pathsOf(target) {
27
42
  const out = {};
28
43
  for (const r of this.rows)
29
- if (sameTarget(r, target))
44
+ if (!r.redirect && sameTarget(r, target))
30
45
  out[r.locale] = r.path;
31
46
  return out;
32
47
  }
48
+ async redirectsOf(target) {
49
+ return this.rows.filter((r) => r.redirect && sameTarget(r, target)).map(strip);
50
+ }
51
+ async listLive(locale) {
52
+ return this.rows.filter((r) => !r.redirect && r.locale === locale).map(strip);
53
+ }
54
+ async listRedirects(locale) {
55
+ return this.rows.filter((r) => r.redirect && r.locale === locale).map(strip);
56
+ }
57
+ async addRedirect(row) {
58
+ const existing = this.rows.find((r) => r.locale === row.locale && r.path === row.path);
59
+ if (existing && !existing.redirect)
60
+ throw pathLiveError(row.locale, row.path);
61
+ this.rows = [...this.rows.filter((r) => r !== existing), { ...row, redirect: true }];
62
+ }
63
+ async removeRedirect(locale, path) {
64
+ this.rows = this.rows.filter((r) => !(r.redirect && r.locale === locale && r.path === path));
65
+ }
33
66
  }
package/dist/paths.d.ts CHANGED
@@ -91,6 +91,22 @@ export declare function entrySegment(entry: EntryLike, locale: string): string |
91
91
  export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
92
92
  path?: string | Record<string, string>;
93
93
  }, set: LocaleSet): PathRow[];
94
+ /** The locales a node is published in — the ones whose old addresses may
95
+ * stay as redirects (spec 2026-09-26 redirects §2: only published rows). */
96
+ export declare function publishedLocales(node: {
97
+ locales: Record<string, {
98
+ status: "draft" | "published";
99
+ }>;
100
+ }): string[];
101
+ /** The rewrite targets for a subtree: with `redirect`, each names its published locales. */
102
+ export declare function rewriteTargets<T extends {
103
+ id: string;
104
+ locales: Record<string, {
105
+ status: "draft" | "published";
106
+ }>;
107
+ }>(kind: "page" | "entry", nodes: T[], redirect: boolean): (PathTarget & {
108
+ redirectLocales?: string[];
109
+ })[];
94
110
  /** A target's address in one locale (spec 2026-09-15 §1). A page always
95
111
  * has both; an entry's slug is null when its row claims no URL, and its
96
112
  * path null then or when the collection has no `path`. */
package/dist/paths.js CHANGED
@@ -130,6 +130,15 @@ export function entryPathRows(targets, byId, collection, set) {
130
130
  }
131
131
  return rows;
132
132
  }
133
+ /** The locales a node is published in — the ones whose old addresses may
134
+ * stay as redirects (spec 2026-09-26 redirects §2: only published rows). */
135
+ export function publishedLocales(node) {
136
+ return Object.keys(node.locales).filter((l) => node.locales[l].status === "published");
137
+ }
138
+ /** The rewrite targets for a subtree: with `redirect`, each names its published locales. */
139
+ export function rewriteTargets(kind, nodes, redirect) {
140
+ return nodes.map((n) => (redirect ? { kind, id: n.id, redirectLocales: publishedLocales(n) } : { kind, id: n.id }));
141
+ }
133
142
  /** The page's address in EVERY supported locale, in config order: null
134
143
  * where the node is absent, where an ancestor is, or where `advertised`
135
144
  * says no — the site passes "published, or draft mode", so an
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 2;
4
+ export declare const SCHEMA_VERSION = 4;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 2;
10
+ export const SCHEMA_VERSION = 4;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;