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
|
@@ -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
|
|
6
|
-
export
|
|
7
|
-
/** The
|
|
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;
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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. */
|
package/dist/admin/ui/confirm.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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). */
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -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)}
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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<
|
|
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
|
|
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<
|
|
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
|
}
|
package/dist/entry-store.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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) };
|
package/dist/path-index.d.ts
CHANGED
|
@@ -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:
|
|
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<
|
|
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:
|
|
31
|
+
rewrite(targets: RewriteTarget[], rows: PathRow[]): Promise<void>;
|
|
13
32
|
remove(targets: PathTarget[]): Promise<void>;
|
|
14
|
-
resolve(locale: string, path: string): Promise<
|
|
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
|
}
|
package/dist/path-index.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/schema-version.js
CHANGED
|
@@ -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 =
|
|
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$/;
|