smoodly 0.0.35 → 0.0.37
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 +24 -1611
- package/dist/admin/drift.js +3 -2
- package/dist/admin/editor/SeoSettings.d.ts +7 -1
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/field-label.d.ts +2 -0
- package/dist/admin/field-label.js +2 -0
- package/dist/admin/forms/FieldWidget.js +2 -2
- package/dist/admin/next/create-admin.d.ts +4 -4
- package/dist/admin/next/create-admin.js +5 -5
- package/dist/admin/next/index.d.ts +1 -1
- package/dist/admin/serialize.d.ts +8 -1
- package/dist/admin/serialize.js +23 -7
- package/dist/admin/shell/AdminApp.d.ts +3 -3
- package/dist/admin/shell/AdminApp.js +2 -2
- package/dist/admin/shell/EntryForm.js +8 -1
- package/dist/admin/shell/presence-realtime.d.ts +2 -2
- package/dist/cli/space-io.js +1 -1
- package/dist/collections.d.ts +4 -0
- package/dist/collections.js +9 -1
- package/dist/entry-page.d.ts +12 -10
- package/dist/entry-store.d.ts +30 -1
- package/dist/entry-store.js +54 -1
- package/dist/env.d.ts +5 -5
- package/dist/env.js +2 -2
- package/dist/fields.d.ts +13 -7
- package/dist/fields.js +2 -2
- package/dist/global.d.ts +4 -7
- package/dist/index.d.ts +10 -39
- package/dist/index.js +1 -1
- package/dist/migrations.js +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +6 -4
- package/dist/next/meta.js +14 -17
- package/dist/next/page-renderer.js +6 -3
- package/dist/next/queries.d.ts +10 -11
- package/dist/next/queries.js +7 -2
- package/dist/page.d.ts +5 -2
- package/dist/page.js +3 -1
- package/dist/pairing.d.ts +10 -10
- package/dist/path-index.d.ts +3 -0
- package/dist/path-index.js +7 -0
- package/dist/read-shapes.d.ts +64 -0
- package/dist/read-shapes.js +52 -0
- package/dist/render.d.ts +3 -3
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.js +29 -7
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/schema.d.ts +22 -1
- package/dist/seo.d.ts +18 -0
- package/dist/seo.js +53 -0
- package/dist/site.d.ts +15 -16
- package/dist/site.js +64 -58
- package/dist/sql-space.d.ts +6 -5
- package/dist/sql-space.js +11 -24
- package/dist/sql.d.ts +6 -1
- package/dist/sql.js +60 -23
- package/dist/supabase-entry-store.d.ts +3 -1
- package/dist/supabase-entry-store.js +39 -0
- package/dist/supabase-path-index.d.ts +1 -0
- package/dist/supabase-path-index.js +16 -0
- package/migrations/20260930040956_smoodly_v7.sql +84 -0
- package/package.json +1 -1
package/dist/admin/drift.js
CHANGED
|
@@ -2,6 +2,7 @@ import { optionValues } from "../fields.js";
|
|
|
2
2
|
import { isHidden } from "../conditions.js";
|
|
3
3
|
import { allowedOf } from "../toolset.js";
|
|
4
4
|
import { CUSTOM_NODE_TYPE } from "../custom/schema.js";
|
|
5
|
+
import { SEO_KEY } from "../seo.js";
|
|
5
6
|
import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY } from "../global.js";
|
|
6
7
|
export const SEVERITY = {
|
|
7
8
|
"unknown-section": "broken", "unknown-element": "broken", "unknown-shape": "broken", "unknown-collection": "broken",
|
|
@@ -209,7 +210,7 @@ export function pageDrift(registry, template, tree) {
|
|
|
209
210
|
out.push(...holderDrift(registry, { scope: "page", name: shape.name }, "page.fields", { fields: tree.fields ?? {} }, { fields: shape.fields }));
|
|
210
211
|
return out;
|
|
211
212
|
}
|
|
212
|
-
const RESERVED = new Set(["slug", SETTINGS_KEY, STYLES_KEY, CSS_KEY]);
|
|
213
|
+
const RESERVED = new Set(["slug", SEO_KEY, SETTINGS_KEY, STYLES_KEY, CSS_KEY]);
|
|
213
214
|
function localization(subject, descs, input, out) {
|
|
214
215
|
if (!input.shared || !input.own)
|
|
215
216
|
return;
|
|
@@ -231,7 +232,7 @@ export function entryDrift(registry, name, input) {
|
|
|
231
232
|
const collection = registry.collections.find((c) => c.name === name);
|
|
232
233
|
if (collection) {
|
|
233
234
|
const subject = { scope: "collection", name };
|
|
234
|
-
const { slug: _slug, ...fields } = input.fields;
|
|
235
|
+
const { slug: _slug, [SEO_KEY]: _seo, ...fields } = input.fields;
|
|
235
236
|
out.push(...holderDrift(registry, subject, "", { fields }, { fields: collection.fields }));
|
|
236
237
|
localization(subject, collection.fields, input, out);
|
|
237
238
|
return out;
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export declare const SEO_TITLE_MAX = 60;
|
|
2
2
|
export declare const SEO_DESCRIPTION_MAX = 160;
|
|
3
3
|
export type SeoKey = "title" | "description" | "ogImage" | "noindex";
|
|
4
|
-
export declare function SeoSettings({ meta, onPatch }: {
|
|
4
|
+
export declare function SeoSettings({ meta, onPatch, titleHint, descriptionHint, imageHint }: {
|
|
5
5
|
meta: Record<string, unknown>;
|
|
6
6
|
onPatch(key: SeoKey, value: unknown): void;
|
|
7
|
+
/** What an empty title does; the page editor's default names the page title (an entry's names its title field). */
|
|
8
|
+
titleHint?: string;
|
|
9
|
+
/** What an empty description does, when it falls back on a field. */
|
|
10
|
+
descriptionHint?: string;
|
|
11
|
+
/** What an empty social image does, when it falls back on a field. */
|
|
12
|
+
imageHint?: string;
|
|
7
13
|
}): import("react").JSX.Element;
|
|
@@ -8,8 +8,8 @@ const str = (v) => (typeof v === "string" ? v : "");
|
|
|
8
8
|
function Counter({ n, max }) {
|
|
9
9
|
return _jsxs("span", { className: n > max ? "sm-counter--over" : undefined, children: [n, "/", max] });
|
|
10
10
|
}
|
|
11
|
-
export function SeoSettings({ meta, onPatch }) {
|
|
11
|
+
export function SeoSettings({ meta, onPatch, titleHint, descriptionHint, imageHint }) {
|
|
12
12
|
const title = str(meta.title);
|
|
13
13
|
const description = str(meta.description);
|
|
14
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint:
|
|
14
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: `Used in search results and link previews. ${titleHint ?? "Empty: the page title."}`, children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), hint: descriptionHint, children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v), label: "Social image", hint: imageHint ? `1200×630 recommended. ${imageHint}` : "1200×630 recommended" }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
|
|
15
15
|
}
|
|
@@ -11,10 +11,10 @@ import { optionsOf } from "../../fields.js";
|
|
|
11
11
|
import { isHidden } from "../../conditions.js";
|
|
12
12
|
import { cssProblem } from "../../css.js";
|
|
13
13
|
import { Button, Field, Switch } from "../ui/primitives.js";
|
|
14
|
+
import { fieldLabel } from "../field-label.js";
|
|
14
15
|
import { patchObject } from "./list.js";
|
|
15
16
|
import { tidySlug } from "./slug-field.js";
|
|
16
|
-
|
|
17
|
-
const label = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
|
|
17
|
+
const label = fieldLabel;
|
|
18
18
|
export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, hint, onBlur, }) {
|
|
19
19
|
const path = pathProp ?? fieldKey;
|
|
20
20
|
// A tags field has no per-chip slot: the first item error shows under the field.
|
|
@@ -2,15 +2,15 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { ResolvedConfig } from "../../config.ts";
|
|
3
3
|
import type { AdminOpAction } from "../ops.ts";
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
5
|
-
import type {
|
|
6
|
-
/** `
|
|
7
|
-
* concurrency §5), async because its space is asked of the database; absent or failed, presence
|
|
5
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
6
|
+
/** `presence` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
7
|
+
* concurrency §5; presence only, never content reads, spec 2026-09-29 content API contract §8), async because its space is asked of the database; absent or failed, presence
|
|
8
8
|
* is off and everything else works. */
|
|
9
9
|
export declare function createSmoodlyAdmin(input: {
|
|
10
10
|
config: ResolvedConfig;
|
|
11
11
|
op: AdminOpAction;
|
|
12
12
|
auth: () => AuthConfig;
|
|
13
|
-
|
|
13
|
+
presence?: () => PresenceConfig | Promise<PresenceConfig>;
|
|
14
14
|
}): {
|
|
15
15
|
AdminPage: () => null;
|
|
16
16
|
AdminLayout: ({ children }: {
|
|
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { serializeAdminConfig } from "../serialize.js";
|
|
3
3
|
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
|
-
/** `
|
|
6
|
-
* concurrency §5), async because its space is asked of the database; absent or failed, presence
|
|
5
|
+
/** `presence` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
6
|
+
* concurrency §5; presence only, never content reads, spec 2026-09-29 content API contract §8), async because its space is asked of the database; absent or failed, presence
|
|
7
7
|
* is off and everything else works. */
|
|
8
8
|
export function createSmoodlyAdmin(input) {
|
|
9
9
|
const registry = serializeAdminConfig(input.config);
|
|
@@ -28,14 +28,14 @@ export function createSmoodlyAdmin(input) {
|
|
|
28
28
|
// Presence is the one thing that needs it: a failed lookup (an unmigrated project, an
|
|
29
29
|
// unreachable database) is logged and turns presence off, so the shell still reaches
|
|
30
30
|
// sign-in, the schema notice or the admin-path ErrorPane.
|
|
31
|
-
let
|
|
31
|
+
let presenceConfig;
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
presenceConfig = await input.presence?.();
|
|
34
34
|
}
|
|
35
35
|
catch (e) {
|
|
36
36
|
console.error("smoodly: editing presence is off —", e instanceof Error ? e.message : e);
|
|
37
37
|
}
|
|
38
|
-
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(),
|
|
38
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), presenceConfig: presenceConfig }), children] }));
|
|
39
39
|
}
|
|
40
40
|
return { AdminPage, AdminLayout };
|
|
41
41
|
}
|
|
@@ -4,5 +4,5 @@ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult
|
|
|
4
4
|
export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
|
|
5
5
|
export type { MemberRole } from "../../permissions.ts";
|
|
6
6
|
export type { AuthConfig } from "../shell/session.ts";
|
|
7
|
-
export type {
|
|
7
|
+
export type { PresenceConfig } from "../../env.ts";
|
|
8
8
|
export type { AdminAuth } from "../auth.ts";
|
|
@@ -34,6 +34,13 @@ export type AdminCollection = {
|
|
|
34
34
|
depth: number;
|
|
35
35
|
/** `order: "manual"` — the list drags into the `sort` order (spec 2026-09-09 §5). */
|
|
36
36
|
manual: boolean;
|
|
37
|
+
/** The collection has SEO & Social values (spec 2026-09-29 content API contract §7); each label
|
|
38
|
+
* names, in lower case, the field an empty value falls back on — the hints' copy. */
|
|
39
|
+
seo?: {
|
|
40
|
+
titleLabel: string;
|
|
41
|
+
descriptionLabel?: string;
|
|
42
|
+
imageLabel?: string;
|
|
43
|
+
};
|
|
37
44
|
};
|
|
38
45
|
export type AdminPageTemplate = {
|
|
39
46
|
name: string;
|
|
@@ -45,7 +52,7 @@ export type AdminPageTemplate = {
|
|
|
45
52
|
zones: Record<string, ZonePolicy>;
|
|
46
53
|
/** The template's page fields (spec 2026-09-10 page fields §5), edited on the Settings tab. */
|
|
47
54
|
fields: Record<string, AdminField>;
|
|
48
|
-
/** `
|
|
55
|
+
/** `seo: true` — the SEO & Social tab exists for this template. */
|
|
49
56
|
seo: boolean;
|
|
50
57
|
};
|
|
51
58
|
export type AdminGlobal = {
|
package/dist/admin/serialize.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { fixedSlug } from "../page.js";
|
|
2
2
|
import { collectionDepth, collectionOrder } from "../collections.js";
|
|
3
|
+
import { SEO_KEY } from "../seo.js";
|
|
4
|
+
import { fieldLabel } from "./field-label.js";
|
|
3
5
|
import { perLocaleSegments } from "../paths.js";
|
|
4
6
|
import { styleDescriptors } from "../styles.js";
|
|
5
7
|
import { CUSTOM_NODE_TYPE, customFields, defaultRow } from "../custom/index.js";
|
|
@@ -32,6 +34,15 @@ export function sectionSettings(config, name) {
|
|
|
32
34
|
function sectionCss(config, name) {
|
|
33
35
|
return config.css && (config.registry.sections ?? []).find((s) => s.schema.name === name)?.schema.css !== false;
|
|
34
36
|
}
|
|
37
|
+
const lower = (key) => fieldLabel(key).toLowerCase();
|
|
38
|
+
function seoLabels(c) {
|
|
39
|
+
const named = c.seo === true || c.seo === undefined ? {} : c.seo;
|
|
40
|
+
return {
|
|
41
|
+
titleLabel: c.titleField ? lower(c.titleField) : "entry title",
|
|
42
|
+
...(named.description ? { descriptionLabel: lower(named.description) } : {}),
|
|
43
|
+
...(named.image ? { imageLabel: lower(named.image) } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
35
46
|
export function serializeAdminConfig(config) {
|
|
36
47
|
const perLocale = (v) => perLocaleSegments(v, config.locales.supported);
|
|
37
48
|
return {
|
|
@@ -62,12 +73,17 @@ export function serializeAdminConfig(config) {
|
|
|
62
73
|
sample: e.sample,
|
|
63
74
|
};
|
|
64
75
|
}),
|
|
65
|
-
collections: (config.registry.collections ?? []).map((c) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
collections: (config.registry.collections ?? []).map((c) => {
|
|
77
|
+
// `$seo` is the SEO panel's, never a form field.
|
|
78
|
+
const { [SEO_KEY]: _seo, ...fields } = c.fields;
|
|
79
|
+
return {
|
|
80
|
+
name: c.name, title: c.title, titleField: c.titleField, fields: serializeMap(fields),
|
|
81
|
+
...(c.path !== undefined ? { path: perLocale(c.path) } : {}),
|
|
82
|
+
depth: collectionDepth(c),
|
|
83
|
+
manual: collectionOrder(c) === "manual",
|
|
84
|
+
...(c.seo !== undefined ? { seo: seoLabels(c) } : {}),
|
|
85
|
+
};
|
|
86
|
+
}),
|
|
71
87
|
pages: (config.registry.pages ?? [])
|
|
72
88
|
.filter(({ schema }) => schema.kind === "page")
|
|
73
89
|
.map(({ schema }) => {
|
|
@@ -80,7 +96,7 @@ export function serializeAdminConfig(config) {
|
|
|
80
96
|
...(typeof p.slug === "object" && p.slug !== null ? { slugs: { ...p.slug } } : {}),
|
|
81
97
|
zones: p.zones ?? {},
|
|
82
98
|
fields: serializeMap(p.fields),
|
|
83
|
-
seo: p.
|
|
99
|
+
seo: p.seo === true,
|
|
84
100
|
};
|
|
85
101
|
}),
|
|
86
102
|
globals: (config.registry.globals ?? []).map((s) => ({
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import { type AuthConfig } from "./session.ts";
|
|
4
|
-
import type {
|
|
5
|
-
export declare function AdminApp({ registry, op, auth,
|
|
4
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
5
|
+
export declare function AdminApp({ registry, op, auth, presenceConfig }: {
|
|
6
6
|
registry: AdminRegistry;
|
|
7
7
|
op: AdminOpAction;
|
|
8
8
|
auth: AuthConfig;
|
|
9
|
-
|
|
9
|
+
presenceConfig?: PresenceConfig;
|
|
10
10
|
}): import("react").JSX.Element;
|
|
@@ -39,7 +39,7 @@ import { schemaBehind } from "../../schema-version.js";
|
|
|
39
39
|
import { createPresence, followTokenRefresh } from "./presence.js";
|
|
40
40
|
import { connectRealtime } from "./presence-realtime.js";
|
|
41
41
|
import { PresenceProvider } from "./presence-context.js";
|
|
42
|
-
export function AdminApp({ registry, op, auth,
|
|
42
|
+
export function AdminApp({ registry, op, auth, presenceConfig }) {
|
|
43
43
|
const session = useAdminSession(auth);
|
|
44
44
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
45
45
|
const route = useRoute(registry.adminPath);
|
|
@@ -89,7 +89,7 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
89
89
|
const tokenRef = useRef(session.token);
|
|
90
90
|
tokenRef.current = session.token;
|
|
91
91
|
const authClient = session.client;
|
|
92
|
-
const contentUrl =
|
|
92
|
+
const contentUrl = presenceConfig?.url, contentKey = presenceConfig?.key, contentSpace = presenceConfig?.spaceId;
|
|
93
93
|
useEffect(() => {
|
|
94
94
|
if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
|
|
95
95
|
setPresence(null);
|
|
@@ -49,6 +49,8 @@ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } fr
|
|
|
49
49
|
import { editorsOf } from "./presence.js";
|
|
50
50
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
51
51
|
import { slugHint, tidySlug, withFollowingSlug } from "../forms/slug-field.js";
|
|
52
|
+
import { SeoSettings } from "../editor/SeoSettings.js";
|
|
53
|
+
import { SEO_KEY } from "../../seo.js";
|
|
52
54
|
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
53
55
|
const NO_FIELDS = {};
|
|
54
56
|
/** True while the entry's slug box has focus: its field wrapper carries the path. */
|
|
@@ -478,5 +480,10 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
478
480
|
setDirty(true);
|
|
479
481
|
setTouched(true);
|
|
480
482
|
mark();
|
|
481
|
-
} })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [
|
|
483
|
+
} })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && row && meta.seo && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "SEO & Social" }), _jsx(SeoSettings, { meta: fields?.[SEO_KEY] ?? {}, titleHint: `Empty: the ${meta.seo.titleLabel}.`, descriptionHint: meta.seo.descriptionLabel ? `Empty: the ${meta.seo.descriptionLabel}.` : undefined, imageHint: meta.seo.imageLabel ? `Empty: the ${meta.seo.imageLabel}.` : undefined, onPatch: (key, value) => {
|
|
484
|
+
setFields((f) => ({ ...(f ?? {}), [SEO_KEY]: { ...(f?.[SEO_KEY] ?? {}), [key]: value } }));
|
|
485
|
+
setDirty(true);
|
|
486
|
+
setTouched(true);
|
|
487
|
+
mark();
|
|
488
|
+
} })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
482
489
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
2
2
|
import type { PresenceChannel } from "./presence.ts";
|
|
3
|
-
export declare function connectRealtime(config:
|
|
3
|
+
export declare function connectRealtime(config: PresenceConfig, email: string, token: string): PresenceChannel;
|
package/dist/cli/space-io.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function readSchemaVersion(db) {
|
|
|
47
47
|
}
|
|
48
48
|
export function schemaRefusal(missing, command) {
|
|
49
49
|
return missing === "key"
|
|
50
|
-
? `${command} needs SMOODLY_SECRET_KEY — the service-role key, or the cloud's write key. The publishable key
|
|
50
|
+
? `${command} needs SMOODLY_SECRET_KEY — the service-role key, or the cloud's write key. The publishable key reads no content.`
|
|
51
51
|
: "This database has no Smoodly schema. Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).";
|
|
52
52
|
}
|
|
53
53
|
/** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
|
package/dist/collections.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Descriptor } from "./fields.ts";
|
|
2
2
|
import type { BuilderMap, NamespaceValues } from "./schema.ts";
|
|
3
|
+
import { type CollectionSeo } from "./seo.ts";
|
|
3
4
|
/** How a collection is ordered — in the admin list and in public reads
|
|
4
5
|
* alike (spec 2026-09-05 §3). "manual" is the drag order (`sort`). */
|
|
5
6
|
export type EntryOrder = "manual" | {
|
|
@@ -29,6 +30,8 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string
|
|
|
29
30
|
admin?: {
|
|
30
31
|
listColumns?: string[];
|
|
31
32
|
};
|
|
33
|
+
/** Entries carry SEO & Social values (spec 2026-09-29 content API contract §7): stored under `$seo`, empty values falling back on `titleField` and the named fields. */
|
|
34
|
+
seo?: CollectionSeo;
|
|
32
35
|
/** phantom: the entry value shape, used by f.ref target inference */
|
|
33
36
|
readonly __entry?: NamespaceValues<F>;
|
|
34
37
|
};
|
|
@@ -47,4 +50,5 @@ export declare function collection<F extends BuilderMap, const N extends string
|
|
|
47
50
|
admin?: {
|
|
48
51
|
listColumns?: string[];
|
|
49
52
|
};
|
|
53
|
+
seo?: CollectionSeo;
|
|
50
54
|
}): CollectionSchema<F, N>;
|
package/dist/collections.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SEO_DESCRIPTOR, SEO_KEY, validateCollectionSeo } from "./seo.js";
|
|
1
2
|
export const DEFAULT_ORDER = { by: "createdAt", direction: "desc" };
|
|
2
3
|
export const BUILT_IN_ORDER_FIELDS = ["createdAt", "updatedAt"];
|
|
3
4
|
export const collectionOrder = (c) => c.order ?? DEFAULT_ORDER;
|
|
@@ -7,6 +8,9 @@ export function collection(input) {
|
|
|
7
8
|
throw new Error("smoodly: a collection requires a name.");
|
|
8
9
|
if (["global", "asset", "media"].includes(input.name))
|
|
9
10
|
throw new Error(`smoodly: "${input.name}" is a reserved name.`);
|
|
11
|
+
if (Object.prototype.hasOwnProperty.call(input.fields, SEO_KEY)) {
|
|
12
|
+
throw new Error(`smoodly: collection "${input.name}": "${SEO_KEY}" is a reserved key.`);
|
|
13
|
+
}
|
|
10
14
|
if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
|
|
11
15
|
throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
|
|
12
16
|
}
|
|
@@ -49,12 +53,16 @@ export function collection(input) {
|
|
|
49
53
|
throw new Error(`smoodly: collection "${input.name}" slug field "${key}" follows an unknown field "${from}".`);
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
const fields = Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
|
|
57
|
+
if (input.seo !== undefined)
|
|
58
|
+
validateCollectionSeo(input.name, input.seo, fields);
|
|
52
59
|
return {
|
|
53
60
|
kind: "collection",
|
|
54
61
|
name: input.name,
|
|
55
62
|
title: input.title,
|
|
56
63
|
titleField: input.titleField,
|
|
57
|
-
fields:
|
|
64
|
+
fields: input.seo !== undefined ? { ...fields, [SEO_KEY]: SEO_DESCRIPTOR } : fields,
|
|
65
|
+
...(input.seo !== undefined ? { seo: input.seo } : {}),
|
|
58
66
|
...(input.path !== undefined ? { path: input.path } : {}),
|
|
59
67
|
...(input.tree !== undefined ? { tree: input.tree } : {}),
|
|
60
68
|
...(input.order !== undefined ? { order: input.order } : {}),
|
package/dist/entry-page.d.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
3
|
+
import type { EntryItem } from "./read-shapes.ts";
|
|
4
|
+
import type { Materialize } from "./schema.ts";
|
|
5
5
|
export type EntryPageSchema = {
|
|
6
6
|
kind: "entryPage";
|
|
7
7
|
name: string;
|
|
8
8
|
collection: string;
|
|
9
9
|
};
|
|
10
|
-
/** What an entry page renders: the entry
|
|
11
|
-
*
|
|
12
|
-
export type EntryPageProps = {
|
|
13
|
-
entry:
|
|
14
|
-
path: string;
|
|
15
|
-
locale: string;
|
|
16
|
-
locales: Record<string, LocaleAlternate | null>;
|
|
10
|
+
/** What an entry page renders: the entry as a read returns it — its path, locale and
|
|
11
|
+
* locale alternates ride on it (spec 2026-09-29 content API contract §3). */
|
|
12
|
+
export type EntryPageProps<F = Record<string, unknown>> = {
|
|
13
|
+
entry: EntryItem<F>;
|
|
17
14
|
};
|
|
18
15
|
export type PairedEntryPage = ((props: EntryPageProps) => ReactElement) & {
|
|
19
16
|
schema: EntryPageSchema;
|
|
20
17
|
};
|
|
21
|
-
|
|
18
|
+
/** The collection's fields as a read returns them. */
|
|
19
|
+
type Materialized<C extends CollectionSchema<any>> = {
|
|
20
|
+
[K in keyof NonNullable<C["__entry"]>]: Materialize<NonNullable<C["__entry"]>[K]>;
|
|
21
|
+
};
|
|
22
|
+
export declare function entryPage<C extends CollectionSchema<any>>(collection: C, View: ComponentType<EntryPageProps<Materialized<C>>>): PairedEntryPage;
|
|
23
|
+
export {};
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -81,6 +81,22 @@ export type ListOptions = {
|
|
|
81
81
|
*/
|
|
82
82
|
parent?: string | null;
|
|
83
83
|
};
|
|
84
|
+
export type SnapshotQuery = {
|
|
85
|
+
locale: string;
|
|
86
|
+
/** true: the live row (the editor preview); false: the published snapshot. */
|
|
87
|
+
draft: boolean;
|
|
88
|
+
where?: Where;
|
|
89
|
+
order?: EntryOrder;
|
|
90
|
+
limit?: number;
|
|
91
|
+
parent?: string | null;
|
|
92
|
+
/** Only these entries (getEntry by id). */
|
|
93
|
+
ids?: string[];
|
|
94
|
+
};
|
|
95
|
+
/** `fields` is the merged snapshot (shared, localized, `slug`) of the version shown; `record` never leaves the site layer. */
|
|
96
|
+
export type SnapshotRow = {
|
|
97
|
+
record: EntryRecord;
|
|
98
|
+
fields: Record<string, unknown>;
|
|
99
|
+
};
|
|
84
100
|
export type CreateEntryOptions = {
|
|
85
101
|
locale: string;
|
|
86
102
|
parentId?: string | null;
|
|
@@ -102,6 +118,11 @@ export interface EntryStore {
|
|
|
102
118
|
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
|
|
103
119
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
104
120
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
121
|
+
/** Filters, orders and limits over the version SHOWN: the published snapshot (a row
|
|
122
|
+
* without one falls back to the live merge), or the live row when `draft` (spec
|
|
123
|
+
* 2026-09-29 content API contract §5). Only rows present in `locale`; the published
|
|
124
|
+
* path also needs the row published. */
|
|
125
|
+
query(collection: string, q: SnapshotQuery): Promise<SnapshotRow[]>;
|
|
105
126
|
/** Every entry row, registered collection or not, with each published locale's live fields (spec 2026-09-27 editor safety §3). */
|
|
106
127
|
scanEntries(): Promise<EntryScan[]>;
|
|
107
128
|
/** Batched read for the resolver: the MERGED fields in `locale`, keyed
|
|
@@ -168,6 +189,8 @@ export interface EntryStore {
|
|
|
168
189
|
}[]>;
|
|
169
190
|
resolvePath(locale: string, path: string): Promise<PathHit | null>;
|
|
170
191
|
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
192
|
+
/** id → full live path in `locale` for many entries of one collection; ids without one are absent. */
|
|
193
|
+
pathsIn(collection: string, ids: string[], locale: string): Promise<Record<string, string>>;
|
|
171
194
|
/** locale → this entry's old addresses. */
|
|
172
195
|
redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
|
|
173
196
|
}
|
|
@@ -235,10 +258,14 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
|
|
|
235
258
|
* the creation tiebreak follows `direction` for non-manual orders.
|
|
236
259
|
* Declared fields compare numerically when both values are numbers,
|
|
237
260
|
* otherwise as strings via `localeCompare` — the same order Postgres
|
|
238
|
-
* gives the jsonb VALUE (`fields->x`) for one type; see
|
|
261
|
+
* gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
|
|
239
262
|
* follow-ups for the mixed-type and JSON-null edges.
|
|
240
263
|
*/
|
|
241
264
|
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
|
265
|
+
/** Comparator for `query`: like compareEntries, but a declared field reads the
|
|
266
|
+
* merged snapshot, `updatedAt` the locale row's, and an absent, null or empty-string value
|
|
267
|
+
* sorts last in both directions; ties break by creation order (ascending). */
|
|
268
|
+
export declare function compareSnapshots(order: EntryOrder, locale: string, seq: (id: string) => number): (a: SnapshotRow, b: SnapshotRow) => number;
|
|
242
269
|
export declare class MemoryEntryStore implements EntryStore {
|
|
243
270
|
private refs;
|
|
244
271
|
private entries;
|
|
@@ -287,6 +314,7 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
287
314
|
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
|
|
288
315
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
289
316
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
317
|
+
query(collection: string, q: SnapshotQuery): Promise<SnapshotRow[]>;
|
|
290
318
|
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
291
319
|
status?: "published";
|
|
292
320
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
@@ -315,6 +343,7 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
315
343
|
sourceId: string;
|
|
316
344
|
}[]>;
|
|
317
345
|
resolvePath(locale: string, path: string): Promise<PathHit | null>;
|
|
346
|
+
pathsIn(collection: string, ids: string[], locale: string): Promise<Record<string, string>>;
|
|
318
347
|
pathsOf(collection: string, id: string): Promise<{}>;
|
|
319
348
|
redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
|
|
320
349
|
}
|
package/dist/entry-store.js
CHANGED
|
@@ -150,7 +150,7 @@ export function entryTitleIn(record, locale, schema) {
|
|
|
150
150
|
* the creation tiebreak follows `direction` for non-manual orders.
|
|
151
151
|
* Declared fields compare numerically when both values are numbers,
|
|
152
152
|
* otherwise as strings via `localeCompare` — the same order Postgres
|
|
153
|
-
* gives the jsonb VALUE (`fields->x`) for one type; see
|
|
153
|
+
* gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
|
|
154
154
|
* follow-ups for the mixed-type and JSON-null edges.
|
|
155
155
|
*/
|
|
156
156
|
export function compareEntries(order, seq) {
|
|
@@ -172,6 +172,32 @@ export function compareEntries(order, seq) {
|
|
|
172
172
|
return d !== 0 ? d * dir : (seq(a) - seq(b)) * dir;
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
|
+
/** Comparator for `query`: like compareEntries, but a declared field reads the
|
|
176
|
+
* merged snapshot, `updatedAt` the locale row's, and an absent, null or empty-string value
|
|
177
|
+
* sorts last in both directions; ties break by creation order (ascending). */
|
|
178
|
+
export function compareSnapshots(order, locale, seq) {
|
|
179
|
+
return (a, b) => {
|
|
180
|
+
if (order === "manual") {
|
|
181
|
+
const d = (a.record.sort ?? Infinity) - (b.record.sort ?? Infinity);
|
|
182
|
+
return d !== 0 ? d : seq(a.record.id) - seq(b.record.id);
|
|
183
|
+
}
|
|
184
|
+
const dir = order.direction === "asc" ? 1 : -1;
|
|
185
|
+
const value = (r) => order.by === "createdAt" ? r.record.createdAt : order.by === "updatedAt" ? r.record.locales[locale]?.updatedAt : r.fields[order.by];
|
|
186
|
+
const empty = (v) => (v === "" ? undefined : v); // a cleared text or date is empty too
|
|
187
|
+
const va = empty(value(a)), vb = empty(value(b));
|
|
188
|
+
const tie = seq(a.record.id) - seq(b.record.id);
|
|
189
|
+
if (order.by === "createdAt")
|
|
190
|
+
return tie * dir; // the clock can tie within a millisecond; creation order cannot
|
|
191
|
+
if (va == null && vb == null)
|
|
192
|
+
return tie;
|
|
193
|
+
if (va == null)
|
|
194
|
+
return 1;
|
|
195
|
+
if (vb == null)
|
|
196
|
+
return -1;
|
|
197
|
+
const d = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
|
|
198
|
+
return d !== 0 ? d * dir : tie;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
175
201
|
export class MemoryEntryStore {
|
|
176
202
|
constructor(collections,
|
|
177
203
|
// pass the SAME index to MemoryPageStore so cross-kind questions work
|
|
@@ -389,6 +415,29 @@ export class MemoryEntryStore {
|
|
|
389
415
|
rows = rows.slice(0, options.limit);
|
|
390
416
|
return rows.map((e) => this.snapshot(e));
|
|
391
417
|
}
|
|
418
|
+
async query(collection, q) {
|
|
419
|
+
const schema = this.schema(collection);
|
|
420
|
+
const wanted = q.ids ? new Set(q.ids) : null;
|
|
421
|
+
const rows = [];
|
|
422
|
+
for (const e of this.entries.values()) {
|
|
423
|
+
if (e.collection !== collection)
|
|
424
|
+
continue;
|
|
425
|
+
if (wanted && !wanted.has(e.id))
|
|
426
|
+
continue;
|
|
427
|
+
if (q.parent !== undefined && e.parentId !== q.parent)
|
|
428
|
+
continue;
|
|
429
|
+
const row = e.locales[q.locale];
|
|
430
|
+
if (!row || (!q.draft && row.status !== "published"))
|
|
431
|
+
continue;
|
|
432
|
+
const snapshot = !q.draft && row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
|
|
433
|
+
const fields = snapshot ? structuredClone(snapshot.fields) : entryFieldsIn(e, q.locale);
|
|
434
|
+
if (q.where && !matchesWhere(fields, q.where))
|
|
435
|
+
continue;
|
|
436
|
+
rows.push({ record: this.snapshot(e), fields });
|
|
437
|
+
}
|
|
438
|
+
rows.sort(compareSnapshots(q.order ?? collectionOrder(schema), q.locale, (id) => this.order.get(id)));
|
|
439
|
+
return typeof q.limit === "number" ? rows.slice(0, q.limit) : rows;
|
|
440
|
+
}
|
|
392
441
|
async getMany(collection, ids, locale, options) {
|
|
393
442
|
this.schema(collection);
|
|
394
443
|
const out = {};
|
|
@@ -644,6 +693,10 @@ export class MemoryEntryStore {
|
|
|
644
693
|
resolvePath(locale, path) {
|
|
645
694
|
return this.paths.resolve(locale, path);
|
|
646
695
|
}
|
|
696
|
+
pathsIn(collection, ids, locale) {
|
|
697
|
+
this.schema(collection);
|
|
698
|
+
return this.paths.livePathsIn("entry", ids, locale);
|
|
699
|
+
}
|
|
647
700
|
pathsOf(collection, id) {
|
|
648
701
|
return this.row(collection, id) ? this.paths.pathsOf({ kind: "entry", id }) : Promise.resolve({});
|
|
649
702
|
}
|
package/dist/env.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type SmoodlyEnv = {
|
|
|
4
4
|
url: string;
|
|
5
5
|
/** Server-only: the service-role key (self-host) or the environment's write key (cloud). */
|
|
6
6
|
secretKey: string;
|
|
7
|
-
/** What a browser may hold for the content project: anon/publishable key, or the
|
|
7
|
+
/** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). */
|
|
8
8
|
publishableKey: string;
|
|
9
9
|
/** Where editors sign in. Defaults to `url`. */
|
|
10
10
|
authUrl: string;
|
|
@@ -15,12 +15,12 @@ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined
|
|
|
15
15
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
16
16
|
* so `next build` without env still succeeds. */
|
|
17
17
|
export declare const smoodlyEnv: () => SmoodlyEnv;
|
|
18
|
-
/** What the browser needs to join the content project's
|
|
19
|
-
export type
|
|
18
|
+
/** What the browser needs to join the admin's presence channel: the content project's URL, publishable key and space, public values only — never for content reads (spec 2026-09-29 content API contract §8). */
|
|
19
|
+
export type PresenceConfig = {
|
|
20
20
|
url: string;
|
|
21
21
|
key: string;
|
|
22
22
|
spaceId: string;
|
|
23
23
|
};
|
|
24
|
-
/** The
|
|
24
|
+
/** The presence config, with the space the database says the secret key acts in —
|
|
25
25
|
* asked, never read out of the key, so it cannot disagree with where rows land. */
|
|
26
|
-
export declare function
|
|
26
|
+
export declare function presenceConfig(env: SmoodlyEnv, db?: Parameters<typeof readCurrentSpace>[0]): Promise<PresenceConfig>;
|
package/dist/env.js
CHANGED
|
@@ -25,9 +25,9 @@ export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
|
|
|
25
25
|
/** One lookup per process per content project and key: the space never
|
|
26
26
|
* changes for a key. A failed lookup is not cached, so the next call retries. */
|
|
27
27
|
const spaces = new Map();
|
|
28
|
-
/** The
|
|
28
|
+
/** The presence config, with the space the database says the secret key acts in —
|
|
29
29
|
* asked, never read out of the key, so it cannot disagree with where rows land. */
|
|
30
|
-
export function
|
|
30
|
+
export function presenceConfig(env, db) {
|
|
31
31
|
const cacheKey = `${env.url}\n${env.secretKey}`;
|
|
32
32
|
let space = spaces.get(cacheKey);
|
|
33
33
|
if (!space) {
|