smoodly 0.0.62 → 0.0.63
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/dist/admin/client-ops.js +1 -1
- package/dist/admin/drift.js +3 -1
- package/dist/admin/editor/EditorView.js +65 -9
- package/dist/admin/editor/InsertPicker.d.ts +8 -27
- package/dist/admin/editor/InsertPicker.js +56 -47
- package/dist/admin/editor/SamplePreview.d.ts +7 -0
- package/dist/admin/editor/SamplePreview.js +86 -0
- package/dist/admin/editor/SectionIcon.d.ts +4 -0
- package/dist/admin/editor/SectionIcon.js +8 -0
- package/dist/admin/editor/StructureView.d.ts +2 -2
- package/dist/admin/editor/StructureView.js +2 -1
- package/dist/admin/editor/canvas-address.d.ts +2 -0
- package/dist/admin/editor/canvas-address.js +3 -1
- package/dist/admin/editor/picker-rows.d.ts +33 -0
- package/dist/admin/editor/picker-rows.js +38 -0
- package/dist/admin/editor/sample-frame.d.ts +36 -0
- package/dist/admin/editor/sample-frame.js +58 -0
- package/dist/admin/editor/sample-picks.d.ts +34 -0
- package/dist/admin/editor/sample-picks.js +49 -0
- package/dist/admin/editor/view-memory.d.ts +5 -0
- package/dist/admin/editor/view-memory.js +19 -0
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/forms/MediaWidget.d.ts +1 -1
- package/dist/admin/forms/MediaWidget.js +10 -2
- package/dist/admin/ops.d.ts +5 -0
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/GlobalList.js +1 -1
- package/dist/admin/shell/NavBar.js +1 -1
- package/dist/admin/shell/PagesList.js +4 -4
- package/dist/admin/shell/console-links.js +1 -1
- package/dist/admin/shell/health-list.js +1 -1
- package/dist/admin/tree-ops.d.ts +11 -4
- package/dist/admin/tree-ops.js +28 -17
- package/dist/admin/ui/theme.js +32 -13
- package/dist/admin/validate.js +3 -2
- package/dist/api-client.js +12 -2
- package/dist/astro/middleware.js +2 -0
- package/dist/astro/shell.d.ts +3 -0
- package/dist/astro/shell.js +8 -1
- package/dist/canvas-marker.d.ts +5 -0
- package/dist/canvas-marker.js +18 -6
- package/dist/conditions.js +5 -2
- package/dist/media.d.ts +15 -2
- package/dist/media.js +68 -10
- package/dist/next/page-renderer.js +12 -5
- package/dist/resolve.d.ts +6 -1
- package/dist/resolve.js +7 -7
- package/dist/sample-fill.d.ts +40 -0
- package/dist/sample-fill.js +145 -0
- package/dist/sample-tree.d.ts +30 -0
- package/dist/sample-tree.js +76 -0
- package/dist/site.d.ts +13 -0
- package/dist/site.js +10 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/content-modeling/sections.md +19 -3
- package/docs/content-modeling/zones.md +2 -2
- package/docs/getting-started/working-together.md +1 -1
- package/docs/globals/defining.md +1 -1
- package/docs/globals/header-and-footer.md +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type AssetRecord } from "../../media.ts";
|
|
3
3
|
import type { AdminField } from "../serialize.ts";
|
|
4
4
|
export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded, label, mark, hint, error: fieldError, path }: {
|
|
5
5
|
descriptor: AdminField;
|
|
@@ -8,9 +8,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
8
8
|
// actions in the field's head, right of the label (2026-09-25; the same
|
|
9
9
|
// row a richtext image card shows). The widget renders its own Field so
|
|
10
10
|
// the actions can sit in the head.
|
|
11
|
+
// A fresh section's image placeholder (spec 2026-11-14 filled samples) is shown for what it is: the picture the
|
|
12
|
+
// canvas draws, marked "Placeholder", with Choose image and Remove; a dropped file replaces it.
|
|
11
13
|
// The record comes from the assets context (cached per id); `asset`
|
|
12
14
|
// preloads it for static renders.
|
|
13
15
|
import { useEffect, useState } from "react";
|
|
16
|
+
import { isImagePlaceholder, placeholderImage } from "../../media.js";
|
|
14
17
|
import { Button, Field, UploadProgress } from "../ui/primitives.js";
|
|
15
18
|
import { acceptFor, formatBytes } from "../shell/media-list.js";
|
|
16
19
|
import { useAssets } from "./assets-context.js";
|
|
@@ -25,6 +28,7 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
25
28
|
const assets = useAssets();
|
|
26
29
|
const kind = descriptor.type;
|
|
27
30
|
const id = typeof value?.asset === "string" ? value.asset : null;
|
|
31
|
+
const placeholder = kind === "image" && isImagePlaceholder(value);
|
|
28
32
|
const [asset, setAsset] = useState(preloaded ?? null);
|
|
29
33
|
/** The fraction sent while a file uploads; null when none is. */
|
|
30
34
|
const [progress, setProgress] = useState(null);
|
|
@@ -69,7 +73,11 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
69
73
|
const accept = acceptFor([kind], assets.media.accept);
|
|
70
74
|
const fileInput = (label, variant = "secondary") => (_jsxs("label", { className: `sm-btn sm-btn--${variant}`, children: [progress !== null ? `Uploading… ${Math.round(progress * 100)}%` : label, _jsx("input", { type: "file", accept: accept, hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] }));
|
|
71
75
|
let body;
|
|
72
|
-
if (
|
|
76
|
+
if (placeholder) {
|
|
77
|
+
const picture = placeholderImage(descriptor);
|
|
78
|
+
body = (_jsxs("div", { className: "sm-media", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: [_jsx("div", { className: "sm-media__canvas", children: _jsxs("div", { className: "sm-media__thumb sm-media__thumb--placeholder", style: { aspectRatio: `${picture.width} / ${picture.height}`, width: `calc(${CANVAS_H}px * ${picture.width / picture.height})` }, children: [_jsx("img", { src: picture.url, alt: "" }), progress !== null && _jsx(UploadProgress, { fraction: progress })] }) }), _jsx("span", { className: "sm-media__alt", children: "Placeholder: shown in the editor only" })] }));
|
|
79
|
+
}
|
|
80
|
+
else if (!id) {
|
|
73
81
|
body = (_jsx("div", { className: "sm-dropzone", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: progress !== null ? _jsx(UploadProgress, { fraction: progress }) : _jsxs(_Fragment, { children: [_jsx("span", { children: "Drop a file here" }), _jsxs("div", { className: "sm-media__actions", children: [fileInput("Upload", "primary"), _jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Choose from library" })] })] }) }));
|
|
74
82
|
}
|
|
75
83
|
else if (!asset) {
|
|
@@ -87,6 +95,6 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
87
95
|
body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" })] }));
|
|
88
96
|
}
|
|
89
97
|
// Edit only for an image that has its dimensions (the crop editor's frame needs them).
|
|
90
|
-
const actions = id && asset ? (_jsxs("span", { className: "sm-field__actions", children: [asset.kind === "image" && asset.width && asset.height && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" })), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : undefined;
|
|
98
|
+
const actions = placeholder ? (_jsxs("span", { className: "sm-field__actions", children: [_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Choose image" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : id && asset ? (_jsxs("span", { className: "sm-field__actions", children: [asset.kind === "image" && asset.width && asset.height && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCrop(true), children: "Edit" })), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => setPicker(true), children: "Replace" }), _jsx("button", { type: "button", className: "sm-field__action", onClick: () => onChange(undefined), children: "Remove" })] })) : undefined;
|
|
91
99
|
return (_jsxs(Field, { label: label, mark: mark, hint: hint, error: fieldError, path: path, action: actions, asDiv: true, children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
|
|
92
100
|
}
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { EntryRecord, EntryUsage } from "../entry-store.ts";
|
|
|
4
4
|
import type { AssetRecord, AssetMeta } from "../media.ts";
|
|
5
5
|
import type { AssetPage, AssetQuery } from "../asset-store.ts";
|
|
6
6
|
import type { MemberRecord } from "../member-store.ts";
|
|
7
|
+
import type { SamplePicks } from "../sample-fill.ts";
|
|
7
8
|
import type { MemberRole } from "../permissions.ts";
|
|
8
9
|
import type { FieldError } from "./validate.ts";
|
|
9
10
|
import type { DeployState } from "../deploy-state.ts";
|
|
@@ -217,6 +218,10 @@ export type AdminOps = {
|
|
|
217
218
|
entries: EntryRecord[];
|
|
218
219
|
total: number;
|
|
219
220
|
}>>;
|
|
221
|
+
/** The reference picks of a fresh node (spec 2026-11-14 filled samples): for each named collection the ids of its
|
|
222
|
+
* first three PUBLISHED entries in `locale`, as the site's `getEntries(collection, { locale, limit: 3 })` returns
|
|
223
|
+
* them, so an insert references what the picker's picture showed. A name that is no collection is left out. */
|
|
224
|
+
samplePicks(collections: string[], locale: string): Promise<OpResult<SamplePicks>>;
|
|
220
225
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
221
226
|
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
222
227
|
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
|
|
@@ -391,7 +391,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
391
391
|
};
|
|
392
392
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
393
393
|
{
|
|
394
|
-
label: "
|
|
394
|
+
label: "Globals", href: adminHref("/globals"),
|
|
395
395
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
396
396
|
},
|
|
397
397
|
{ label: item.title },
|
|
@@ -38,7 +38,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
38
38
|
if (error)
|
|
39
39
|
return _jsx(ErrorPane, { message: error });
|
|
40
40
|
const rows = items ? globalRows(items, registry, locale) : [];
|
|
41
|
-
return (_jsx(ListView, { title: "
|
|
41
|
+
return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
|
|
42
42
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
43
43
|
{ label: "Kind", width: "90px" },
|
|
44
44
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
@@ -106,7 +106,7 @@ export function NavBar({ registry, active, email, settings = true, deploy, onSig
|
|
|
106
106
|
const collectionsOpen = open === "collections" || (hovered && open === null);
|
|
107
107
|
// What ⋮ holds, in the rail's order.
|
|
108
108
|
const more = [
|
|
109
|
-
...(registry.globals.length > 0 ? [{ key: "globals", label: "
|
|
109
|
+
...(registry.globals.length > 0 ? [{ key: "globals", label: "Globals", icon: _jsx(LayoutPanelTop, { ...ICON }), href: adminHref("/globals") }] : []),
|
|
110
110
|
...(settings ? [{ key: "settings", label: "Settings", icon: _jsx(Settings, { ...ICON }), href: adminHref("/settings") }] : []),
|
|
111
111
|
];
|
|
112
112
|
const moreActive = more.some((m) => m.key === active);
|
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
10
10
|
import { useCallback, useEffect, useState } from "react";
|
|
11
11
|
import { ListView } from "./ListView.js";
|
|
12
12
|
import { canDragRow, codeAddressNote, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
|
|
13
|
-
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
13
|
+
import { Button, StatusBadge, cx } from "../ui/primitives.js";
|
|
14
14
|
import { statusLabel } from "../ui/status-label.js";
|
|
15
15
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
16
16
|
import { useSortable } from "../ui/useSortable.js";
|
|
@@ -238,7 +238,7 @@ export function PagesList({ ops, registry }) {
|
|
|
238
238
|
// A record no longer in code (spec 2026-09-29 nested paths §5): deleting it is all that is open.
|
|
239
239
|
if (row.orphan) {
|
|
240
240
|
if (!row.hasChildren)
|
|
241
|
-
items.push({ label: "Delete", run: () => destroy(row) });
|
|
241
|
+
items.push({ label: "Delete", run: () => destroy(row), danger: true });
|
|
242
242
|
return items;
|
|
243
243
|
}
|
|
244
244
|
if (!row.present) {
|
|
@@ -262,7 +262,7 @@ export function PagesList({ ops, registry }) {
|
|
|
262
262
|
items.push({ label: `Remove from ${locale}`, run: () => removeLocale(row) });
|
|
263
263
|
}
|
|
264
264
|
if (!row.fixed && !row.isHome && !row.hasChildren)
|
|
265
|
-
items.push({ label: "Delete", run: () => destroy(row) });
|
|
265
|
+
items.push({ label: "Delete", run: () => destroy(row), danger: true });
|
|
266
266
|
return items;
|
|
267
267
|
};
|
|
268
268
|
// A mount row is a page like any other when a fixed page claims the
|
|
@@ -322,7 +322,7 @@ export function PagesList({ ops, registry }) {
|
|
|
322
322
|
position: "absolute",
|
|
323
323
|
left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
|
|
324
324
|
top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
|
|
325
|
-
}, 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)
|
|
325
|
+
}, children: [items.map((item) => (_jsx("button", { type: "button", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), 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)
|
|
326
326
|
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)
|
|
327
327
|
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)
|
|
328
328
|
closeDialog(); return message; }) })), dialog?.kind === "duplicate" && (_jsx(PageFormDialog, { title: "Duplicate page", submitLabel: "Duplicate", fields: { title: true, slug: true }, initial: { title: `${dialog.row.title} copy`, slug: `${dialog.row.record.locales[locale]?.slug ?? ""}-copy` }, parentPath: dialog.row.isHome ? "/" : parentPathOf(dialog.row.path), onClose: closeDialog, onSubmit: (values) => submitDialog(values).then((message) => { if (message === null)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// (this site, the docs; 2026-10-23), the account menu's "My Account" and the Members page's "Manage members at
|
|
3
3
|
// smoodly.io". The dashboard links are deep links into the console the service names (`GET /v1/auth`'s consoleUrl),
|
|
4
4
|
// set once by the entry from the page's data; the docs are smoodly.io itself.
|
|
5
|
-
let console = { url: "https://smoodly.io", spaceId: "" };
|
|
5
|
+
let console = { url: "https://app.smoodly.io", spaceId: "" };
|
|
6
6
|
export function setConsole(url, spaceId) {
|
|
7
7
|
console = { url: url.replace(/\/+$/, ""), spaceId };
|
|
8
8
|
}
|
|
@@ -2,7 +2,7 @@ import { SEVERITY, groupKey } from "../drift.js";
|
|
|
2
2
|
import { driftHeadline } from "../drift-copy.js";
|
|
3
3
|
/** A group shows this many rows, then "Show all N". */
|
|
4
4
|
export const GROUP_PREVIEW = 10;
|
|
5
|
-
const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "
|
|
5
|
+
const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Globals" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
|
|
6
6
|
const href = (doc, locale) => {
|
|
7
7
|
const q = `?locale=${encodeURIComponent(locale)}`;
|
|
8
8
|
if (doc.kind === "page")
|
package/dist/admin/tree-ops.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PageTree, TreeNode } from "../page-context.ts";
|
|
2
2
|
import type { ZonePolicy } from "../zones.ts";
|
|
3
3
|
import type { AdminSection, AdminGlobal } from "./serialize.ts";
|
|
4
|
+
import { type FreshOptions } from "../sample-fill.ts";
|
|
4
5
|
export declare function zoneOf(tree: PageTree, id: string): string | null;
|
|
5
6
|
export declare function findNode(tree: PageTree, id: string): TreeNode | null;
|
|
6
7
|
export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
|
|
@@ -24,15 +25,21 @@ export declare function allowedGlobals(policy: ZonePolicy | undefined, globals:
|
|
|
24
25
|
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
25
26
|
* names the registration, `ref` is the item's row id. */
|
|
26
27
|
export declare function placementNode(item: AdminGlobal, ref: string, id: string): TreeNode;
|
|
27
|
-
/**
|
|
28
|
-
|
|
28
|
+
/** The one builder of a fresh node (spec 2026-11-14 filled samples): the insert, a locked zone, a richtext element and
|
|
29
|
+
* the picker's sample tree all take it from here, so the picture is the insert. Per field: the `sample` (taken whole,
|
|
30
|
+
* nothing generated on it or inside it), then the `default`, a required choice's first option and a boolean's false,
|
|
31
|
+
* then the generated value (sample-fill.ts), then the empty value by type. `empty` skips the sample and the generated
|
|
32
|
+
* value. Settings are choices: sampled or defaulted, never generated.
|
|
33
|
+
* Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
|
|
34
|
+
export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fields" | "settings" | "sample">, id: string, options?: FreshOptions): TreeNode;
|
|
29
35
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
30
36
|
* remove — so someone has to put it there. This does, from the component's
|
|
31
37
|
* sample (or, for a zone bound to a global item, a placement), for every
|
|
32
38
|
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
33
39
|
* which also covers pages that predate a locked zone being added to their
|
|
34
|
-
* schema. Returns the same tree when nothing changed.
|
|
35
|
-
|
|
40
|
+
* schema. Returns the same tree when nothing changed. `fresh` carries the
|
|
41
|
+
* space's reference picks and whether the service takes an image placeholder. */
|
|
42
|
+
export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], globalIds?: Record<string, string>, fresh?: FreshOptions): PageTree;
|
|
36
43
|
export declare function newNodeId(type: string): string;
|
|
37
44
|
/** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
|
|
38
45
|
export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
|
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { GLOBAL_NODE_TYPE } from "../global.js";
|
|
2
2
|
import { setAtPath, unsetAtPath } from "./field-paths.js";
|
|
3
|
-
import {
|
|
3
|
+
import { firstChoice, generated, starting } from "../sample-fill.js";
|
|
4
4
|
export function zoneOf(tree, id) {
|
|
5
5
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
6
6
|
if (nodes.some((n) => n.id === id))
|
|
@@ -119,32 +119,41 @@ export function placementNode(item, ref, id) {
|
|
|
119
119
|
/** The empty value a fresh node's field starts at, by descriptor type.
|
|
120
120
|
* Shape matters: a list-valued field seeded with "" is a type lie that
|
|
121
121
|
* crashes the first section view that maps over it. */
|
|
122
|
-
const EMPTY_BY_TYPE = { refList: [], object: {} };
|
|
122
|
+
const EMPTY_BY_TYPE = { refList: () => [], list: () => [], tags: () => [], checkboxes: () => [], object: () => ({}) };
|
|
123
123
|
/** Media/number fields have no honest empty value — "" renders as a
|
|
124
|
-
* broken-image icon or a stray zero, worse than nothing at all. With
|
|
125
|
-
*
|
|
124
|
+
* broken-image icon or a stray zero, worse than nothing at all. With
|
|
125
|
+
* nothing to start from, leave the key out of `fields` entirely rather than seed a lie. */
|
|
126
126
|
const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
/** What the section's `sample` sets for a key: an empty array counts as left out (spec 2026-11-14 filled samples),
|
|
128
|
+
* since "start with nothing" for a list is what leaving it out already means. */
|
|
129
|
+
const sampledOf = (sample, key) => {
|
|
130
|
+
const v = sample?.[key];
|
|
131
|
+
return Array.isArray(v) && v.length === 0 ? undefined : v;
|
|
132
|
+
};
|
|
133
|
+
/** The one builder of a fresh node (spec 2026-11-14 filled samples): the insert, a locked zone, a richtext element and
|
|
134
|
+
* the picker's sample tree all take it from here, so the picture is the insert. Per field: the `sample` (taken whole,
|
|
135
|
+
* nothing generated on it or inside it), then the `default`, a required choice's first option and a boolean's false,
|
|
136
|
+
* then the generated value (sample-fill.ts), then the empty value by type. `empty` skips the sample and the generated
|
|
137
|
+
* value. Settings are choices: sampled or defaulted, never generated.
|
|
138
|
+
* Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
|
|
139
|
+
export function nodeFromSample(section, id, options = {}) {
|
|
132
140
|
const fields = {};
|
|
133
141
|
const settings = {};
|
|
142
|
+
const sample = options.empty ? undefined : section.sample;
|
|
134
143
|
for (const [key, d] of Object.entries(section.fields)) {
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
fields[key] =
|
|
144
|
+
const value = sampledOf(sample, key) ?? starting(d) ?? (options.empty ? undefined : generated(key, d, options));
|
|
145
|
+
if (value !== undefined)
|
|
146
|
+
fields[key] = value;
|
|
138
147
|
else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
|
|
139
148
|
continue;
|
|
140
149
|
// An optional object with no sample is absent: `{}` would read as present, its members required at publish.
|
|
141
150
|
else if (d.type === "object" && d.optional === true)
|
|
142
151
|
continue;
|
|
143
152
|
else
|
|
144
|
-
fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
|
|
153
|
+
fields[key] = EMPTY_BY_TYPE[d.type]?.() ?? "";
|
|
145
154
|
}
|
|
146
155
|
for (const [key, d] of Object.entries(section.settings)) {
|
|
147
|
-
const v =
|
|
156
|
+
const v = sampledOf(sample, key) ?? d.default ?? firstChoice(d);
|
|
148
157
|
if (v !== undefined)
|
|
149
158
|
settings[key] = v;
|
|
150
159
|
}
|
|
@@ -155,8 +164,9 @@ export function nodeFromSample(section, id) {
|
|
|
155
164
|
* sample (or, for a zone bound to a global item, a placement), for every
|
|
156
165
|
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
157
166
|
* which also covers pages that predate a locked zone being added to their
|
|
158
|
-
* schema. Returns the same tree when nothing changed.
|
|
159
|
-
|
|
167
|
+
* schema. Returns the same tree when nothing changed. `fresh` carries the
|
|
168
|
+
* space's reference picks and whether the service takes an image placeholder. */
|
|
169
|
+
export function fillLockedZones(tree, policies, sections, globalIds = {}, fresh) {
|
|
160
170
|
let next = null;
|
|
161
171
|
for (const [zone, policy] of Object.entries(policies)) {
|
|
162
172
|
if (policy.kind !== "locked")
|
|
@@ -177,7 +187,8 @@ export function fillLockedZones(tree, policies, sections, globalIds = {}) {
|
|
|
177
187
|
const section = sections.find((s) => s.name === policy.component);
|
|
178
188
|
if (!section)
|
|
179
189
|
continue;
|
|
180
|
-
node
|
|
190
|
+
// Always the filled node (spec 2026-11-14 filled samples): a locked zone has no picker and no switch.
|
|
191
|
+
node = nodeFromSample(section, newNodeId(section.name), { ...fresh, empty: false });
|
|
181
192
|
}
|
|
182
193
|
next ?? (next = { ...tree, zones: { ...tree.zones } });
|
|
183
194
|
next.zones[zone] = [node];
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -26,7 +26,8 @@ const TOKENS_LIGHT = `
|
|
|
26
26
|
/* The shell: navy frame, white sheet, grey tiles on the sheet, white fields on the tiles. */
|
|
27
27
|
--frame:#151727; --sheet:${SHEET_LIGHT};
|
|
28
28
|
--bg:var(--sheet); --canvas:#ECEDF1; --surface:#F6F6F6; --surface-form:#F6F6F6; --surface-inset:#FFFFFF;
|
|
29
|
-
--menu-bg:#FFFFFF;
|
|
29
|
+
--menu-bg:#FFFFFF; --menu-hover:var(--hover);
|
|
30
|
+
--avatar-hover:color-mix(in srgb,var(--accent-soft),#fff 60%);
|
|
30
31
|
--input-bg:#FFFFFF; --input-hover-bg:#FFFFFF; --input-focus-bg:#FFFFFF; --input-border:var(--line);
|
|
31
32
|
--line:#E4E5EA; --line-dashed:#CFD1D9; --line-soft:#ECEDF1;
|
|
32
33
|
--text:#151727; --text-secondary:#454A5C; --text-muted:#6B6F80; --text-subtle:#9A9EAB; --text-disabled:#C5C8D2;
|
|
@@ -61,6 +62,7 @@ const TOKENS_LIGHT = `
|
|
|
61
62
|
--status-saved-dot:var(--green-500);
|
|
62
63
|
--warn:var(--amber-700); --warn-soft:var(--amber-50);
|
|
63
64
|
--danger:var(--red-700); --danger-soft:var(--red-50);
|
|
65
|
+
--destructive-bg:var(--red-700); /* the filled destructive button: white text on it in both themes (2026-11-14) */
|
|
64
66
|
|
|
65
67
|
/* The edited page inside the canvas stays light in both themes. */
|
|
66
68
|
--page-bg:#FFFFFF; --page-text:#151727;
|
|
@@ -80,11 +82,11 @@ const TOKENS_LIGHT = `
|
|
|
80
82
|
and sheet a step darker, tiles a step lighter, fields in the sheet tone;
|
|
81
83
|
the bar and the logo unchanged. */
|
|
82
84
|
const TOKENS_DARK = `
|
|
83
|
-
--accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --accent-line:#6CC4A6; --on-accent:#151727;
|
|
85
|
+
--accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --avatar-hover:rgba(108,196,166,.26); /* a step up from accent-soft: white over the tint read grey (2026-11-14) */ --accent-line:#6CC4A6; --on-accent:#151727;
|
|
84
86
|
--selection-global:#F09A5C;
|
|
85
87
|
--frame:#0F1120; --sheet:${SHEET_DARK};
|
|
86
88
|
--canvas:#131526; --surface:#242840; --surface-form:#242840; --surface-inset:#1B1E30;
|
|
87
|
-
--menu-bg:#242840;
|
|
89
|
+
--menu-bg:#242840; --menu-hover:#2A2E47; /* a step off the menu, not the brighter --hover (2026-11-14) */
|
|
88
90
|
/* A field's edge is drawn in dark: a dialog's fields take the card's own tone there (2026-11-13). */
|
|
89
91
|
--input-bg:#1B1E30; --input-hover-bg:#1B1E30; --input-focus-bg:#1B1E30; --input-border:var(--line-dashed);
|
|
90
92
|
--line:#33384F; --line-dashed:#444A63; --line-soft:#33384F;
|
|
@@ -126,7 +128,7 @@ const SCALE = `
|
|
|
126
128
|
|
|
127
129
|
--radius-xs:2px; --radius-sm:6px; --radius-md:8px; --radius-lg:10px;
|
|
128
130
|
--radius-card:16px; --radius-card-lg:16px; --radius-modal:20px; --radius-btn:999px; --radius-round:999px;
|
|
129
|
-
--page-radius:
|
|
131
|
+
--page-radius:0; --selection-radius:2px; /* the canvas hover/selection ring: 2px (asked for 2026-09-26; 8px before) */
|
|
130
132
|
--outline-row-radius:6px; --tabs-radius:10px; --tab-radius:6px; --row-radius:2px; --tag-radius:999px; /* list row hover fill: 2px (same day; 10px before) */
|
|
131
133
|
--plus-radius:8px; --toolbar-radius:8px;
|
|
132
134
|
--rtcard-bg:var(--surface); --rtcard-border:var(--line);
|
|
@@ -135,10 +137,10 @@ const SCALE = `
|
|
|
135
137
|
--plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
|
|
136
138
|
--inspector-w:360px; --gutter:20px;
|
|
137
139
|
/* The frame is 8px left of, right of and below the sheet (6px in the handoff, +2px 2026-10-23); the bar fills the top edge. */
|
|
138
|
-
--frame-pad:8px; --shell-gap:0px; --sheet-radius:
|
|
140
|
+
--frame-pad:8px; --shell-gap:0px; --sheet-radius:12px; --sheet-pad:0 24px 24px;
|
|
139
141
|
--navbar-h:64px; --navbar-pad-x:12px; --navbar-item-h:40px; --navbar-item-radius:10px;
|
|
140
142
|
--topbar-pad:0; --back-size:32px; --back-bg:var(--surface);
|
|
141
|
-
--table-pad:8px 12px; --table-top:80px; --canvas-pad:28px
|
|
143
|
+
--table-pad:8px 12px; --table-top:80px; --canvas-pad:28px 28px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
|
|
142
144
|
--border:1px solid var(--line);
|
|
143
145
|
--outline-selection:2px solid var(--selection);
|
|
144
146
|
--outline-selection-global:2px solid var(--selection-global);
|
|
@@ -220,7 +222,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
220
222
|
width:32px;height:32px;border-radius:var(--radius-round);background:var(--accent-soft);color:var(--accent);
|
|
221
223
|
display:grid;place-items:center;font-size:var(--text-xs);font-weight:600;border:0;cursor:pointer;padding:0;transition:background .15s;
|
|
222
224
|
}
|
|
223
|
-
.sm-navbar__user:hover{background:
|
|
225
|
+
.sm-navbar__user:hover{background:var(--avatar-hover)}
|
|
224
226
|
.sm-navbar__user:focus-visible{outline:2px solid var(--navbar-active-fg);outline-offset:2px;box-shadow:none}
|
|
225
227
|
.sm-navbar__ext{width:13px;height:13px;flex:none;margin-left:12px;opacity:.45}
|
|
226
228
|
.sm-navbar__menu-item:hover .sm-navbar__ext,.sm-navbar__menu-item:focus-visible .sm-navbar__ext{opacity:.8}
|
|
@@ -272,7 +274,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
272
274
|
appearance:none;background:none;border:0;width:100%;display:flex;align-items:center;justify-content:space-between;
|
|
273
275
|
padding:7px 10px;border-radius:var(--radius-sm);color:inherit;font:inherit;cursor:pointer;text-align:left;text-decoration:none;
|
|
274
276
|
}
|
|
275
|
-
.sm-navbar__menu-item:hover,.sm-navbar__menu-item:focus-visible{background:var(--hover);color:inherit;outline:0;box-shadow:none}
|
|
277
|
+
.sm-navbar__menu-item:hover,.sm-navbar__menu-item:focus-visible{background:var(--menu-hover);color:inherit;outline:0;box-shadow:none}
|
|
276
278
|
.sm-navbar__menu-item[aria-current="page"],.sm-navbar__menu-item[aria-current="page"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}
|
|
277
279
|
.sm-navbar__menu-label{display:flex;align-items:center;gap:10px;min-width:0}
|
|
278
280
|
.sm-navbar__menu-label>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -336,7 +338,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
336
338
|
.sm-btn--danger{background:var(--btn-secondary-bg);color:var(--danger)}
|
|
337
339
|
.sm-btn--danger:hover{background:var(--danger-soft);color:var(--danger)}
|
|
338
340
|
/* The confirm dialog's destructive primary: filled red (ConfirmModal.html). */
|
|
339
|
-
.sm-btn--destructive{background:var(--
|
|
341
|
+
.sm-btn--destructive{background:var(--destructive-bg);color:#fff;font-weight:600;padding:0 18px}
|
|
340
342
|
.sm-btn--destructive:hover{filter:brightness(.94);color:#fff}
|
|
341
343
|
.sm-btn--ok{background:var(--status-published-bg);color:var(--status-published-fg);font-weight:600;padding:0 18px}
|
|
342
344
|
.sm-btn:disabled,.sm-btn[aria-disabled="true"]{opacity:.5;cursor:default;pointer-events:none}
|
|
@@ -455,12 +457,12 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
455
457
|
.sm-richtext__content ul,.sm-richtext__content ol{padding-left:20px}
|
|
456
458
|
.sm-richtext__content li+li{margin-top:.25em}
|
|
457
459
|
.sm-richtext__body{position:relative}
|
|
458
|
-
/* The "+" menu
|
|
460
|
+
/* The "+" menu hangs right under the toolbar's right end, no gap (2026-11-14; the "+" is its last
|
|
459
461
|
button) instead of pushing the content down; the picker inside drops
|
|
460
462
|
its own chrome. The slash menu floats under the caret the same way. */
|
|
461
|
-
.sm-richtext__head{position:relative}
|
|
463
|
+
.sm-richtext__head{position:relative;width:fit-content;max-width:100%} /* as wide as the toolbar, so the menu hangs from the "+" (2026-11-14) */
|
|
462
464
|
.sm-richtext__menu{
|
|
463
|
-
position:absolute;top:
|
|
465
|
+
position:absolute;top:100%;right:0;z-index:65;width:300px;max-width:calc(100vw - 32px);
|
|
464
466
|
display:flex;flex-direction:column;gap:6px;padding:6px;
|
|
465
467
|
background:var(--menu-bg);border:var(--menu-border);border-radius:var(--radius-md);box-shadow:var(--shadow-pop);
|
|
466
468
|
}
|
|
@@ -564,7 +566,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
564
566
|
.sm-listnotice:empty{display:none}
|
|
565
567
|
/* The rows sit in a centred column of at most 1200px inside the card,
|
|
566
568
|
under --table-top of air reserved for the coming search and filters. */
|
|
567
|
-
.sm-table{display:flex;flex-direction:column;padding:var(--table-pad);padding-top:var(--table-top);max-width:1200px;margin:0 auto}
|
|
569
|
+
.sm-table{display:flex;flex-direction:column;padding:var(--table-pad);padding-top:var(--table-top);padding-bottom:100px;max-width:1200px;margin:0 auto}
|
|
568
570
|
/* The filter sits in that air, so the column heads stay where they are on a list without one (2026-11-13). */
|
|
569
571
|
.sm-table--tools{padding-top:20px}
|
|
570
572
|
.sm-table__tools{display:flex;align-items:center;gap:12px;height:40px;margin-bottom:20px;padding:0 8px}
|
|
@@ -963,6 +965,22 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
963
965
|
width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
|
|
964
966
|
color:var(--accent);display:grid;place-items:center;font-size:13px;
|
|
965
967
|
}
|
|
968
|
+
/* The insert picker as a modal (spec 2026-11-14 add section previews): the rows keep the picker's item styles in a
|
|
969
|
+
plain column; beside them the current row as the site draws it, in one scaled, cropped frame. */
|
|
970
|
+
.sm-insert{display:flex;flex:1;min-height:0}
|
|
971
|
+
.sm-insert__list{width:auto;flex:1;min-width:0;overflow-y:auto;background:transparent;border:0;border-radius:0;box-shadow:none;padding:8px}
|
|
972
|
+
.sm-insert__side{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column}
|
|
973
|
+
.sm-insert--pane .sm-insert__side{flex:none;width:300px;border-right:var(--card-divider)}
|
|
974
|
+
.sm-insert__foot{flex:none;display:flex;align-items:center;gap:8px;padding:10px 16px;border-top:var(--card-divider);font-size:var(--text-sm);color:var(--text-muted);cursor:pointer}
|
|
975
|
+
/* The preview pane is a small canvas (2026-11-14): the canvas tone, inset from the dialog's edges, its corners rounded. */
|
|
976
|
+
.sm-insert__pane{flex:1;min-width:0;display:flex;flex-direction:column;gap:12px;padding:20px;margin:0 16px 16px 0;border-radius:12px;background:var(--canvas)}
|
|
977
|
+
.sm-insert__preview{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center;overflow:hidden;padding:4px 4px 10px} /* room for the crop's shadow */
|
|
978
|
+
.sm-insert__crop{position:relative;width:100%;overflow:hidden;border-radius:var(--radius-md);max-height:100%}
|
|
979
|
+
/* A hairline and a small shadow keep a light section off a light pane. */
|
|
980
|
+
.sm-insert__crop--shown{background:#fff;box-shadow:0 0 0 1px rgba(21,23,39,.1),0 2px 8px rgba(21,23,39,.1)}
|
|
981
|
+
.sm-insert__fallback{flex:1;display:grid;place-items:center;border-radius:var(--radius-md);border:1px dashed var(--line)}
|
|
982
|
+
.sm-insert__icon{width:72px;height:72px;border-radius:var(--radius-md);background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:32px}
|
|
983
|
+
.sm-insert__caption{flex:none;min-height:40px}
|
|
966
984
|
/* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it —
|
|
967
985
|
portaled and fixed at the input's width (--combo-w), above modals and below
|
|
968
986
|
the confirm scrim (2026-09-27); inline under the input in static markup. */
|
|
@@ -1114,6 +1132,7 @@ html:has(.sm-admin),body:has(.sm-admin){overscroll-behavior:none}
|
|
|
1114
1132
|
}
|
|
1115
1133
|
.sm-media__thumb{position:relative;overflow:hidden;max-width:100%;max-height:300px;background:var(--surface-inset)}
|
|
1116
1134
|
.sm-media__thumb>img,.sm-media__thumb>video{position:absolute;max-width:none;display:block}
|
|
1135
|
+
.sm-media__thumb--placeholder>img{inset:0;width:100%;height:100%}
|
|
1117
1136
|
.sm-media__alt{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1118
1137
|
.sm-media>.sm-media__alt{text-align:center}
|
|
1119
1138
|
.sm-media__actions{display:flex;gap:6px}
|
package/dist/admin/validate.js
CHANGED
|
@@ -15,7 +15,7 @@ import { isCalendarDay, isInstant } from "./forms/date-picker.js";
|
|
|
15
15
|
import { fits, misfitMessage } from "./drift.js";
|
|
16
16
|
import { isSegment } from "../paths.js";
|
|
17
17
|
import { safeHref } from "../richtext-href.js";
|
|
18
|
-
import { isCrop } from "../media.js";
|
|
18
|
+
import { isCrop, isImagePlaceholder } from "../media.js";
|
|
19
19
|
/** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
|
|
20
20
|
export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
|
|
21
21
|
/** A link field takes only what the site renders (richtext-href.ts's rule): the widget says so as the editor types,
|
|
@@ -36,7 +36,8 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
36
36
|
// An optional object with nothing filled in is absent (drafts inserted before 2026-09-26 carry `{}`).
|
|
37
37
|
if (d.type === "object" && d.optional === true && isBlankObject(value))
|
|
38
38
|
return;
|
|
39
|
-
|
|
39
|
+
// An image placeholder is unset to publish (spec 2026-11-14 filled samples): Required on a required image.
|
|
40
|
+
if (isEmpty(value) || (d.type === "image" && isImagePlaceholder(value))) {
|
|
40
41
|
if (!mayBeAbsent(d))
|
|
41
42
|
errors.push({ field: path, message: "Required." });
|
|
42
43
|
return;
|
package/dist/api-client.js
CHANGED
|
@@ -29,7 +29,8 @@ export function createSmoodlyApi(options) {
|
|
|
29
29
|
* server pushes its schema to the space it reads, so the developer must see when that space is production. */
|
|
30
30
|
let pushed = null;
|
|
31
31
|
const pushOnce = (body) => (pushed ?? (pushed = pushSchema({ apiUrl, spaceId: options.spaceId, key: options.key, fetch: doFetch }, body).then((result) => { const line = pushLine(options.spaceId, result); console.log(`smoodly: ${line[0].toLowerCase()}${line.slice(1)}`); }, (e) => console.error(`smoodly: the schema push failed — ${e instanceof Error ? e.message : String(e)}; the site runs against the schema the space has.`))));
|
|
32
|
-
|
|
32
|
+
/** `send`: a read that carries a body (the picker's samples) goes as a POST; every other read is a GET. */
|
|
33
|
+
async function call(path, params, send) {
|
|
33
34
|
if (push)
|
|
34
35
|
await pushOnce(push);
|
|
35
36
|
countRead();
|
|
@@ -39,7 +40,9 @@ export function createSmoodlyApi(options) {
|
|
|
39
40
|
url.searchParams.set(k, v);
|
|
40
41
|
let res;
|
|
41
42
|
try {
|
|
42
|
-
res = await doFetch(url.toString(),
|
|
43
|
+
res = await doFetch(url.toString(), send === undefined
|
|
44
|
+
? { headers: { authorization: `Bearer ${options.key}`, accept: "application/json" } }
|
|
45
|
+
: { method: "POST", headers: { authorization: `Bearer ${options.key}`, accept: "application/json", "content-type": "application/json" }, body: JSON.stringify(send) });
|
|
43
46
|
}
|
|
44
47
|
catch (e) {
|
|
45
48
|
throw new TypeError(`smoodly: can't reach SMOODLY_API_URL (${apiUrl}): ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -60,6 +63,8 @@ export function createSmoodlyApi(options) {
|
|
|
60
63
|
styles: statics.styles,
|
|
61
64
|
custom: statics.custom,
|
|
62
65
|
codePage: statics.codePage,
|
|
66
|
+
sampleTree: statics.sampleTree,
|
|
67
|
+
sampleCollections: statics.sampleCollections,
|
|
63
68
|
async locate(locale, path) {
|
|
64
69
|
return (await call("/locate", { locale, path })).data;
|
|
65
70
|
},
|
|
@@ -71,6 +76,11 @@ export function createSmoodlyApi(options) {
|
|
|
71
76
|
const r = await call("/load", { kind: target.kind, id: target.id, locale, path, draft: o?.draft ? "1" : undefined, version: o?.version });
|
|
72
77
|
return withTags(r.data, r.tags, draft);
|
|
73
78
|
},
|
|
79
|
+
async loadSamples(target, locale, path, tree) {
|
|
80
|
+
if (target.kind !== "page")
|
|
81
|
+
return null;
|
|
82
|
+
return (await call("/load-samples", {}, { id: target.id, locale, path, tree })).data;
|
|
83
|
+
},
|
|
74
84
|
async lookup(locale, path) {
|
|
75
85
|
const r = await call("/lookup", { locale, path });
|
|
76
86
|
return withTags(r.data.hit, r.tags, false);
|
package/dist/astro/middleware.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// for the body is what makes a late read count. A preview response gains the draft marker and, in the
|
|
6
6
|
// canvas, the bridge, when the shell rendered it; a live page gains nothing. Nothing under the preview
|
|
7
7
|
// address is ever stored, and neither is a render a publish overtook or one a read failed in.
|
|
8
|
+
import { SAMPLES_PARAM } from "../canvas-marker.js";
|
|
8
9
|
import { defineMiddleware } from "astro:middleware";
|
|
9
10
|
import { loadAdminFiles } from "../admin/files.js";
|
|
10
11
|
import { ADMIN_INTERNAL } from "../admin/page-data.js";
|
|
@@ -32,6 +33,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
32
33
|
draft,
|
|
33
34
|
editor: draft && searchParams.get("smoodly-editor") === "1",
|
|
34
35
|
version: (draft && searchParams.get("smoodly-version")) || undefined,
|
|
36
|
+
samples: (draft && searchParams.get(SAMPLES_PARAM)) || undefined,
|
|
35
37
|
cacheable: false,
|
|
36
38
|
shell: false,
|
|
37
39
|
};
|
package/dist/astro/shell.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export type SmoodlyLocals = {
|
|
|
14
14
|
editor: boolean;
|
|
15
15
|
/** History's stored version, inside a preview only. */
|
|
16
16
|
version?: string;
|
|
17
|
+
/** The picker's zone (`smoodly-samples`), inside a preview only: its rows are drawn from their samples in place of
|
|
18
|
+
* the page's content (spec 2026-11-14 add section previews). */
|
|
19
|
+
samples?: string;
|
|
17
20
|
/** Set by the shell when it rendered a page read from the service: the response may be cached under its tags. */
|
|
18
21
|
cacheable: boolean;
|
|
19
22
|
/** Set by the shell when it rendered a page at all: in a preview, only that response is a canvas. */
|
package/dist/astro/shell.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { addressWithGlobalsUnit, globalUnit } from "../reads.js";
|
|
2
2
|
import { toEntryItem } from "../read-shapes.js";
|
|
3
3
|
import { pageContextOf } from "../site.js";
|
|
4
|
+
import { samplesHit } from "../sample-tree.js";
|
|
4
5
|
import { headOf } from "./head.js";
|
|
5
6
|
import { redirectsRoot, splitLocale } from "./locale-paths.js";
|
|
6
7
|
const NOT_FOUND = { kind: "notFound" };
|
|
@@ -55,9 +56,15 @@ export async function resolveShell(deps, pathname, search, state, views) {
|
|
|
55
56
|
// Only a page read from the service has a tag to expire it by.
|
|
56
57
|
state.cacheable = !hit.record.id.startsWith("code:") && !state.draft;
|
|
57
58
|
state.shell = true;
|
|
59
|
+
// The picker's samples: the same page with the zone's sample tree in place of its content. A code-owned page
|
|
60
|
+
// has no record for the service to answer with, so its zones are empty.
|
|
61
|
+
const shown = state.draft && state.samples !== undefined
|
|
62
|
+
? await samplesHit(site, hit, hit.record.id.startsWith("code:") ? null : { kind: "page", id: hit.record.id }, locale, path, state.samples)
|
|
63
|
+
: hit;
|
|
64
|
+
const tree = shown.kind === "page" ? shown.tree : hit.tree;
|
|
58
65
|
const zoneNames = Object.keys(schema.zones ?? {});
|
|
59
66
|
const zones = Object.fromEntries(zoneNames.map((name) => [
|
|
60
|
-
name, { name, nodes:
|
|
67
|
+
name, { name, nodes: tree.zones?.[name] ?? [], editor: state.editor, styleValues: site.styles },
|
|
61
68
|
]));
|
|
62
69
|
return { kind: "page", template: hit.template, page: pageContextOf(hit, deps.href), zones, head };
|
|
63
70
|
}
|
package/dist/canvas-marker.d.ts
CHANGED
|
@@ -2,13 +2,18 @@ export type CanvasFlags = {
|
|
|
2
2
|
editor: true;
|
|
3
3
|
} | {
|
|
4
4
|
version: string;
|
|
5
|
+
} | {
|
|
6
|
+
samples: string;
|
|
5
7
|
};
|
|
8
|
+
/** The samples flag in the query, where a host keeps it (Astro; Next off Vercel). */
|
|
9
|
+
export declare const SAMPLES_PARAM = "smoodly-samples";
|
|
6
10
|
/** `path` with the marker as its last segment, before any query or hash. */
|
|
7
11
|
export declare function markCanvasPath(path: string, flags: CanvasFlags): string;
|
|
8
12
|
export type CanvasMarker = {
|
|
9
13
|
segments: string[];
|
|
10
14
|
editor: boolean;
|
|
11
15
|
version?: string;
|
|
16
|
+
samples?: string;
|
|
12
17
|
};
|
|
13
18
|
/** The marker taken off a catch-all's segments, which a host may hand over encoded or not. */
|
|
14
19
|
export declare function readCanvasMarker(segments: string[]): CanvasMarker;
|
package/dist/canvas-marker.js
CHANGED
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
// on Next the flags ride the path's last segment: `~editor` (mount the editor bridge) or `~version~<id>` (render a
|
|
4
4
|
// stored version). The admin writes it (admin/editor/canvas-address.ts), the Next renderer reads it inside draft
|
|
5
5
|
// mode only; outside draft mode a marked address is an ordinary miss. A slug never starts with `~`.
|
|
6
|
+
// `~samples~<zone>` (spec 2026-11-14 add section previews) asks for the zone's picker rows drawn from their samples
|
|
7
|
+
// in place of the page's content; where the host keeps the query it rides there as `smoodly-samples`.
|
|
8
|
+
/** The samples flag in the query, where a host keeps it (Astro; Next off Vercel). */
|
|
9
|
+
export const SAMPLES_PARAM = "smoodly-samples";
|
|
6
10
|
const EDITOR = "~editor";
|
|
7
11
|
const VERSION = "~version~";
|
|
12
|
+
const SAMPLES = "~samples~";
|
|
8
13
|
/** `path` with the marker as its last segment, before any query or hash. */
|
|
9
14
|
export function markCanvasPath(path, flags) {
|
|
10
15
|
const cut = path.search(/[?#]/);
|
|
11
16
|
const [pathname, rest] = cut === -1 ? [path, ""] : [path.slice(0, cut), path.slice(cut)];
|
|
12
|
-
const segment = "editor" in flags ? EDITOR : VERSION + encodeURIComponent(flags.version);
|
|
17
|
+
const segment = "editor" in flags ? EDITOR : "version" in flags ? VERSION + encodeURIComponent(flags.version) : SAMPLES + encodeURIComponent(flags.samples);
|
|
13
18
|
return `${pathname.replace(/\/+$/, "")}/${segment}${rest}`;
|
|
14
19
|
}
|
|
15
20
|
/** The marker taken off a catch-all's segments, which a host may hand over encoded or not. */
|
|
@@ -18,12 +23,19 @@ export function readCanvasMarker(segments) {
|
|
|
18
23
|
const rest = segments.slice(0, -1);
|
|
19
24
|
if (last === EDITOR)
|
|
20
25
|
return { segments: rest, editor: true };
|
|
21
|
-
|
|
26
|
+
// The value was encoded once when the marker was written; a host that decoded the segment already handed it over plain.
|
|
27
|
+
const valueAfter = (prefix) => {
|
|
28
|
+
if (!last.startsWith(prefix) || last.length === prefix.length)
|
|
29
|
+
return undefined;
|
|
22
30
|
const raw = segments[segments.length - 1];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
return raw.startsWith(prefix) ? safeDecode(raw.slice(prefix.length)) : last.slice(prefix.length);
|
|
32
|
+
};
|
|
33
|
+
const version = valueAfter(VERSION);
|
|
34
|
+
if (version !== undefined)
|
|
35
|
+
return { segments: rest, editor: false, version };
|
|
36
|
+
const samples = valueAfter(SAMPLES);
|
|
37
|
+
if (samples !== undefined)
|
|
38
|
+
return { segments: rest, editor: false, samples };
|
|
27
39
|
return { segments, editor: false };
|
|
28
40
|
}
|
|
29
41
|
function safeDecode(segment) {
|