smoodly 0.0.32 → 0.0.34
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 +77 -15
- package/dist/addresses.d.ts +42 -0
- package/dist/addresses.js +51 -0
- package/dist/admin/editor/EditorView.js +5 -3
- package/dist/admin/editor/PageSettings.js +7 -2
- package/dist/admin/editor/autosave.d.ts +2 -1
- package/dist/admin/editor/autosave.js +6 -0
- package/dist/admin/editor/useCanvasBridge.js +25 -2
- package/dist/admin/fixed-nodes.d.ts +9 -24
- package/dist/admin/fixed-nodes.js +36 -72
- package/dist/admin/forms/FieldWidget.d.ts +5 -1
- package/dist/admin/forms/FieldWidget.js +6 -3
- package/dist/admin/forms/slug-field.d.ts +17 -0
- package/dist/admin/forms/slug-field.js +39 -0
- package/dist/admin/health.js +9 -0
- package/dist/admin/next/create-admin.d.ts +4 -3
- package/dist/admin/next/create-admin.js +14 -3
- package/dist/admin/ops-impl.js +144 -14
- package/dist/admin/redirect-path.d.ts +3 -0
- package/dist/admin/redirect-path.js +9 -0
- package/dist/admin/serialize.d.ts +3 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AddRedirectModal.js +2 -2
- package/dist/admin/shell/EntryForm.js +46 -6
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/PageDialogs.js +11 -5
- package/dist/admin/shell/PagesList.js +29 -12
- package/dist/admin/shell/live-url.d.ts +3 -1
- package/dist/admin/shell/live-url.js +5 -2
- package/dist/admin/shell/pages-tree.d.ts +19 -14
- package/dist/admin/shell/pages-tree.js +49 -16
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/ui/theme.js +1 -0
- package/dist/admin/validate.d.ts +2 -0
- package/dist/admin/validate.js +9 -0
- package/dist/anchors.d.ts +42 -0
- package/dist/anchors.js +72 -0
- package/dist/cli/export.js +3 -1
- package/dist/cli/import.d.ts +1 -0
- package/dist/cli/import.js +13 -2
- package/dist/cli/space-io.d.ts +1 -2
- package/dist/cli/space-io.js +5 -5
- package/dist/collections.js +15 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +53 -18
- package/dist/current-space.d.ts +4 -0
- package/dist/current-space.js +9 -0
- package/dist/entry-store.js +4 -2
- package/dist/env.d.ts +4 -3
- package/dist/env.js +15 -17
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/next/page-renderer.js +2 -2
- package/dist/path-index.d.ts +11 -0
- package/dist/path-index.js +52 -25
- package/dist/paths.d.ts +32 -12
- package/dist/paths.js +80 -21
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +7 -3
- package/dist/site.js +26 -15
- package/dist/space-transfer.d.ts +2 -0
- package/dist/space-transfer.js +8 -1
- package/dist/sql.js +35 -9
- package/dist/store.d.ts +15 -1
- package/dist/store.js +27 -6
- package/dist/supabase-entry-store.d.ts +0 -1
- package/dist/supabase-entry-store.js +6 -11
- package/dist/supabase-path-index.d.ts +12 -1
- package/dist/supabase-path-index.js +56 -19
- package/dist/supabase-store.d.ts +2 -0
- package/dist/supabase-store.js +30 -9
- package/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/package.json +1 -1
- package/dist/admin/shell/page-dialogs.d.ts +0 -9
- package/dist/admin/shell/page-dialogs.js +0 -19
|
@@ -10,7 +10,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
10
10
|
import { useId, useState } from "react";
|
|
11
11
|
import { DialogCard } from "../ui/Dialog.js";
|
|
12
12
|
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
13
|
-
import {
|
|
13
|
+
import { slugify } from "../../paths.js";
|
|
14
|
+
import { followsTitle, slugHint, tidySlug } from "../forms/slug-field.js";
|
|
14
15
|
export function PageFormDialog({ title: heading, submitLabel, fields, templates = [], parentPath, initial, onClose, onSubmit }) {
|
|
15
16
|
const [values, setValues] = useState({
|
|
16
17
|
title: initial?.title ?? "",
|
|
@@ -22,17 +23,22 @@ export function PageFormDialog({ title: heading, submitLabel, fields, templates
|
|
|
22
23
|
const chooseTemplate = fields.template && templates.length > 1;
|
|
23
24
|
const prefix = parentPath === undefined ? undefined : parentPath === "/" ? "/" : `${parentPath}/`;
|
|
24
25
|
const setTitle = (title) => setValues((v) => ({ ...v, title, slug: fields.slug && followsTitle(v.slug, v.title) ? slugify(title) : v.slug }));
|
|
26
|
+
// The slug tidies when it loses focus (spec 2026-09-28 ASCII slugs §4).
|
|
27
|
+
const tidy = () => setValues((v) => ({ ...v, slug: tidySlug(v.slug) }));
|
|
25
28
|
const submit = async (e) => {
|
|
26
29
|
e.preventDefault();
|
|
27
30
|
if (busy)
|
|
28
31
|
return;
|
|
29
32
|
setError(null);
|
|
30
33
|
setBusy(true);
|
|
31
|
-
|
|
34
|
+
// Enter submits without a blur: the slug is tidied here too (spec 2026-09-28 ASCII slugs §4).
|
|
35
|
+
const slug = tidySlug(values.slug.trim());
|
|
36
|
+
setValues((v) => ({ ...v, slug }));
|
|
37
|
+
setError(await onSubmit({ ...values, title: values.title.trim(), slug }));
|
|
32
38
|
setBusy(false);
|
|
33
39
|
};
|
|
34
40
|
const formId = `sm-page-form${useId()}`;
|
|
35
|
-
return (_jsx(DialogCard, { title: heading, onClose: onClose, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", form: formId, className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Saving…" : submitLabel })] }), children: _jsxs("form", { id: formId, className: "sm-dialog__form", onSubmit: submit, children: [fields.title && (_jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", required: true, autoFocus: true, value: values.title, onChange: (e) => setTitle(e.target.value) }) })), fields.slug && (_jsx(Field, { label: "Slug", hint: "The URL segment: lowercase letters, digits and hyphens.", children: prefix
|
|
36
|
-
? (_jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), _jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })) })] }))
|
|
37
|
-
: (_jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })) })) })), chooseTemplate && (_jsx(Field, { label: "Page type", children: _jsx("select", { className: "sm-select", autoFocus: !fields.title && !fields.slug, value: values.template ?? "", onChange: (e) => setValues((v) => ({ ...v, template: e.target.value })), children: templates.map((t) => _jsx("option", { value: t.name, children: t.title }, t.name)) }) })), error && _jsx(Banner, { tone: "error", children: error })] }) }));
|
|
41
|
+
return (_jsx(DialogCard, { title: heading, onClose: onClose, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", form: formId, className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Saving…" : submitLabel })] }), children: _jsxs("form", { id: formId, className: "sm-dialog__form", onSubmit: submit, children: [fields.title && (_jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", required: true, autoFocus: true, value: values.title, onChange: (e) => setTitle(e.target.value) }) })), fields.slug && (_jsx(Field, { label: "Slug", hint: (values.slug === "" ? slugHint(values.title) : undefined) ?? "The URL segment: lowercase letters, digits and hyphens.", children: prefix
|
|
42
|
+
? (_jsxs("span", { className: "sm-inputwrap", children: [_jsx("span", { className: "sm-prefix", children: prefix }), _jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })), onBlur: tidy })] }))
|
|
43
|
+
: (_jsx("input", { className: "sm-input sm-input--mono", required: true, autoFocus: !fields.title, value: values.slug, onChange: (e) => setValues((v) => ({ ...v, slug: e.target.value })), onBlur: tidy })) })), chooseTemplate && (_jsx(Field, { label: "Page type", children: _jsx("select", { className: "sm-select", autoFocus: !fields.title && !fields.slug, value: values.template ?? "", onChange: (e) => setValues((v) => ({ ...v, template: e.target.value })), children: templates.map((t) => _jsx("option", { value: t.name, children: t.title }, t.name)) }) })), error && _jsx(Banner, { tone: "error", children: error })] }) }));
|
|
38
44
|
}
|
|
@@ -9,7 +9,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
9
9
|
// absent from the selected locale is dimmed and offers "Add <locale>".
|
|
10
10
|
import { useCallback, useEffect, useState } from "react";
|
|
11
11
|
import { ListView } from "./ListView.js";
|
|
12
|
-
import { canDragRow, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
|
|
12
|
+
import { canDragRow, codeAddressNote, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
|
|
13
13
|
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
14
14
|
import { statusLabel } from "../ui/status-label.js";
|
|
15
15
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
@@ -68,7 +68,7 @@ export function PagesList({ ops, registry }) {
|
|
|
68
68
|
return;
|
|
69
69
|
// A published page under a new parent is a new live URL (members management §5).
|
|
70
70
|
const current = row.record.locales[locale];
|
|
71
|
-
const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path });
|
|
71
|
+
const live = await confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }, { name: row.title, path: row.path }, codeAddressNote(row.path, registry, locale));
|
|
72
72
|
if (!live.ok)
|
|
73
73
|
return;
|
|
74
74
|
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index, redirect: live.redirect }));
|
|
@@ -151,7 +151,7 @@ export function PagesList({ ops, registry }) {
|
|
|
151
151
|
// The site root's slug is the config's homePageSlug — title only there.
|
|
152
152
|
if (row.isHome)
|
|
153
153
|
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title }));
|
|
154
|
-
const live = await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path });
|
|
154
|
+
const live = await confirmLiveUrl({ status: current.status, from: current.slug, to: values.slug }, { name: row.title, path: row.path }, codeAddressNote(row.path, registry, locale));
|
|
155
155
|
if (!live.ok)
|
|
156
156
|
return null;
|
|
157
157
|
return attempt(() => ops.pages.rename(row.id, { locale, title: values.title, slug: values.slug, redirect: live.redirect }));
|
|
@@ -196,8 +196,16 @@ export function PagesList({ ops, registry }) {
|
|
|
196
196
|
};
|
|
197
197
|
const menuItems = (row) => {
|
|
198
198
|
const items = [];
|
|
199
|
+
// A record no longer in code (spec 2026-09-29 nested paths §5): deleting it is all that is open.
|
|
200
|
+
if (row.orphan) {
|
|
201
|
+
if (!row.hasChildren)
|
|
202
|
+
items.push({ label: "Delete", run: () => destroy(row) });
|
|
203
|
+
return items;
|
|
204
|
+
}
|
|
199
205
|
if (!row.present) {
|
|
200
|
-
|
|
206
|
+
// A code-owned record absent here waits for its address in this locale (an editor's page holds it, §5).
|
|
207
|
+
if (!row.fixed)
|
|
208
|
+
items.push({ label: `Add ${locale}`, run: () => addLocale(row) });
|
|
201
209
|
return items;
|
|
202
210
|
}
|
|
203
211
|
if (row.canHaveChildren && !noOpen)
|
|
@@ -221,8 +229,10 @@ export function PagesList({ ops, registry }) {
|
|
|
221
229
|
// Only a mount with no index page has nowhere else to go, so it opens
|
|
222
230
|
// the collection list. The collection itself lives on the rail.
|
|
223
231
|
const onRow = (row) => {
|
|
224
|
-
if (!row.present)
|
|
225
|
-
|
|
232
|
+
if (!row.present) {
|
|
233
|
+
if (!row.orphan && !row.fixed)
|
|
234
|
+
addLocale(row);
|
|
235
|
+
}
|
|
226
236
|
else if (row.template !== null)
|
|
227
237
|
openEditor(row.id);
|
|
228
238
|
else if (row.kind === "mount" && row.collection)
|
|
@@ -249,11 +259,18 @@ export function PagesList({ ops, registry }) {
|
|
|
249
259
|
key: row.id,
|
|
250
260
|
onClick: () => onRow(row),
|
|
251
261
|
cells: [
|
|
252
|
-
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }),
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
262
|
+
_jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), row.conflict
|
|
263
|
+
? (
|
|
264
|
+
// An editor's page on a code address (spec 2026-09-29 nested paths §5): the notice under its title.
|
|
265
|
+
// One line in the fixed-height row; the whole notice on hover.
|
|
266
|
+
_jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx("span", { className: "sm-cell--warn", title: row.conflict, children: row.conflict })] }))
|
|
267
|
+
: _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title }), _jsx(LockIcon, { names: editorsOf(others, { kind: "page", id: row.id, locale }) })] }, "t"),
|
|
268
|
+
row.orphan
|
|
269
|
+
? _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "p")
|
|
270
|
+
: row.present
|
|
271
|
+
? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
|
|
272
|
+
: _jsx("span", { className: "sm-cell sm-cell--muted", children: row.fixed ? `not in ${locale}` : `not in ${locale} — click to add` }, "p"),
|
|
273
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", style: dim, children: row.orphan ? "No longer in code" : row.templateTitle }, "k"),
|
|
257
274
|
row.status
|
|
258
275
|
? _jsx(StatusBadge, { status: statusLabel(row.status) }, "s")
|
|
259
276
|
: _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
|
|
@@ -264,7 +281,7 @@ export function PagesList({ ops, registry }) {
|
|
|
264
281
|
position: "absolute",
|
|
265
282
|
left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
|
|
266
283
|
top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
|
|
267
|
-
}, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "Owned by code
|
|
284
|
+
}, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: menu?.row.orphan ? "No longer in code — move its child pages out, then delete it." : "Owned by code — nothing to change here." }))] }) })), dialog?.kind === "new" && (_jsx(PageFormDialog, { title: dialog.node === "page" ? (dialog.parentId ? "New subpage" : "New page") : (dialog.parentId ? "New subpath" : "New path"), submitLabel: dialog.node === "page" ? "Create page" : "Create path", fields: { title: true, slug: true, template: dialog.node === "page" }, templates: creatable, parentPath: dialog.parentId ? (rows.find((r) => r.id === dialog.parentId)?.path ?? "/") : "/", onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
268
285
|
closeDialog(); return message; }) })), dialog?.kind === "template" && (_jsx(PageFormDialog, { title: "Add page here", submitLabel: "Add page", fields: { template: true }, templates: creatable, onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
269
286
|
closeDialog(); return message; }) })), dialog?.kind === "rename" && (_jsx(PageFormDialog, { title: "Rename page", submitLabel: "Rename", fields: { title: true, slug: !dialog.row.isHome }, initial: { title: dialog.row.record.locales[locale]?.title ?? "", slug: dialog.row.record.locales[locale]?.slug ?? "" }, parentPath: dialog.row.isHome ? undefined : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
270
287
|
closeDialog(); return message; }) }))] }));
|
|
@@ -12,7 +12,9 @@ export declare function liveUrlChanges(input: {
|
|
|
12
12
|
to: string | null;
|
|
13
13
|
}): boolean;
|
|
14
14
|
/** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
|
|
15
|
-
export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject
|
|
15
|
+
export declare function confirmLiveUrl(input: Parameters<typeof liveUrlChanges>[0], subject?: ConfirmSubject,
|
|
16
|
+
/** A line after the body: the code addresses below the page that stay put (spec 2026-09-29 nested paths §3). */
|
|
17
|
+
note?: string): Promise<{
|
|
16
18
|
ok: boolean;
|
|
17
19
|
redirect: boolean;
|
|
18
20
|
}>;
|
|
@@ -16,9 +16,12 @@ export function liveUrlChanges(input) {
|
|
|
16
16
|
return input.status === "published" && input.from !== null && input.to !== input.from;
|
|
17
17
|
}
|
|
18
18
|
/** `ok` false = cancelled. `redirect` is the checkbox (true when no question was asked: the store's default). */
|
|
19
|
-
export async function confirmLiveUrl(input, subject
|
|
19
|
+
export async function confirmLiveUrl(input, subject,
|
|
20
|
+
/** A line after the body: the code addresses below the page that stay put (spec 2026-09-29 nested paths §3). */
|
|
21
|
+
note) {
|
|
20
22
|
if (!liveUrlChanges(input))
|
|
21
23
|
return { ok: true, redirect: true };
|
|
22
|
-
const
|
|
24
|
+
const body = note ? `${LIVE_URL_CONFIRM.body} ${note}` : LIVE_URL_CONFIRM.body;
|
|
25
|
+
const { yes, checked } = await confirmWithOption({ ...LIVE_URL_CONFIRM, body, option: { ...LIVE_URL_OPTION }, subject });
|
|
23
26
|
return { ok: yes, redirect: checked };
|
|
24
27
|
}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import type { PageLocale, PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import type { Projection } from "../ui/useSortable.ts";
|
|
4
|
-
type NodeLike = {
|
|
5
|
-
parentId: string | null;
|
|
6
|
-
locales: Record<string, {
|
|
7
|
-
slug: string;
|
|
8
|
-
}>;
|
|
9
|
-
};
|
|
10
4
|
/** Registrations editors may create pages against: fixed ones are
|
|
11
5
|
* materialized by Smoodly, never offered. */
|
|
12
6
|
export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
|
|
13
|
-
/** The collection a
|
|
14
|
-
*
|
|
15
|
-
export declare function mountOf(record:
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
/** The collection a record is the address of — by owner, never by slug, while code still gives
|
|
8
|
+
* it an address (spec 2026-09-29 nested paths §3). */
|
|
9
|
+
export declare function mountOf(record: {
|
|
10
|
+
fixed?: string | null;
|
|
11
|
+
}, registry: AdminRegistry): AdminCollection | null;
|
|
12
|
+
/** Fixed pages and mounts: owned by a registration that still exists. Never renamed, moved,
|
|
13
|
+
* deleted or stripped of a locale by editors. */
|
|
14
|
+
export declare function isFixedNode(record: {
|
|
15
|
+
fixed?: string | null;
|
|
16
|
+
}, registry: AdminRegistry): boolean;
|
|
17
|
+
/** The rename/move confirm's extra line (spec 2026-09-29 nested paths §3): code addresses below a page stay put. */
|
|
18
|
+
export declare function codeAddressNote(path: string | null, registry: AdminRegistry, locale: string): string | undefined;
|
|
18
19
|
/** A node's title for display in a locale it may not exist in: its own,
|
|
19
20
|
* else the default locale's, else any. Display only — never content. */
|
|
20
21
|
export declare function displayTitle(record: PageRecord, locale: string, registry: AdminRegistry): string;
|
|
@@ -46,6 +47,10 @@ export type PagesTreeRow = {
|
|
|
46
47
|
kind: "page" | "folder" | "mount";
|
|
47
48
|
collection: string | null;
|
|
48
49
|
fixed: boolean;
|
|
50
|
+
/** The notice on an editor's page that sits on a code address in this locale (spec 2026-09-29 nested paths §5). */
|
|
51
|
+
conflict: string | null;
|
|
52
|
+
/** A code-owned record no registration names any more: it has no path and may only be deleted. */
|
|
53
|
+
orphan: boolean;
|
|
49
54
|
/** The record serving "/" — never moved or deleted, and its default-locale slug is the config's. */
|
|
50
55
|
isHome: boolean;
|
|
51
56
|
template: string | null;
|
|
@@ -58,8 +63,9 @@ export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegi
|
|
|
58
63
|
export type TreeProjection = Projection;
|
|
59
64
|
/** Every row drags: root order is the listing's order, nothing code owns.
|
|
60
65
|
* Fixed nodes and the home page stay at the root — projectDrop refuses
|
|
61
|
-
* them any deeper slot, the move op refuses any other parent.
|
|
62
|
-
|
|
66
|
+
* them any deeper slot, the move op refuses any other parent. A record
|
|
67
|
+
* no longer in code does not move at all: deleting it is all that is open. */
|
|
68
|
+
export declare const canDragRow: (row: PagesTreeRow) => boolean;
|
|
63
69
|
/** Whether a row may only reorder among the root siblings. */
|
|
64
70
|
export declare const rootOnly: (row: PagesTreeRow) => boolean;
|
|
65
71
|
/** Levels below a node: 0 for a leaf. From the records, because the
|
|
@@ -78,4 +84,3 @@ export declare function subtreeHeight(records: {
|
|
|
78
84
|
* code-owned or the site root.
|
|
79
85
|
*/
|
|
80
86
|
export declare function projectDrop(rows: PagesTreeRow[], from: number, slot: number, xDrift: number, height: number, pageDepth: number, indent?: number): TreeProjection | null;
|
|
81
|
-
export {};
|
|
@@ -6,26 +6,54 @@
|
|
|
6
6
|
// an "Add" action. The shell is not DOM-tested; every decision that
|
|
7
7
|
// matters lives here.
|
|
8
8
|
import { buildPageTree } from "../../paths.js";
|
|
9
|
+
import { ownerCollection, pageKey } from "../../addresses.js";
|
|
9
10
|
/** Registrations editors may create pages against: fixed ones are
|
|
10
11
|
* materialized by Smoodly, never offered. */
|
|
11
12
|
export function openTemplates(templates) {
|
|
12
13
|
return templates.filter((t) => t.slug === undefined);
|
|
13
14
|
}
|
|
14
|
-
/** The
|
|
15
|
-
*
|
|
16
|
-
const rootSlug = (record, registry) => record.parentId === null ? record.locales[registry.locales.default]?.slug : undefined;
|
|
17
|
-
/** The collection a ROOT record is the mount of, if any — known from the
|
|
18
|
-
* registry, never from a column (spec 2026-09-05 §4). */
|
|
15
|
+
/** The collection a record is the address of — by owner, never by slug, while code still gives
|
|
16
|
+
* it an address (spec 2026-09-29 nested paths §3). */
|
|
19
17
|
export function mountOf(record, registry) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
return null;
|
|
23
|
-
return registry.collections.find((c) => c.path?.[registry.locales.default] === slug) ?? null;
|
|
18
|
+
const name = record.fixed && registry.addresses[record.fixed] ? ownerCollection(record.fixed) : null;
|
|
19
|
+
return name === null ? null : registry.collections.find((c) => c.name === name) ?? null;
|
|
24
20
|
}
|
|
25
|
-
/** Fixed pages and mounts:
|
|
21
|
+
/** Fixed pages and mounts: owned by a registration that still exists. Never renamed, moved,
|
|
22
|
+
* deleted or stripped of a locale by editors. */
|
|
26
23
|
export function isFixedNode(record, registry) {
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
return !!record.fixed && registry.addresses[record.fixed] !== undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Who owns `path` in a locale: a code address other than the home page's, which serves "/". */
|
|
27
|
+
function ownerAt(path, registry, locale) {
|
|
28
|
+
for (const [key, segments] of Object.entries(registry.addresses)) {
|
|
29
|
+
const address = segments[locale];
|
|
30
|
+
if (address === undefined || segments[registry.locales.default] === registry.homePageSlug || `/${address}` !== path)
|
|
31
|
+
continue;
|
|
32
|
+
const collection = ownerCollection(key);
|
|
33
|
+
const title = collection
|
|
34
|
+
? registry.collections.find((c) => c.name === collection)?.title ?? collection
|
|
35
|
+
: registry.pages.find((p) => pageKey(p.name) === key)?.title ?? key;
|
|
36
|
+
return { key, label: collection ? `the ${title} collection` : `the ${title} page` };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** The notice on a page that sits on another code address, which visitors get instead (spec 2026-09-29 anchored paths §4). */
|
|
41
|
+
function conflictNote(path, registry, locale) {
|
|
42
|
+
if (path === null)
|
|
43
|
+
return null;
|
|
44
|
+
const owner = ownerAt(path, registry, locale);
|
|
45
|
+
if (!owner)
|
|
46
|
+
return null;
|
|
47
|
+
return `${path} is set in code for ${owner.label}, so visitors don't see this page. Rename or move it.`;
|
|
48
|
+
}
|
|
49
|
+
/** The rename/move confirm's extra line (spec 2026-09-29 nested paths §3): code addresses below a page stay put. */
|
|
50
|
+
export function codeAddressNote(path, registry, locale) {
|
|
51
|
+
if (path === null)
|
|
52
|
+
return undefined;
|
|
53
|
+
const below = Object.values(registry.addresses)
|
|
54
|
+
.map((s) => s[locale])
|
|
55
|
+
.filter((a) => a !== undefined && `/${a}`.startsWith(`${path}/`));
|
|
56
|
+
return below.length === 0 ? undefined : below.map((a) => `/${a} stays where it is — it is set in code.`).join(" ");
|
|
29
57
|
}
|
|
30
58
|
/** A node's title for display in a locale it may not exist in: its own,
|
|
31
59
|
* else the default locale's, else any. Display only — never content. */
|
|
@@ -54,7 +82,7 @@ export function localeStatusOf(row) {
|
|
|
54
82
|
return row.publishedVersionId === row.draftVersionId ? "published" : "draft edits";
|
|
55
83
|
}
|
|
56
84
|
export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
57
|
-
const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
|
|
85
|
+
const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug, addresses: registry.addresses };
|
|
58
86
|
const templateTitle = (name) => registry.pages.find((t) => t.name === name)?.title ?? name;
|
|
59
87
|
const rows = [];
|
|
60
88
|
const walk = (nodes, depth, parentId) => {
|
|
@@ -65,6 +93,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
65
93
|
const mount = mountOf(r, registry);
|
|
66
94
|
const isHome = r.parentId === null && r.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
67
95
|
const expanded = !collapsed.has(r.id);
|
|
96
|
+
const orphan = !!r.fixed && registry.addresses[r.fixed] === undefined;
|
|
68
97
|
rows.push({
|
|
69
98
|
id: r.id,
|
|
70
99
|
parentId,
|
|
@@ -80,6 +109,8 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
80
109
|
kind: mount ? "mount" : r.template === null ? "folder" : "page",
|
|
81
110
|
collection: mount?.name ?? null,
|
|
82
111
|
fixed: isFixedNode(r, registry),
|
|
112
|
+
conflict: r.fixed ? null : conflictNote(node.path, registry, locale),
|
|
113
|
+
orphan,
|
|
83
114
|
isHome,
|
|
84
115
|
template: r.template,
|
|
85
116
|
templateTitle: mount
|
|
@@ -87,7 +118,8 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
87
118
|
: r.template === null ? "Path" : templateTitle(r.template),
|
|
88
119
|
status: !row || r.template === null ? null : localeStatusOf(row),
|
|
89
120
|
// A child needs its parent in the locale (the parent rule), so an absent node offers none.
|
|
90
|
-
|
|
121
|
+
// A record no longer in code has no URL to give a child (spec 2026-09-29 nested paths §5).
|
|
122
|
+
canHaveChildren: present && !mount && !isHome && !orphan && depth + 1 < registry.pageDepth,
|
|
91
123
|
record: r,
|
|
92
124
|
});
|
|
93
125
|
if (expanded)
|
|
@@ -99,8 +131,9 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
99
131
|
}
|
|
100
132
|
/** Every row drags: root order is the listing's order, nothing code owns.
|
|
101
133
|
* Fixed nodes and the home page stay at the root — projectDrop refuses
|
|
102
|
-
* them any deeper slot, the move op refuses any other parent.
|
|
103
|
-
|
|
134
|
+
* them any deeper slot, the move op refuses any other parent. A record
|
|
135
|
+
* no longer in code does not move at all: deleting it is all that is open. */
|
|
136
|
+
export const canDragRow = (row) => !row.orphan;
|
|
104
137
|
/** Whether a row may only reorder among the root siblings. */
|
|
105
138
|
export const rootOnly = (row) => row.fixed || row.isHome;
|
|
106
139
|
/** Levels below a node: 0 for a leaf. From the records, because the
|
|
@@ -132,6 +132,7 @@ export function SaveIndicator({ state, pending = false }) {
|
|
|
132
132
|
saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
|
|
133
133
|
error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
|
|
134
134
|
stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
|
|
135
|
+
refused: { label: "Not saved — fix the marked fields", dot: "var(--danger)", color: "var(--danger)" },
|
|
135
136
|
};
|
|
136
137
|
const { label, dot, color } = map[pending && state === "saved" ? "saving" : state];
|
|
137
138
|
return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -667,6 +667,7 @@ body:has(.sm-admin){margin:0}
|
|
|
667
667
|
.sm-row--clickable:hover{background:var(--surface-inset)}
|
|
668
668
|
.sm-cell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
669
669
|
.sm-cell--muted{color:var(--text-muted);font-size:var(--meta-size)}
|
|
670
|
+
.sm-cell--warn{color:var(--warn);font-size:var(--meta-size);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
670
671
|
.sm-cell--mono{font-family:var(--font-mono);font-size:var(--text-sm);color:var(--text-muted)}
|
|
671
672
|
.sm-cell--title{font-weight:500}
|
|
672
673
|
.sm-cell--grip{overflow:visible;display:flex;align-items:center;margin-left:-4px}
|
package/dist/admin/validate.d.ts
CHANGED
package/dist/admin/validate.js
CHANGED
|
@@ -13,6 +13,9 @@ import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
|
|
|
13
13
|
import { rowsProblems } from "../custom/rows.js";
|
|
14
14
|
import { isCalendarDay, isInstant } from "./forms/date-picker.js";
|
|
15
15
|
import { fits, misfitMessage } from "./drift.js";
|
|
16
|
+
import { isSegment } from "../paths.js";
|
|
17
|
+
/** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
|
|
18
|
+
export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
|
|
16
19
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
17
20
|
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
18
21
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
@@ -37,6 +40,12 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
37
40
|
errors.push({ field: path, message: misfitMessage(d, value) });
|
|
38
41
|
return;
|
|
39
42
|
}
|
|
43
|
+
// Slugs (spec 2026-09-28 ASCII slugs §3): the path grammar, checked at
|
|
44
|
+
// publish only — saves stay lenient, since the form autosaves mid-typing.
|
|
45
|
+
if (d.type === "slug" && !isSegment(value)) {
|
|
46
|
+
errors.push({ field: path, message: SLUG_GRAMMAR_MESSAGE });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
40
49
|
if (d.type === "richtext") {
|
|
41
50
|
if (!hasContent(value)) {
|
|
42
51
|
if (!mayBeAbsent(d))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { codeAddresses, type Addresses } from "./addresses.ts";
|
|
2
|
+
import type { LocaleSet } from "./paths.ts";
|
|
3
|
+
export type Anchors = {
|
|
4
|
+
addresses: Addresses;
|
|
5
|
+
homePageSlug: string;
|
|
6
|
+
defaultLocale: string;
|
|
7
|
+
};
|
|
8
|
+
export type AnchorKey = {
|
|
9
|
+
anchor: string;
|
|
10
|
+
path: string;
|
|
11
|
+
};
|
|
12
|
+
type PageLike = Parameters<typeof codeAddresses>[0]["pages"][number];
|
|
13
|
+
type CollectionLike = Parameters<typeof codeAddresses>[0]["collections"][number];
|
|
14
|
+
export declare function anchorsFor(input: {
|
|
15
|
+
locales: LocaleSet;
|
|
16
|
+
pages?: ReadonlyArray<PageLike>;
|
|
17
|
+
collections?: ReadonlyArray<CollectionLike>;
|
|
18
|
+
homePageSlug?: string;
|
|
19
|
+
}): Anchors;
|
|
20
|
+
/** The URL an anchor's own record serves in a locale; the home page's is "/". Null when the config names no address there. */
|
|
21
|
+
export declare function anchorUrl(anchors: Anchors, anchor: string, locale: string): string | null;
|
|
22
|
+
/** A stored row's full URL; null when its anchor has no address (a record no longer in code, a collection without `path`). */
|
|
23
|
+
export declare function fullPath(anchors: Anchors, anchor: string, path: string, locale: string): string | null;
|
|
24
|
+
/** What a request URL may hit, best first: every code address it equals or lies below (the longest first), then the
|
|
25
|
+
* editors' tree. `coded`: the URL is code's, so a row without an anchor counts only as a redirect (spec §2). */
|
|
26
|
+
export declare function candidates(anchors: Anchors, locale: string, url: string): {
|
|
27
|
+
keys: AnchorKey[];
|
|
28
|
+
coded: boolean;
|
|
29
|
+
};
|
|
30
|
+
/** The anchor a URL is served under: its longest code address, or `''` for the editors' tree (spec §2). A page
|
|
31
|
+
* whose own anchor (its chain root's owner key, `''` in the editors' tree) is another one is hidden there: code wins
|
|
32
|
+
* the address (spec §4). */
|
|
33
|
+
export declare const servedAnchor: (anchors: Anchors, locale: string, url: string) => string;
|
|
34
|
+
/** Every stored key that yields the same full URL as (anchor, path): a row moving between the editors' tree and a
|
|
35
|
+
* code anchor keeps its URL, so it must be the same address to the rewrite (no redirect to itself). An anchor with
|
|
36
|
+
* no address has only its own key. */
|
|
37
|
+
export declare function sameUrlKeys(anchors: Anchors, locale: string, anchor: string, path: string): AnchorKey[];
|
|
38
|
+
/** The winning row among those found for `keys`, by key order (spec §2's precedence). */
|
|
39
|
+
export declare function pickHit<R extends AnchorKey & {
|
|
40
|
+
redirect: boolean;
|
|
41
|
+
}>(found: R[], keys: AnchorKey[], coded: boolean): R | null;
|
|
42
|
+
export {};
|
package/dist/anchors.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Anchored paths (spec 2026-09-29 anchored paths): a `paths` row stores
|
|
2
|
+
// the part editors made, under the owner key of the code address it hangs
|
|
3
|
+
// on (`collection:news`, `page:about`; `''` for the editors' tree). The
|
|
4
|
+
// address itself lives only in the config, so a code change moves its
|
|
5
|
+
// URLs at the deploy with no write. Pure: both path index adapters join
|
|
6
|
+
// the address back on through here.
|
|
7
|
+
import { addressMap, codeAddresses } from "./addresses.js";
|
|
8
|
+
export function anchorsFor(input) {
|
|
9
|
+
return {
|
|
10
|
+
addresses: addressMap(codeAddresses({ locales: input.locales, pages: input.pages ?? [], collections: input.collections ?? [] })),
|
|
11
|
+
homePageSlug: input.homePageSlug ?? "home",
|
|
12
|
+
defaultLocale: input.locales.default,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** The URL an anchor's own record serves in a locale; the home page's is "/". Null when the config names no address there. */
|
|
16
|
+
export function anchorUrl(anchors, anchor, locale) {
|
|
17
|
+
const byLocale = anchors.addresses[anchor];
|
|
18
|
+
const address = byLocale?.[locale];
|
|
19
|
+
if (address === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
return byLocale[anchors.defaultLocale] === anchors.homePageSlug ? "/" : `/${address}`;
|
|
22
|
+
}
|
|
23
|
+
/** A stored row's full URL; null when its anchor has no address (a record no longer in code, a collection without `path`). */
|
|
24
|
+
export function fullPath(anchors, anchor, path, locale) {
|
|
25
|
+
if (anchor === "")
|
|
26
|
+
return path;
|
|
27
|
+
const base = anchorUrl(anchors, anchor, locale);
|
|
28
|
+
if (base === null)
|
|
29
|
+
return null;
|
|
30
|
+
if (path === "")
|
|
31
|
+
return base;
|
|
32
|
+
return base === "/" ? path : `${base}${path}`;
|
|
33
|
+
}
|
|
34
|
+
/** What a request URL may hit, best first: every code address it equals or lies below (the longest first), then the
|
|
35
|
+
* editors' tree. `coded`: the URL is code's, so a row without an anchor counts only as a redirect (spec §2). */
|
|
36
|
+
export function candidates(anchors, locale, url) {
|
|
37
|
+
const keys = [];
|
|
38
|
+
for (const anchor of Object.keys(anchors.addresses)) {
|
|
39
|
+
const base = anchorUrl(anchors, anchor, locale);
|
|
40
|
+
if (base === null)
|
|
41
|
+
continue;
|
|
42
|
+
if (url === base)
|
|
43
|
+
keys.push({ anchor, path: "" });
|
|
44
|
+
else if (base !== "/" && url.startsWith(`${base}/`))
|
|
45
|
+
keys.push({ anchor, path: url.slice(base.length) });
|
|
46
|
+
}
|
|
47
|
+
keys.sort((a, b) => a.path.length - b.path.length);
|
|
48
|
+
return { keys: [...keys, { anchor: "", path: url }], coded: keys.length > 0 };
|
|
49
|
+
}
|
|
50
|
+
/** The anchor a URL is served under: its longest code address, or `''` for the editors' tree (spec §2). A page
|
|
51
|
+
* whose own anchor (its chain root's owner key, `''` in the editors' tree) is another one is hidden there: code wins
|
|
52
|
+
* the address (spec §4). */
|
|
53
|
+
export const servedAnchor = (anchors, locale, url) => candidates(anchors, locale, url).keys[0].anchor;
|
|
54
|
+
/** Every stored key that yields the same full URL as (anchor, path): a row moving between the editors' tree and a
|
|
55
|
+
* code anchor keeps its URL, so it must be the same address to the rewrite (no redirect to itself). An anchor with
|
|
56
|
+
* no address has only its own key. */
|
|
57
|
+
export function sameUrlKeys(anchors, locale, anchor, path) {
|
|
58
|
+
const url = fullPath(anchors, anchor, path, locale);
|
|
59
|
+
return url === null ? [{ anchor, path }] : candidates(anchors, locale, url).keys;
|
|
60
|
+
}
|
|
61
|
+
/** The winning row among those found for `keys`, by key order (spec §2's precedence). */
|
|
62
|
+
export function pickHit(found, keys, coded) {
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
const row = found.find((r) => r.anchor === key.anchor && r.path === key.path);
|
|
65
|
+
if (!row)
|
|
66
|
+
continue;
|
|
67
|
+
if (coded && key.anchor === "" && !row.redirect)
|
|
68
|
+
continue;
|
|
69
|
+
return row;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
package/dist/cli/export.js
CHANGED
|
@@ -11,6 +11,7 @@ import { schemaBehind, schemaBehindMessage } from "../schema-version.js";
|
|
|
11
11
|
import { CONTENT_TABLES } from "../sql-space.js";
|
|
12
12
|
import { FORMAT, brokenReferences, countRows, describeExport, emptyTables, fromSpace, sortRows, } from "../space-transfer.js";
|
|
13
13
|
import { MEDIA_BUCKET, connectFromEnv, isNotFound, packageVersion, readSchemaVersion, readSpaceRows, schemaRefusal, } from "./space-io.js";
|
|
14
|
+
import { readCurrentSpace } from "../current-space.js";
|
|
14
15
|
export const defaultExportDir = (now) => `smoodly-export-${now.toISOString().slice(0, 10)}`;
|
|
15
16
|
export const folderExists = (dir) => `Refusing: ${dir} already exists. Remove it, or pass --out <dir>.`;
|
|
16
17
|
export const CHANGED_DURING_EXPORT = "Content changed during export — run it again.";
|
|
@@ -26,12 +27,13 @@ export async function runExport(args, out, deps = {}) {
|
|
|
26
27
|
out(folderExists(shown));
|
|
27
28
|
return 1;
|
|
28
29
|
}
|
|
29
|
-
const { db
|
|
30
|
+
const { db } = deps.connection ?? connectFromEnv(cwd);
|
|
30
31
|
const schema = await readSchemaVersion(db);
|
|
31
32
|
if ("missing" in schema) {
|
|
32
33
|
out(schemaRefusal(schema.missing, "Export"));
|
|
33
34
|
return 1;
|
|
34
35
|
}
|
|
36
|
+
const space = await readCurrentSpace(db);
|
|
35
37
|
if (schemaBehind(schema.version)) {
|
|
36
38
|
out(schemaBehindMessage(schema.version));
|
|
37
39
|
return 1;
|
package/dist/cli/import.d.ts
CHANGED
|
@@ -15,5 +15,6 @@ export declare const notAnExport: (dir: string) => string;
|
|
|
15
15
|
export declare const formatMismatch: (format: unknown) => string;
|
|
16
16
|
export declare const RUN_AGAIN = "Run the same command again to continue: rows and files that landed are skipped.";
|
|
17
17
|
export declare const MEMBERS_HEADING = "Members in the source \u2014 invite them in Settings \u203A Members:";
|
|
18
|
+
export declare const nothingNew: (space: string) => string;
|
|
18
19
|
export declare const CACHE_NOTE = "Restart or redeploy the app: it may have cached pages from while the space was empty.";
|
|
19
20
|
export declare function runImport(args: ImportArgs, out: (line: string) => void, deps?: ImportDeps): Promise<number>;
|
package/dist/cli/import.js
CHANGED
|
@@ -9,13 +9,15 @@ import { readFile } from "node:fs/promises";
|
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
import { MEDIA_CACHE_SECONDS } from "../media.js";
|
|
11
11
|
import { CONTENT_TABLES } from "../sql-space.js";
|
|
12
|
-
import { CONFLICT_COLUMNS, FORMAT, ROW_KEY, describeImport, emptyTables, foreignRows, insertPlan, intoSpace, notEmptyMessage, rowKey, versionMismatchMessage, } from "../space-transfer.js";
|
|
12
|
+
import { CONFLICT_COLUMNS, FORMAT, ROW_KEY, describeImport, emptyTables, foreignRows, insertPlan, intoSpace, missingRows, notEmptyMessage, rowKey, versionMismatchMessage, } from "../space-transfer.js";
|
|
13
13
|
import { MEDIA_BUCKET, connectFromEnv, isAlreadyExists, readSchemaVersion, readSpaceRows, schemaRefusal, } from "./space-io.js";
|
|
14
|
+
import { readCurrentSpace } from "../current-space.js";
|
|
14
15
|
export const BATCH_ROWS = 500;
|
|
15
16
|
export const notAnExport = (dir) => `Refusing: ${dir} is not a Smoodly export (no manifest.json).`;
|
|
16
17
|
export const formatMismatch = (format) => `Refusing: this export's format is ${String(format)}; this smoodly reads format ${FORMAT}. Update smoodly.`;
|
|
17
18
|
export const RUN_AGAIN = "Run the same command again to continue: rows and files that landed are skipped.";
|
|
18
19
|
export const MEMBERS_HEADING = "Members in the source — invite them in Settings › Members:";
|
|
20
|
+
export const nothingNew = (space) => `Nothing new: space ${space} already holds everything in this export.`;
|
|
19
21
|
export const CACHE_NOTE = "Restart or redeploy the app: it may have cached pages from while the space was empty.";
|
|
20
22
|
export async function runImport(args, out, deps = {}) {
|
|
21
23
|
const cwd = deps.cwd ?? process.cwd();
|
|
@@ -30,12 +32,13 @@ export async function runImport(args, out, deps = {}) {
|
|
|
30
32
|
out(formatMismatch(manifest.format));
|
|
31
33
|
return 1;
|
|
32
34
|
}
|
|
33
|
-
const { db
|
|
35
|
+
const { db } = deps.connection ?? connectFromEnv(cwd);
|
|
34
36
|
const schema = await readSchemaVersion(db);
|
|
35
37
|
if ("missing" in schema) {
|
|
36
38
|
out(schemaRefusal(schema.missing, "Import"));
|
|
37
39
|
return 1;
|
|
38
40
|
}
|
|
41
|
+
const space = await readCurrentSpace(db);
|
|
39
42
|
if (schema.version !== manifest.schemaVersion) {
|
|
40
43
|
out(versionMismatchMessage(schema.version, manifest.schemaVersion));
|
|
41
44
|
return 1;
|
|
@@ -52,6 +55,8 @@ export async function runImport(args, out, deps = {}) {
|
|
|
52
55
|
out(notEmptyMessage(foreign));
|
|
53
56
|
return 1;
|
|
54
57
|
}
|
|
58
|
+
const newRows = missingRows(target, rows);
|
|
59
|
+
let newFiles = 0;
|
|
55
60
|
try {
|
|
56
61
|
// Media first, so no ready asset row ever points at a missing file.
|
|
57
62
|
for (const asset of rows.assets) {
|
|
@@ -66,6 +71,8 @@ export async function runImport(args, out, deps = {}) {
|
|
|
66
71
|
});
|
|
67
72
|
if (up.error && !isAlreadyExists(up.error))
|
|
68
73
|
throw new Error(`uploading ${path}: ${up.error.message}`);
|
|
74
|
+
if (!up.error)
|
|
75
|
+
newFiles++;
|
|
69
76
|
}
|
|
70
77
|
const size = deps.batchSize ?? BATCH_ROWS;
|
|
71
78
|
for (const { table, rows: list } of insertPlan(rows)) {
|
|
@@ -83,6 +90,10 @@ export async function runImport(args, out, deps = {}) {
|
|
|
83
90
|
out(RUN_AGAIN);
|
|
84
91
|
return 1;
|
|
85
92
|
}
|
|
93
|
+
if (newRows === 0 && newFiles === 0) {
|
|
94
|
+
out(nothingNew(space));
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
86
97
|
out(`Imported ${describeImport(rows)} into space ${space}.`);
|
|
87
98
|
if (manifest.members.length > 0) {
|
|
88
99
|
out(MEMBERS_HEADING);
|
package/dist/cli/space-io.d.ts
CHANGED
|
@@ -3,14 +3,13 @@ import type { ContentTable } from "../sql-space.ts";
|
|
|
3
3
|
import { type Row } from "../space-transfer.ts";
|
|
4
4
|
export type SpaceConnection = {
|
|
5
5
|
db: SupabaseClient;
|
|
6
|
-
space: string;
|
|
7
6
|
};
|
|
8
7
|
export declare const MEDIA_BUCKET = "media";
|
|
9
8
|
/** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
|
|
10
9
|
export declare const PAGE_ROWS = 1000;
|
|
11
10
|
/** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). */
|
|
12
11
|
export declare function loadAppEnv(cwd: string, env?: Record<string, string | undefined>): void;
|
|
13
|
-
/** The app's content client
|
|
12
|
+
/** The app's content client, from its env. */
|
|
14
13
|
export declare function connectFromEnv(cwd: string): SpaceConnection;
|
|
15
14
|
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
16
15
|
export declare function readSchemaVersion(db: SupabaseClient): Promise<{
|