smoodly 0.0.35 → 0.0.36
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 +29 -38
- 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 +29 -0
- package/dist/entry-store.js +53 -0
- 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/README.md
CHANGED
|
@@ -523,6 +523,22 @@ Implemented:
|
|
|
523
523
|
(the rule table, both adapters), the store contracts, `admin-ops`
|
|
524
524
|
("redirects on rename and move", "redirects ops"),
|
|
525
525
|
`next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
|
|
526
|
+
- Content API contract (spec 2026-09-29 content API contract): every
|
|
527
|
+
read returns the public shape — `EntryItem`, `PageItem`, `GlobalItem`
|
|
528
|
+
(`src/read-shapes.ts`), nothing of other locales, pointers or editors
|
|
529
|
+
leaves the site layer; a reference resolves as `{ id, path, fields }`
|
|
530
|
+
as deep as its field says, in lists and single reads alike, and the
|
|
531
|
+
types follow the depth; `smoodly_list_entries` (schema version 7)
|
|
532
|
+
filters, orders and limits over the snapshot shown, so `where` sees
|
|
533
|
+
localized fields and never the live row; a list is tagged by every
|
|
534
|
+
collection it resolved into; `seo: true` or `{ description, image }`
|
|
535
|
+
on a collection adds the SEO & Social panel, stored per locale under
|
|
536
|
+
`$seo` (`src/seo.ts`); every content table admits a write key only, the
|
|
537
|
+
publishable key serves sign-in and presence (`presenceConfig`). Tests:
|
|
538
|
+
`test/entry-store-contract.ts` ("query"), `test/site.test.tsx`,
|
|
539
|
+
`test/next-queries.test.tsx`, `test/types-refs.test.ts`,
|
|
540
|
+
`test/seo.test.ts`, `test/isolation.supabase.test.ts`,
|
|
541
|
+
`test/rls.supabase.test.ts`.
|
|
526
542
|
- Editor safety (spec 2026-09-27 editor safety): schema drift — stored
|
|
527
543
|
content the code no longer describes — is found by one pure walk
|
|
528
544
|
(`admin/drift.ts`) over the serialized registry. Broken findings
|
|
@@ -692,31 +708,7 @@ goes straight into its bucket. Design-level questions live in
|
|
|
692
708
|
|
|
693
709
|
### Now
|
|
694
710
|
|
|
695
|
-
|
|
696
|
-
title and a description field are the next step. *(2026-09-10, page fields and SEO)*
|
|
697
|
-
- `where` filters a versioned collection's LIVE row, not its published
|
|
698
|
-
snapshot: an entry whose published version had `kind: "x"` and whose
|
|
699
|
-
draft changed it to `"y"` drops out of `where: { kind: "x" }` before
|
|
700
|
-
the change is published. The published gate is per locale row, the
|
|
701
|
-
filter is on the node's shared fields. Fix by filtering the snapshot
|
|
702
|
-
(a join on the version row) or by documenting that a versioned
|
|
703
|
-
collection's shared fields are live for filtering. *(2026-09-11, queries)*
|
|
704
|
-
- `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
|
|
705
|
-
by path resolves them, `getEntry` by id or slug does not — document or
|
|
706
|
-
unify when a site needs resolved lists. Worse than it reads: the field
|
|
707
|
-
types promise resolved objects everywhere (`f.ref` builds
|
|
708
|
-
`Resolved<T>`), and `guides/collections/queries.mdx` says `getEntry`
|
|
709
|
-
"by id or by its URL path" resolves references, so a developer's code
|
|
710
|
-
typechecks and fails at runtime. Decide the rule and make the types,
|
|
711
|
-
the runtime and the guide agree. *(2026-09-05; raised to Now 2026-09-29)*
|
|
712
|
-
- The read API returns the site layer's shapes unprojected: `getEntries`
|
|
713
|
-
gives `{ entry: EntryRecord, path }` (the locales map, sort, parentId,
|
|
714
|
-
collection and timestamps ride along under `entry`), `getPage` gives
|
|
715
|
-
the renderer's `SitePage` (`record`, `tree`). Project in `queries.ts`,
|
|
716
|
-
at the public boundary, to `{ id, path, fields, updatedAt }` per entry
|
|
717
|
-
and a page without renderer internals; the tree item is already lean.
|
|
718
|
-
Do it before the Astro glue (OQ 29) wraps the same reads and before
|
|
719
|
-
a second site depends on the shape — it is a breaking change to `q`. *(2026-09-11, queries)*
|
|
711
|
+
Nothing open.
|
|
720
712
|
|
|
721
713
|
### Next
|
|
722
714
|
|
|
@@ -1199,13 +1191,17 @@ goes straight into its bucket. Design-level questions live in
|
|
|
1199
1191
|
|
|
1200
1192
|
#### Queries (`q`)
|
|
1201
1193
|
|
|
1202
|
-
-
|
|
1203
|
-
- `where`
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1194
|
+
- `presence()` serves the presence channel only; a future read gateway (DESIGN.md OQ 31) is not built on it — no key but a write key reads content. *(2026-09-26, concurrency; 2026-09-30 §8)*
|
|
1195
|
+
- `where` ranges and negation. *(2026-09-11, queries)*
|
|
1196
|
+
- `EntryItem.updatedAt`, and `order: updatedAt`, read the LIVE locale row, so a draft autosave bumps the public timestamp and can reorder a public list; read the published version's `saved_at` for published reads. *(2026-09-30, content API contract final review)*
|
|
1197
|
+
- The bare-slug `getEntry` fallback re-reads the whole collection and resolves every entry's references and media before matching one slug; match on `record` first, then resolve only the hit (or query by slug). *(2026-09-30, content API contract final review)*
|
|
1198
|
+
- `SmoodlySite.resolve` (and `load`) still return the full `EntryRecord` (every locale, `updatedBy`) on the object the developer holds; the types are unexported and the spec calls it internal — mark it `@internal` or move both off the public interface. *(2026-09-30, content API contract final review)*
|
|
1199
|
+
- A nested `.resolve({ a: { b: true } })` types only the top level (`Rebase<V, keyof P>`) while the runtime resolves deeper; type the nesting. *(2026-09-30, content API contract final review)*
|
|
1200
|
+
- Any-of over an array-valued field: any-of compares the text form of
|
|
1201
|
+
the field, so it covers selects, refs and numbers; on a checkboxes,
|
|
1202
|
+
refList or list field `site.getEntries` refuses it rather than match
|
|
1203
|
+
nothing — both adapters agree on the text form, so a one-element array
|
|
1204
|
+
never matches its element. *(2026-09-11, queries)*
|
|
1209
1205
|
- `getEntriesTree` has no `q` counterpart yet; add one when a site
|
|
1210
1206
|
needs a nested collection nav. *(2026-09-11, queries)*
|
|
1211
1207
|
- `getShared(["header", "footer", "contact"], { locale })` returning a
|
|
@@ -1278,12 +1274,10 @@ goes straight into its bucket. Design-level questions live in
|
|
|
1278
1274
|
locale's cached unit of that page (spec 2026-09-06 §4). A
|
|
1279
1275
|
`page:<id>:<locale>` tag would halve the revalidations on a bilingual
|
|
1280
1276
|
site. *(2026-09-06)*
|
|
1277
|
+
- Examples and guides write `url(entry.path!)`: `path` is `string | null` on every entry, though a collection that declares `path` always has one — type it `string` for those and drop the assertions. *(2026-09-30, content API contract final review)*
|
|
1281
1278
|
- `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
|
|
1282
1279
|
applies: a record whose `template` has no registration is a 404 body with
|
|
1283
1280
|
its meta emitted beside it. *(2026-09-05)*
|
|
1284
|
-
- The entry-page view receives the whole `EntryRecord`, every locale's
|
|
1285
|
-
row in `locales` included (fields, slug, status and version pointers); a
|
|
1286
|
-
client entry page would ship all of it to the browser. *(2026-09-05)*
|
|
1287
1281
|
|
|
1288
1282
|
#### Media
|
|
1289
1283
|
|
|
@@ -1439,9 +1433,6 @@ goes straight into its bucket. Design-level questions live in
|
|
|
1439
1433
|
actor-free parts. *(2026-09-13, history)*
|
|
1440
1434
|
- A GIN index on `entries.fields` when a customer's collection is large
|
|
1441
1435
|
enough to need it; additive, no migration. *(2026-09-11, queries)*
|
|
1442
|
-
- `getEntry` by id goes through `site.getEntry`, which lists the whole
|
|
1443
|
-
collection to find one row; a primary-key read first would be cheaper
|
|
1444
|
-
on the uncached path (draft mode, first miss). *(2026-09-11, queries)*
|
|
1445
1436
|
- Every revalidating op (publish, delete, a save of published content)
|
|
1446
1437
|
answers with a fresh RSC render of the admin route — the layout's
|
|
1447
1438
|
styles included, ~30 KB — because `updateTag` runs inside the server
|
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
|
}
|
|
@@ -239,6 +262,10 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
|
|
|
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
|
}
|