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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
2
|
+
import type { EntryRecord } from "./entry-store.ts";
|
|
3
|
+
import type { LocaleAlternate, PageAlternate } from "./paths.ts";
|
|
4
|
+
import type { PageLink } from "./render.tsx";
|
|
5
|
+
import type { ResolvedImage } from "./media.ts";
|
|
6
|
+
import type { SitePage } from "./site.ts";
|
|
7
|
+
import type { StyleProps } from "./styles.ts";
|
|
8
|
+
/** A resolved reference: the target in the read's locale (§2). */
|
|
9
|
+
export type EntryRef<F = Record<string, unknown>> = {
|
|
10
|
+
id: string;
|
|
11
|
+
path: string | null;
|
|
12
|
+
fields: F;
|
|
13
|
+
};
|
|
14
|
+
/** The effective SEO values: an editor's override where set, the fallback otherwise (§3, §7). */
|
|
15
|
+
export type Seo = {
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
image?: ResolvedImage;
|
|
19
|
+
noindex?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type EntryItem<F = Record<string, unknown>> = {
|
|
22
|
+
id: string;
|
|
23
|
+
path: string | null;
|
|
24
|
+
locale: string;
|
|
25
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
26
|
+
updatedAt: number;
|
|
27
|
+
fields: F;
|
|
28
|
+
seo?: Seo;
|
|
29
|
+
};
|
|
30
|
+
export type PageItem<F = Record<string, unknown>> = {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
path: string;
|
|
34
|
+
locale: string;
|
|
35
|
+
locales: Record<string, PageAlternate | null>;
|
|
36
|
+
template: string;
|
|
37
|
+
fields: F;
|
|
38
|
+
seo?: Seo;
|
|
39
|
+
ancestors: PageLink[];
|
|
40
|
+
children: PageLink[];
|
|
41
|
+
};
|
|
42
|
+
export type GlobalItem<F = Record<string, unknown>, S = Record<string, unknown>> = {
|
|
43
|
+
locale: string;
|
|
44
|
+
fields: F;
|
|
45
|
+
settings?: S;
|
|
46
|
+
styles?: StyleProps;
|
|
47
|
+
};
|
|
48
|
+
/** A visual item's read: its settings and styles always present. */
|
|
49
|
+
export type VisualGlobalItem<F = Record<string, unknown>, S = Record<string, unknown>> = GlobalItem<F, S> & {
|
|
50
|
+
settings: S;
|
|
51
|
+
styles: StyleProps;
|
|
52
|
+
};
|
|
53
|
+
/** One entry as a read returns it: Smoodly's keys, the content under `fields`; the record never leaves the site layer. */
|
|
54
|
+
export declare function toEntryItem(record: EntryRecord, fields: Record<string, unknown>, o: {
|
|
55
|
+
path: string | null;
|
|
56
|
+
locale: string;
|
|
57
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
58
|
+
seo?: Seo;
|
|
59
|
+
}): EntryItem;
|
|
60
|
+
/** One page as a read returns it: no record, no tree. */
|
|
61
|
+
export declare function toPageItem(hit: SitePage): PageItem;
|
|
62
|
+
/** Every collection a read of `schema` resolves into, by each ref field's own depth (the mirror of
|
|
63
|
+
* runResolution's modes) — a list unit's extra tags (spec 2026-09-29 content API contract §6). */
|
|
64
|
+
export declare function referencedCollections(schema: CollectionSchema<any>, all: CollectionSchema<any>[]): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SEO_KEY } from "./seo.js";
|
|
2
|
+
/** One entry as a read returns it: Smoodly's keys, the content under `fields`; the record never leaves the site layer. */
|
|
3
|
+
export function toEntryItem(record, fields, o) {
|
|
4
|
+
// The reserved `$seo` values are Smoodly's: they leave as `seo`, never as a field.
|
|
5
|
+
const { [SEO_KEY]: _seo, ...rest } = fields;
|
|
6
|
+
return {
|
|
7
|
+
id: record.id, path: o.path, locale: o.locale, locales: o.locales,
|
|
8
|
+
updatedAt: record.locales[o.locale]?.updatedAt ?? record.updatedAt, fields: rest,
|
|
9
|
+
...(o.seo ? { seo: o.seo } : {}),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** One page as a read returns it: no record, no tree. */
|
|
13
|
+
export function toPageItem(hit) {
|
|
14
|
+
return {
|
|
15
|
+
id: hit.record.id, title: hit.title, path: hit.path, locale: hit.locale, locales: hit.locales,
|
|
16
|
+
template: hit.template, fields: hit.fields, ...(hit.seo ? { seo: hit.seo } : {}),
|
|
17
|
+
ancestors: hit.ancestors, children: hit.children,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Every collection a read of `schema` resolves into, by each ref field's own depth (the mirror of
|
|
21
|
+
* runResolution's modes) — a list unit's extra tags (spec 2026-09-29 content API contract §6). */
|
|
22
|
+
export function referencedCollections(schema, all) {
|
|
23
|
+
const byName = new Map(all.map((c) => [c.name, c]));
|
|
24
|
+
const out = new Set();
|
|
25
|
+
const walk = (s, mode, seen) => {
|
|
26
|
+
for (const [key, raw] of Object.entries(s.fields)) {
|
|
27
|
+
const d = raw;
|
|
28
|
+
if (d.type !== "ref" && d.type !== "refList")
|
|
29
|
+
continue;
|
|
30
|
+
let next;
|
|
31
|
+
if (mode === "own")
|
|
32
|
+
next = d.resolve === "full" ? "full" : d.resolve !== null && typeof d.resolve === "object" ? d.resolve : "shallow";
|
|
33
|
+
else if (mode === "full")
|
|
34
|
+
next = "full";
|
|
35
|
+
else if (mode === "shallow")
|
|
36
|
+
continue;
|
|
37
|
+
else {
|
|
38
|
+
const on = mode[key];
|
|
39
|
+
if (!on)
|
|
40
|
+
continue;
|
|
41
|
+
next = typeof on === "object" ? on : "shallow";
|
|
42
|
+
}
|
|
43
|
+
const name = d.target().name;
|
|
44
|
+
out.add(name);
|
|
45
|
+
const target = byName.get(name);
|
|
46
|
+
if (next !== "shallow" && target && !seen.has(name))
|
|
47
|
+
walk(target, next, new Set([...seen, name]));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(schema, "own", new Set([schema.name]));
|
|
51
|
+
return [...out];
|
|
52
|
+
}
|
package/dist/render.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ComponentType, type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import type { PageAlternate } from "./paths.ts";
|
|
3
3
|
import type { PageSchema } from "./page.ts";
|
|
4
|
-
import type { BuilderMap,
|
|
4
|
+
import type { BuilderMap, Values } from "./schema.ts";
|
|
5
5
|
import { type StyleValues } from "./styles.ts";
|
|
6
6
|
import { type CustomOptions } from "./custom/index.ts";
|
|
7
7
|
export type TreeNode = {
|
|
@@ -96,10 +96,10 @@ export type PageViewProps<Zones extends Record<string, unknown>, Fields = Record
|
|
|
96
96
|
zones: Record<keyof Zones & string, ZoneContent>;
|
|
97
97
|
page: PageContext<Fields>;
|
|
98
98
|
};
|
|
99
|
-
export type PairedPage<Zones extends Record<string, unknown>, F extends BuilderMap = BuilderMap> = ((props: PageViewProps<Zones,
|
|
99
|
+
export type PairedPage<Zones extends Record<string, unknown>, F extends BuilderMap = BuilderMap> = ((props: PageViewProps<Zones, Values<F>>) => ReactElement) & {
|
|
100
100
|
schema: PageSchema<Zones, F>;
|
|
101
101
|
};
|
|
102
|
-
export declare function page<Zones extends Record<string, unknown>, F extends BuilderMap = {}>(schema: PageSchema<Zones, F>, View: ComponentType<PageViewProps<Zones,
|
|
102
|
+
export declare function page<Zones extends Record<string, unknown>, F extends BuilderMap = {}>(schema: PageSchema<Zones, F>, View: ComponentType<PageViewProps<Zones, Values<F>>>): PairedPage<Zones, F>;
|
|
103
103
|
export type Registry = {
|
|
104
104
|
sections?: AnyPaired[];
|
|
105
105
|
elements?: AnyPaired[];
|
package/dist/resolve.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { type ElementSchemas } from "./richtext-walk.ts";
|
|
|
7
7
|
import type { SectionLike } from "./refs.ts";
|
|
8
8
|
import type { Toolset } from "./toolset.ts";
|
|
9
9
|
export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
10
|
+
/** id → the entry's live full path in the read's locale; ids without one are absent. */
|
|
11
|
+
export type PathLookup = (collection: string, ids: string[]) => Promise<Record<string, string>>;
|
|
10
12
|
export type AssetFetcher = (ids: string[]) => Promise<Record<string, AssetRecord>>;
|
|
11
13
|
type SchemaLike = {
|
|
12
14
|
name: string;
|
|
@@ -25,6 +27,8 @@ type ResolveOptions = {
|
|
|
25
27
|
})[];
|
|
26
28
|
collections: CollectionSchema<any>[];
|
|
27
29
|
fetch: EntryFetcher;
|
|
30
|
+
/** Each hydrated ref's address (spec 2026-09-29 content API contract §2). */
|
|
31
|
+
paths?: PathLookup;
|
|
28
32
|
/** Media (spec 2026-09-08 §9): one batched asset fetch per page, values become resolved objects. */
|
|
29
33
|
assets?: AssetFetcher;
|
|
30
34
|
/** The registered global items a placement node may name (spec 2026-09-07 §4). */
|
|
@@ -52,6 +56,8 @@ export declare function resolveMediaSets(sets: MediaSet[], fetch: AssetFetcher,
|
|
|
52
56
|
export type ResolveFieldsOptions = {
|
|
53
57
|
collections: CollectionSchema<any>[];
|
|
54
58
|
fetch: EntryFetcher;
|
|
59
|
+
/** Each hydrated ref's address (spec 2026-09-29 content API contract §2). */
|
|
60
|
+
paths?: PathLookup;
|
|
55
61
|
assets?: AssetFetcher;
|
|
56
62
|
/** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
|
|
57
63
|
elements?: SectionLike[];
|
|
@@ -60,4 +66,10 @@ export type ResolveFieldsOptions = {
|
|
|
60
66
|
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
|
61
67
|
* resolveTree. Same rules, same batching, same cycle cut. */
|
|
62
68
|
export declare function resolveFields(fields: Record<string, unknown>, descriptors: Record<string, Descriptor>, options: ResolveFieldsOptions): Promise<Record<string, unknown>>;
|
|
69
|
+
/** Many value sets (a list's entries), one resolution: every ref batched per
|
|
70
|
+
* collection across all sets, then one media round — all in place. */
|
|
71
|
+
export declare function resolveFieldSets(sets: {
|
|
72
|
+
values: Record<string, unknown>;
|
|
73
|
+
descriptors: Record<string, Descriptor>;
|
|
74
|
+
}[], options: ResolveFieldsOptions): Promise<void>;
|
|
63
75
|
export {};
|
package/dist/resolve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The batched ref-resolution pass (DESIGN.md §3): one walk collects every
|
|
2
2
|
// ref in the tree, fetches per collection in single batched calls, and
|
|
3
|
-
// hands components hydrated entries. The ref site alone decides depth:
|
|
3
|
+
// hands components hydrated entries as `{ id, path, fields }` (spec 2026-09-29 content API contract §2). The ref site alone decides depth:
|
|
4
4
|
// bare refs hydrate one entry (refs stay ids), `.resolve("key")` follows
|
|
5
5
|
// the named refs one level, and bare `.resolve()` follows the whole chain.
|
|
6
6
|
// Cycles are cut per path — a ref back to an entry already on its own
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { globalLink, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
|
|
10
10
|
import { applyAssets, collectAssetIds } from "./media.js";
|
|
11
11
|
import { elementSchemas } from "./richtext-walk.js";
|
|
12
|
+
import { SEO_KEY } from "./seo.js";
|
|
12
13
|
import { CUSTOM_NODE_TYPE, customSchema, customToolset } from "./custom/index.js";
|
|
13
14
|
/** The built-in SEO set's one media value (spec 2026-09-10 page fields §2). */
|
|
14
15
|
const META_DESCRIPTORS = { ogImage: { type: "image" } };
|
|
@@ -47,7 +48,7 @@ export async function resolveTree(tree, options) {
|
|
|
47
48
|
}
|
|
48
49
|
if (options.page && resolved.fields)
|
|
49
50
|
collectTasks(resolved.fields, options.page.fields, tasks);
|
|
50
|
-
const hydrated = await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
51
|
+
const hydrated = await runResolution(tasks, { collections: options.collections, fetch: options.fetch, paths: options.paths, maxDepth });
|
|
51
52
|
if (options.assets) {
|
|
52
53
|
const sets = [];
|
|
53
54
|
for (const node of Object.values(resolved.zones ?? {}).flat()) {
|
|
@@ -150,6 +151,16 @@ export async function resolveFields(fields, descriptors, options) {
|
|
|
150
151
|
await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets, elementSchemas(options.elements));
|
|
151
152
|
return resolved;
|
|
152
153
|
}
|
|
154
|
+
/** Many value sets (a list's entries), one resolution: every ref batched per
|
|
155
|
+
* collection across all sets, then one media round — all in place. */
|
|
156
|
+
export async function resolveFieldSets(sets, options) {
|
|
157
|
+
const tasks = [];
|
|
158
|
+
for (const { values, descriptors } of sets)
|
|
159
|
+
collectTasks(values, descriptors, tasks);
|
|
160
|
+
const hydrated = await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
|
|
161
|
+
if (options.assets)
|
|
162
|
+
await resolveMediaSets([...sets, ...hydrated], options.assets, elementSchemas(options.elements));
|
|
163
|
+
}
|
|
153
164
|
/** Hydrates every task's refs in place and returns each hydrated entry
|
|
154
165
|
* copy with its collection's descriptors — the media pass walks those
|
|
155
166
|
* too, so a card reached through a ref carries its image URL. */
|
|
@@ -157,6 +168,7 @@ async function runResolution(seeds, options) {
|
|
|
157
168
|
const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
|
|
158
169
|
// every entry is fetched once; cycle cuts reuse cached ancestors
|
|
159
170
|
const cache = new Map();
|
|
171
|
+
const pathCache = new Map();
|
|
160
172
|
const hydrated = [];
|
|
161
173
|
let tasks = seeds;
|
|
162
174
|
for (let round = 0; round < options.maxDepth && tasks.length > 0; round++) {
|
|
@@ -177,6 +189,14 @@ async function runResolution(seeds, options) {
|
|
|
177
189
|
for (const [id, entry] of Object.entries(fetched))
|
|
178
190
|
table.set(id, entry);
|
|
179
191
|
cache.set(collection, table);
|
|
192
|
+
// a collection with no path has no addresses: its refs carry path null
|
|
193
|
+
if (options.paths && collectionSchemas.get(collection)?.path !== undefined) {
|
|
194
|
+
const found = await options.paths(collection, [...ids]);
|
|
195
|
+
const paths = pathCache.get(collection) ?? new Map();
|
|
196
|
+
for (const [id, p] of Object.entries(found))
|
|
197
|
+
paths.set(id, p);
|
|
198
|
+
pathCache.set(collection, paths);
|
|
199
|
+
}
|
|
180
200
|
}));
|
|
181
201
|
// apply results and queue follow-ups per the task's mode
|
|
182
202
|
const next = [];
|
|
@@ -185,19 +205,21 @@ async function runResolution(seeds, options) {
|
|
|
185
205
|
const raw = cache.get(task.collection)?.get(id);
|
|
186
206
|
if (!raw)
|
|
187
207
|
return null;
|
|
188
|
-
|
|
208
|
+
// The SEO panel's values are the entry page's, never a reference's (and would cost a media fetch).
|
|
209
|
+
const { [SEO_KEY]: _seo, ...entry } = structuredClone(raw);
|
|
210
|
+
const ref = { id, path: pathCache.get(task.collection)?.get(id) ?? null, fields: entry };
|
|
189
211
|
const schema = collectionSchemas.get(task.collection);
|
|
190
212
|
if (schema)
|
|
191
213
|
hydrated.push({ values: entry, descriptors: schema.fields });
|
|
192
214
|
const pathKey = `${task.collection}:${id}`;
|
|
193
215
|
// cycle: this entry is its own ancestor — shallow copy, no recursion
|
|
194
216
|
if (task.ancestors.includes(pathKey))
|
|
195
|
-
return
|
|
217
|
+
return ref;
|
|
196
218
|
if (task.mode === "shallow")
|
|
197
|
-
return
|
|
219
|
+
return ref;
|
|
198
220
|
const targetSchema = collectionSchemas.get(task.collection);
|
|
199
221
|
if (!targetSchema)
|
|
200
|
-
return
|
|
222
|
+
return ref;
|
|
201
223
|
const ancestors = [...task.ancestors, pathKey];
|
|
202
224
|
for (const [key, d] of Object.entries(targetSchema.fields)) {
|
|
203
225
|
if (d.type !== "ref" && d.type !== "refList")
|
|
@@ -225,7 +247,7 @@ async function runResolution(seeds, options) {
|
|
|
225
247
|
ancestors,
|
|
226
248
|
});
|
|
227
249
|
}
|
|
228
|
-
return
|
|
250
|
+
return ref;
|
|
229
251
|
};
|
|
230
252
|
task.holder[task.key] = task.isList
|
|
231
253
|
? task.id.map(hydrate)
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
2
|
* that adds a table, column, function or policy the package reads; a
|
|
3
3
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
-
export declare const SCHEMA_VERSION =
|
|
4
|
+
export declare const SCHEMA_VERSION = 7;
|
|
5
5
|
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
6
|
export declare const CORE_FILE_RE: RegExp;
|
|
7
7
|
export declare const VIEWS_FILE_RE: RegExp;
|
package/dist/schema-version.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
8
|
* that adds a table, column, function or policy the package reads; a
|
|
9
9
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 7;
|
|
11
11
|
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
12
|
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
13
|
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Descriptor, type FieldBuilder, type ValueOf } from "./fields.ts";
|
|
2
2
|
import { pageSchema } from "./page.ts";
|
|
3
|
+
import type { RefTo } from "./fields.ts";
|
|
4
|
+
import type { EntryRef } from "./read-shapes.ts";
|
|
3
5
|
export type BuilderMap = Record<string, FieldBuilder<any, any>>;
|
|
4
6
|
export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
|
|
5
7
|
kind: "section";
|
|
@@ -37,6 +39,25 @@ export type NamespaceValues<M extends BuilderMap> = {
|
|
|
37
39
|
} & {
|
|
38
40
|
[K in OptionalKeys<M>]?: ValueOf<M[K]>;
|
|
39
41
|
};
|
|
42
|
+
/** The target's stored entry values (refs still as RefTo markers). */
|
|
43
|
+
type EntryOf<T> = T extends {
|
|
44
|
+
__entry?: infer E;
|
|
45
|
+
} ? NonNullable<E> : Record<string, unknown>;
|
|
46
|
+
/** A target's fields as a ref site with mode M sees them (the ref site alone decides depth, DESIGN.md §3). */
|
|
47
|
+
export type ShapeOf<T, M> = {
|
|
48
|
+
[K in keyof EntryOf<T>]: FieldAt<EntryOf<T>[K], M, K>;
|
|
49
|
+
};
|
|
50
|
+
type FieldAt<V, M, K> = V extends RefTo<infer U, any> ? M extends "full" ? EntryRef<ShapeOf<U, "full">> | null : K extends M ? EntryRef<ShapeOf<U, "shallow">> | null : string | null : V extends RefTo<infer U, any>[] ? M extends "full" ? (EntryRef<ShapeOf<U, "full">> | null)[] : K extends M ? (EntryRef<ShapeOf<U, "shallow">> | null)[] : string[] : V;
|
|
51
|
+
/** One value as a read returns it: a ref becomes an EntryRef (or null), a refList a list of them. */
|
|
52
|
+
export type Materialize<V> = V extends RefTo<infer T, infer M> ? EntryRef<ShapeOf<T, M>> | null : V extends RefTo<infer T, infer M>[] ? (EntryRef<ShapeOf<T, M>> | null)[] : V;
|
|
53
|
+
/** What a view and a read receive (spec 2026-09-29 content API contract §4). */
|
|
54
|
+
export type Values<M extends BuilderMap> = {
|
|
55
|
+
[K in keyof NamespaceValues<M>]: Materialize<NamespaceValues<M>[K]>;
|
|
56
|
+
};
|
|
57
|
+
/** A sample stores ids where a read shows references. */
|
|
58
|
+
type Stored<M extends BuilderMap> = {
|
|
59
|
+
[K in keyof NamespaceValues<M>]: NamespaceValues<M>[K] extends RefTo<any, any> ? string : NamespaceValues<M>[K] extends RefTo<any, any>[] ? string[] : NamespaceValues<M>[K];
|
|
60
|
+
};
|
|
40
61
|
type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
|
|
41
62
|
name: string;
|
|
42
63
|
title: string;
|
|
@@ -50,7 +71,7 @@ type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
|
|
|
50
71
|
omit: string[];
|
|
51
72
|
};
|
|
52
73
|
css?: false;
|
|
53
|
-
sample?: Partial<
|
|
74
|
+
sample?: Partial<Stored<F> & Stored<S>>;
|
|
54
75
|
};
|
|
55
76
|
export declare const schema: {
|
|
56
77
|
page: typeof pageSchema;
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Descriptor } from "./fields.ts";
|
|
2
|
+
import type { Seo } from "./read-shapes.ts";
|
|
3
|
+
/** The reserved key an entry's SEO values live under; a collection may not declare a field of this name. */
|
|
4
|
+
export declare const SEO_KEY = "$seo";
|
|
5
|
+
/** `seo: true` falls back on the title only; the object names the fields the description and the image fall back on. */
|
|
6
|
+
export type CollectionSeo = true | {
|
|
7
|
+
description?: string;
|
|
8
|
+
image?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const SEO_DESCRIPTOR: Descriptor;
|
|
11
|
+
export declare function validateCollectionSeo(name: string, seo: CollectionSeo, fields: Record<string, Descriptor>): void;
|
|
12
|
+
/** An entry's effective SEO: the editor's `$seo` values where set, the fallbacks the collection names otherwise. */
|
|
13
|
+
export declare function entrySeo(fields: Record<string, unknown>, schema: {
|
|
14
|
+
titleField?: string;
|
|
15
|
+
seo?: CollectionSeo;
|
|
16
|
+
}): Seo;
|
|
17
|
+
/** A page's effective SEO from its stored meta; the title falls back to the page title. */
|
|
18
|
+
export declare function pageSeo(meta: Record<string, unknown> | undefined, title: string): Seo;
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The reserved key an entry's SEO values live under; a collection may not declare a field of this name. */
|
|
2
|
+
export const SEO_KEY = "$seo";
|
|
3
|
+
export const SEO_DESCRIPTOR = {
|
|
4
|
+
type: "object",
|
|
5
|
+
optional: true,
|
|
6
|
+
localized: true,
|
|
7
|
+
fields: {
|
|
8
|
+
title: { type: "text", optional: true },
|
|
9
|
+
description: { type: "text", optional: true },
|
|
10
|
+
ogImage: { type: "image", optional: true },
|
|
11
|
+
noindex: { type: "boolean", optional: true },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export function validateCollectionSeo(name, seo, fields) {
|
|
15
|
+
if (seo === true)
|
|
16
|
+
return;
|
|
17
|
+
for (const [role, key] of Object.entries(seo)) {
|
|
18
|
+
if (key === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
const d = fields[key];
|
|
21
|
+
if (!d)
|
|
22
|
+
throw new Error(`smoodly: collection "${name}" seo.${role} names "${key}", which is not a field.`);
|
|
23
|
+
if (role === "description" && d.type !== "text")
|
|
24
|
+
throw new Error(`smoodly: collection "${name}" seo.description "${key}" must be a text field, not ${d.type}.`);
|
|
25
|
+
if (role === "image" && d.type !== "image")
|
|
26
|
+
throw new Error(`smoodly: collection "${name}" seo.image "${key}" must be an image field, not ${d.type}.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : undefined);
|
|
30
|
+
const image = (v) => v !== null && typeof v === "object" && typeof v.url === "string" ? v : undefined;
|
|
31
|
+
const compact = (s) => Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined));
|
|
32
|
+
/** An entry's effective SEO: the editor's `$seo` values where set, the fallbacks the collection names otherwise. */
|
|
33
|
+
export function entrySeo(fields, schema) {
|
|
34
|
+
const raw = fields[SEO_KEY];
|
|
35
|
+
const own = raw !== null && typeof raw === "object" ? raw : {};
|
|
36
|
+
const map = schema.seo === true || schema.seo === undefined ? {} : schema.seo;
|
|
37
|
+
return compact({
|
|
38
|
+
title: str(own.title) ?? (schema.titleField ? str(fields[schema.titleField]) : undefined),
|
|
39
|
+
description: str(own.description) ?? (map.description ? str(fields[map.description]) : undefined),
|
|
40
|
+
image: image(own.ogImage) ?? (map.image ? image(fields[map.image]) : undefined),
|
|
41
|
+
noindex: own.noindex === true ? true : undefined,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** A page's effective SEO from its stored meta; the title falls back to the page title. */
|
|
45
|
+
export function pageSeo(meta, title) {
|
|
46
|
+
const m = meta ?? {};
|
|
47
|
+
return compact({
|
|
48
|
+
title: str(m.title) ?? str(title),
|
|
49
|
+
description: str(m.description),
|
|
50
|
+
image: image(m.ogImage),
|
|
51
|
+
noindex: m.noindex === true ? true : undefined,
|
|
52
|
+
});
|
|
53
|
+
}
|
package/dist/site.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type StyleValues, type StyleValuesInput } from "./styles.ts";
|
|
|
10
10
|
import { type CustomConfigInput, type ResolvedCustom } from "./custom/index.ts";
|
|
11
11
|
import type { PageRecord, PageStore } from "./store.ts";
|
|
12
12
|
import type { PathHit } from "./path-index.ts";
|
|
13
|
+
import { type EntryItem, type PageItem, type Seo } from "./read-shapes.ts";
|
|
13
14
|
export type SiteOptions = {
|
|
14
15
|
stores: () => {
|
|
15
16
|
pages: PageStore;
|
|
@@ -40,6 +41,7 @@ export type SitePageSchema = {
|
|
|
40
41
|
title?: string;
|
|
41
42
|
slug?: string | Record<string, string>;
|
|
42
43
|
fields?: Record<string, Descriptor>;
|
|
44
|
+
seo?: true;
|
|
43
45
|
};
|
|
44
46
|
};
|
|
45
47
|
export type SitePage = {
|
|
@@ -58,6 +60,8 @@ export type SitePage = {
|
|
|
58
60
|
/** One key per supported locale: the page's `{ slug, path }` there, or null where it is
|
|
59
61
|
* absent or, outside draft mode, unpublished (spec 2026-09-15 §1). */
|
|
60
62
|
locales: Record<string, PageAlternate | null>;
|
|
63
|
+
/** The effective SEO values, for a template with `seo: true` (spec 2026-09-29 content API contract §7). */
|
|
64
|
+
seo?: Seo;
|
|
61
65
|
};
|
|
62
66
|
export type SiteEntry = {
|
|
63
67
|
kind: "entry";
|
|
@@ -70,6 +74,8 @@ export type SiteEntry = {
|
|
|
70
74
|
entry: EntryRecord;
|
|
71
75
|
path: string;
|
|
72
76
|
locales: Record<string, LocaleAlternate | null>;
|
|
77
|
+
/** The effective SEO values, for a collection with `seo` (§7). */
|
|
78
|
+
seo?: Seo;
|
|
73
79
|
};
|
|
74
80
|
export type SiteHit = SitePage | SiteEntry;
|
|
75
81
|
export type PageTreeItem = {
|
|
@@ -80,13 +86,7 @@ export type PageTreeItem = {
|
|
|
80
86
|
fields: Record<string, unknown>;
|
|
81
87
|
children: PageTreeItem[];
|
|
82
88
|
};
|
|
83
|
-
export type
|
|
84
|
-
entry: EntryRecord;
|
|
85
|
-
path: string | null;
|
|
86
|
-
locale: string;
|
|
87
|
-
locales: Record<string, LocaleAlternate | null>;
|
|
88
|
-
};
|
|
89
|
-
export type EntryTreeItem = SiteEntryItem & {
|
|
89
|
+
export type EntryTreeItem = EntryItem & {
|
|
90
90
|
children: EntryTreeItem[];
|
|
91
91
|
};
|
|
92
92
|
/** `where`, `limit`, `parent` and `order` run in the store (spec 2026-09-11 §3); `draft` reads live rows inside the editor preview. */
|
|
@@ -97,6 +97,8 @@ export type EntryQuery = {
|
|
|
97
97
|
where?: Where;
|
|
98
98
|
limit?: number;
|
|
99
99
|
draft?: boolean;
|
|
100
|
+
/** Only these entries (`getEntry` by id). */
|
|
101
|
+
ids?: string[];
|
|
100
102
|
};
|
|
101
103
|
/** `version`: a stored page version to render instead of the draft — History (spec 2026-09-13 §4), draft mode only. */
|
|
102
104
|
export type LoadOptions = {
|
|
@@ -124,26 +126,23 @@ export interface SmoodlySite {
|
|
|
124
126
|
getPage(pathOrId: string, options?: {
|
|
125
127
|
locale?: string;
|
|
126
128
|
draft?: boolean;
|
|
127
|
-
}): Promise<
|
|
128
|
-
getEntries(collection: string, options?: EntryQuery): Promise<
|
|
129
|
+
}): Promise<PageItem | null>;
|
|
130
|
+
getEntries(collection: string, options?: EntryQuery): Promise<EntryItem[]>;
|
|
129
131
|
getEntry(collection: string, idOrPath: string, options?: {
|
|
130
132
|
locale?: string;
|
|
131
133
|
draft?: boolean;
|
|
132
|
-
}): Promise<
|
|
134
|
+
}): Promise<EntryItem | null>;
|
|
133
135
|
getEntriesTree(collection: string, options?: {
|
|
134
136
|
locale?: string;
|
|
135
137
|
draft?: boolean;
|
|
136
138
|
}): Promise<EntryTreeItem[]>;
|
|
137
|
-
/** A global item's content in a locale (spec 2026-09-07 §4):
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* unpublished (outside draft mode). */
|
|
139
|
+
/** A global item's content in a locale (spec 2026-09-07 §4): `{ locale, fields }`,
|
|
140
|
+
* plus `settings` and `styles` for a visual item, refs hydrated; null when the
|
|
141
|
+
* item has no row there or is unpublished (outside draft mode). */
|
|
141
142
|
getGlobal<V>(item: GlobalSchema<V>, options?: {
|
|
142
143
|
locale?: string;
|
|
143
144
|
draft?: boolean;
|
|
144
145
|
}): Promise<V | null>;
|
|
145
146
|
}
|
|
146
|
-
/** The `page` prop for a loaded page. `href` is the app's seam from a
|
|
147
|
-
* locale-relative path to a site URL; the default keeps CMS paths as URLs. */
|
|
148
147
|
export declare function pageContextOf(hit: SitePage, href?: (path: string, locale: string) => string): PageContext;
|
|
149
148
|
export declare function createSmoodlySite(options: SiteOptions): SmoodlySite;
|