smoodly 0.0.1
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/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/admin/auth.d.ts +10 -0
- package/dist/admin/auth.js +28 -0
- package/dist/admin/client-ops.d.ts +2 -0
- package/dist/admin/client-ops.js +18 -0
- package/dist/admin/editor/EditorView.d.ts +7 -0
- package/dist/admin/editor/EditorView.js +391 -0
- package/dist/admin/editor/autosave.d.ts +31 -0
- package/dist/admin/editor/autosave.js +108 -0
- package/dist/admin/editor/protocol.d.ts +37 -0
- package/dist/admin/editor/protocol.js +1 -0
- package/dist/admin/forms/FieldWidget.d.ts +14 -0
- package/dist/admin/forms/FieldWidget.js +54 -0
- package/dist/admin/forms/tabs.d.ts +11 -0
- package/dist/admin/forms/tabs.js +20 -0
- package/dist/admin/index.d.ts +10 -0
- package/dist/admin/index.js +8 -0
- package/dist/admin/next/bridge.d.ts +1 -0
- package/dist/admin/next/bridge.js +66 -0
- package/dist/admin/next/create-admin.d.ts +18 -0
- package/dist/admin/next/create-admin.js +14 -0
- package/dist/admin/next/index.d.ts +6 -0
- package/dist/admin/next/index.js +3 -0
- package/dist/admin/next/op-handler.d.ts +11 -0
- package/dist/admin/next/op-handler.js +23 -0
- package/dist/admin/ops-impl.d.ts +20 -0
- package/dist/admin/ops-impl.js +189 -0
- package/dist/admin/ops.d.ts +52 -0
- package/dist/admin/ops.js +1 -0
- package/dist/admin/page-path.d.ts +1 -0
- package/dist/admin/page-path.js +6 -0
- package/dist/admin/richtext.d.ts +14 -0
- package/dist/admin/richtext.js +17 -0
- package/dist/admin/serialize.d.ts +40 -0
- package/dist/admin/serialize.js +49 -0
- package/dist/admin/shell/AdminApp.d.ts +9 -0
- package/dist/admin/shell/AdminApp.js +122 -0
- package/dist/admin/shell/EntryForm.d.ts +8 -0
- package/dist/admin/shell/EntryForm.js +96 -0
- package/dist/admin/shell/ListView.d.ts +15 -0
- package/dist/admin/shell/ListView.js +14 -0
- package/dist/admin/shell/LoginView.d.ts +5 -0
- package/dist/admin/shell/LoginView.js +47 -0
- package/dist/admin/shell/pages-list.d.ts +22 -0
- package/dist/admin/shell/pages-list.js +31 -0
- package/dist/admin/shell/session.d.ts +19 -0
- package/dist/admin/shell/session.js +37 -0
- package/dist/admin/supabase-auth.d.ts +7 -0
- package/dist/admin/supabase-auth.js +23 -0
- package/dist/admin/tree-ops.d.ts +19 -0
- package/dist/admin/tree-ops.js +112 -0
- package/dist/admin/validate.d.ts +10 -0
- package/dist/admin/validate.js +19 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +19 -0
- package/dist/collections.d.ts +39 -0
- package/dist/collections.js +22 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +54 -0
- package/dist/entry-store.d.ts +74 -0
- package/dist/entry-store.js +132 -0
- package/dist/env.d.ts +16 -0
- package/dist/env.js +22 -0
- package/dist/fields.d.ts +67 -0
- package/dist/fields.js +41 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +29 -0
- package/dist/next/index.d.ts +3 -0
- package/dist/next/index.js +2 -0
- package/dist/next/meta.d.ts +10 -0
- package/dist/next/meta.js +37 -0
- package/dist/next/page-renderer.d.ts +36 -0
- package/dist/next/page-renderer.js +69 -0
- package/dist/page.d.ts +22 -0
- package/dist/page.js +35 -0
- package/dist/pairing.d.ts +33 -0
- package/dist/pairing.js +26 -0
- package/dist/ref-index.d.ts +19 -0
- package/dist/ref-index.js +0 -0
- package/dist/refs.d.ts +20 -0
- package/dist/refs.js +49 -0
- package/dist/render.d.ts +48 -0
- package/dist/render.js +41 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +128 -0
- package/dist/revalidate.d.ts +8 -0
- package/dist/revalidate.js +34 -0
- package/dist/schema.d.ts +57 -0
- package/dist/schema.js +40 -0
- package/dist/section-wrapper.d.ts +11 -0
- package/dist/section-wrapper.js +19 -0
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +172 -0
- package/dist/sql.d.ts +3 -0
- package/dist/sql.js +162 -0
- package/dist/store.d.ts +73 -0
- package/dist/store.js +145 -0
- package/dist/supabase-entry-store.d.ts +26 -0
- package/dist/supabase-entry-store.js +191 -0
- package/dist/supabase-store.d.ts +26 -0
- package/dist/supabase-store.js +180 -0
- package/dist/zones.d.ts +31 -0
- package/dist/zones.js +23 -0
- package/package.json +50 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export function zoneOf(tree, id) {
|
|
2
|
+
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
3
|
+
if (nodes.some((n) => n.id === id))
|
|
4
|
+
return zone;
|
|
5
|
+
}
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
export function findNode(tree, id) {
|
|
9
|
+
for (const nodes of Object.values(tree.zones)) {
|
|
10
|
+
const hit = nodes.find((n) => n.id === id);
|
|
11
|
+
if (hit)
|
|
12
|
+
return hit;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const withZone = (tree, zone, nodes) => ({
|
|
17
|
+
...tree,
|
|
18
|
+
zones: { ...tree.zones, [zone]: nodes },
|
|
19
|
+
});
|
|
20
|
+
export function insertNode(tree, zone, index, node) {
|
|
21
|
+
const list = tree.zones[zone] ?? [];
|
|
22
|
+
return withZone(tree, zone, [...list.slice(0, index), node, ...list.slice(index)]);
|
|
23
|
+
}
|
|
24
|
+
export function removeNode(tree, id) {
|
|
25
|
+
const zone = zoneOf(tree, id);
|
|
26
|
+
if (!zone)
|
|
27
|
+
return tree;
|
|
28
|
+
return withZone(tree, zone, tree.zones[zone].filter((n) => n.id !== id));
|
|
29
|
+
}
|
|
30
|
+
export function moveNode(tree, id, dir) {
|
|
31
|
+
const zone = zoneOf(tree, id);
|
|
32
|
+
if (!zone)
|
|
33
|
+
return tree;
|
|
34
|
+
const list = [...tree.zones[zone]];
|
|
35
|
+
const i = list.findIndex((n) => n.id === id);
|
|
36
|
+
const j = i + dir;
|
|
37
|
+
if (j < 0 || j >= list.length)
|
|
38
|
+
return tree;
|
|
39
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
40
|
+
return withZone(tree, zone, list);
|
|
41
|
+
}
|
|
42
|
+
export function patchNode(tree, id, ns, key, value) {
|
|
43
|
+
const zone = zoneOf(tree, id);
|
|
44
|
+
if (!zone)
|
|
45
|
+
return tree;
|
|
46
|
+
return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
|
|
47
|
+
}
|
|
48
|
+
export function canInsert(policy, count) {
|
|
49
|
+
if (!policy || policy.kind === "locked")
|
|
50
|
+
return false;
|
|
51
|
+
if (policy.kind === "sections" && policy.max != null && count >= policy.max)
|
|
52
|
+
return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
export function allowedSections(policy, all) {
|
|
56
|
+
if (!policy || policy.kind === "locked")
|
|
57
|
+
return [];
|
|
58
|
+
if (policy.allow === "*")
|
|
59
|
+
return all;
|
|
60
|
+
const allow = new Set(policy.allow);
|
|
61
|
+
return all.filter((s) => allow.has(s.name));
|
|
62
|
+
}
|
|
63
|
+
/** The empty value a fresh node's field starts at, by descriptor type.
|
|
64
|
+
* Shape matters: a list-valued field seeded with "" is a type lie that
|
|
65
|
+
* crashes the first section view that maps over it. */
|
|
66
|
+
const EMPTY_BY_TYPE = { refList: [], object: {} };
|
|
67
|
+
/** Media/number fields have no honest empty value — "" renders as a
|
|
68
|
+
* broken-image icon or a stray zero, worse than nothing at all. With no
|
|
69
|
+
* sample, leave the key out of `fields` entirely rather than seed a lie. */
|
|
70
|
+
const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
|
|
71
|
+
export function nodeFromSample(section, id) {
|
|
72
|
+
const fields = {};
|
|
73
|
+
const styles = {};
|
|
74
|
+
for (const [key, d] of Object.entries(section.fields)) {
|
|
75
|
+
const sampled = section.sample?.[key];
|
|
76
|
+
if (sampled !== undefined)
|
|
77
|
+
fields[key] = sampled;
|
|
78
|
+
else if (ABSENT_WHEN_UNSAMPLED.has(d.type))
|
|
79
|
+
continue;
|
|
80
|
+
else
|
|
81
|
+
fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
|
|
82
|
+
}
|
|
83
|
+
for (const [key, d] of Object.entries(section.styles)) {
|
|
84
|
+
const v = section.sample?.[key] ?? d.default;
|
|
85
|
+
if (v !== undefined)
|
|
86
|
+
styles[key] = v;
|
|
87
|
+
}
|
|
88
|
+
return { id, type: section.name, fields, styles };
|
|
89
|
+
}
|
|
90
|
+
/** A locked zone holds exactly one node the editor can never insert or
|
|
91
|
+
* remove — so someone has to put it there. This does, from the component's
|
|
92
|
+
* sample, for every locked zone that is empty or missing. Runs when the
|
|
93
|
+
* editor loads a draft, which also covers pages that predate a locked zone
|
|
94
|
+
* being added to their schema. Returns the same tree when nothing changed. */
|
|
95
|
+
export function fillLockedZones(tree, policies, sections) {
|
|
96
|
+
let next = null;
|
|
97
|
+
for (const [zone, policy] of Object.entries(policies)) {
|
|
98
|
+
if (policy.kind !== "locked")
|
|
99
|
+
continue;
|
|
100
|
+
if ((tree.zones[zone] ?? []).length > 0)
|
|
101
|
+
continue;
|
|
102
|
+
const section = sections.find((s) => s.name === policy.component);
|
|
103
|
+
if (!section)
|
|
104
|
+
continue;
|
|
105
|
+
next ?? (next = { ...tree, zones: { ...tree.zones } });
|
|
106
|
+
next.zones[zone] = [nodeFromSample(section, newNodeId(section.name))];
|
|
107
|
+
}
|
|
108
|
+
return next ?? tree;
|
|
109
|
+
}
|
|
110
|
+
export function newNodeId(type) {
|
|
111
|
+
return `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type FieldError = {
|
|
2
|
+
field: string;
|
|
3
|
+
message: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, {
|
|
6
|
+
type: string;
|
|
7
|
+
optional?: unknown;
|
|
8
|
+
options?: unknown;
|
|
9
|
+
max?: unknown;
|
|
10
|
+
}>): FieldError[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
2
|
+
export function validateFields(values, descriptors) {
|
|
3
|
+
const errors = [];
|
|
4
|
+
for (const [field, d] of Object.entries(descriptors)) {
|
|
5
|
+
const value = values[field];
|
|
6
|
+
if (isEmpty(value)) {
|
|
7
|
+
if (d.optional !== true)
|
|
8
|
+
errors.push({ field, message: "Required." });
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
if (d.type === "select" && Array.isArray(d.options) && !d.options.includes(value)) {
|
|
12
|
+
errors.push({ field, message: `Must be one of: ${d.options.join(", ")}.` });
|
|
13
|
+
}
|
|
14
|
+
if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
|
|
15
|
+
errors.push({ field, message: `Must be at most ${d.max} characters.` });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return errors;
|
|
19
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import type { SmoodlyEnv } from "./env.ts";
|
|
3
|
+
type ContentEnv = Pick<SmoodlyEnv, "url" | "secretKey" | "publishableKey">;
|
|
4
|
+
export declare function clientOptions(env: ContentEnv): {
|
|
5
|
+
url: string;
|
|
6
|
+
key: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
/** The server-side client the stores are built on. */
|
|
10
|
+
export declare function createSmoodlyClient(env: ContentEnv): SupabaseClient;
|
|
11
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// How the secret reaches PostgREST. A JWT (the self-host service role, or
|
|
2
|
+
// a cloud space key) rides as the bearer PostgREST derives the role from,
|
|
3
|
+
// with the publishable key satisfying the API gateway's apikey check. A
|
|
4
|
+
// Supabase `sb_secret_…` key is itself an apikey. Key *format*, not mode.
|
|
5
|
+
import { createClient } from "@supabase/supabase-js";
|
|
6
|
+
export function clientOptions(env) {
|
|
7
|
+
if (env.secretKey.startsWith("sb_secret_")) {
|
|
8
|
+
return { url: env.url, key: env.secretKey, headers: {} };
|
|
9
|
+
}
|
|
10
|
+
return { url: env.url, key: env.publishableKey, headers: { Authorization: `Bearer ${env.secretKey}` } };
|
|
11
|
+
}
|
|
12
|
+
/** The server-side client the stores are built on. */
|
|
13
|
+
export function createSmoodlyClient(env) {
|
|
14
|
+
const options = clientOptions(env);
|
|
15
|
+
return createClient(options.url, options.key, {
|
|
16
|
+
global: { headers: options.headers },
|
|
17
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Descriptor } from "./fields.ts";
|
|
2
|
+
import type { BuilderMap, NamespaceValues } from "./schema.ts";
|
|
3
|
+
export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
|
|
4
|
+
kind: "collection";
|
|
5
|
+
name: string;
|
|
6
|
+
title: string;
|
|
7
|
+
titleField?: string;
|
|
8
|
+
fields: Record<string, Descriptor>;
|
|
9
|
+
admin?: {
|
|
10
|
+
listColumns?: string[];
|
|
11
|
+
orderBy?: Record<string, "asc" | "desc">;
|
|
12
|
+
};
|
|
13
|
+
/** phantom: the entry value shape, used by f.ref target inference */
|
|
14
|
+
readonly __entry?: NamespaceValues<F>;
|
|
15
|
+
};
|
|
16
|
+
export declare function collection<F extends BuilderMap>(input: {
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
titleField?: string;
|
|
20
|
+
fields: F;
|
|
21
|
+
admin?: {
|
|
22
|
+
listColumns?: string[];
|
|
23
|
+
orderBy?: Record<string, "asc" | "desc">;
|
|
24
|
+
};
|
|
25
|
+
}): CollectionSchema<F>;
|
|
26
|
+
export type Toolset = {
|
|
27
|
+
marks?: string[];
|
|
28
|
+
nodes?: string[];
|
|
29
|
+
elements?: string[];
|
|
30
|
+
};
|
|
31
|
+
export declare function toolset(input: {
|
|
32
|
+
marks?: string[];
|
|
33
|
+
nodes?: string[];
|
|
34
|
+
elements?: {
|
|
35
|
+
schema: {
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
}[];
|
|
39
|
+
}): Toolset;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function collection(input) {
|
|
2
|
+
if (!input?.name)
|
|
3
|
+
throw new Error("smoodly: a collection requires a name.");
|
|
4
|
+
return {
|
|
5
|
+
kind: "collection",
|
|
6
|
+
name: input.name,
|
|
7
|
+
title: input.title,
|
|
8
|
+
titleField: input.titleField,
|
|
9
|
+
fields: Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor])),
|
|
10
|
+
admin: input.admin,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function toolset(input) {
|
|
14
|
+
const out = {};
|
|
15
|
+
if (input.marks)
|
|
16
|
+
out.marks = input.marks;
|
|
17
|
+
if (input.nodes)
|
|
18
|
+
out.nodes = input.nodes;
|
|
19
|
+
if (input.elements)
|
|
20
|
+
out.elements = input.elements.map((e) => e.schema.name);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Descriptor, FieldBuilder } from "./fields.ts";
|
|
2
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
+
type Registered = {
|
|
4
|
+
schema: {
|
|
5
|
+
kind: string;
|
|
6
|
+
name: string;
|
|
7
|
+
slug?: string;
|
|
8
|
+
styles?: Record<string, Descriptor>;
|
|
9
|
+
styleDefaults?: false | {
|
|
10
|
+
omit: string[];
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type SmoodlyConfig = {
|
|
15
|
+
registry: {
|
|
16
|
+
sections?: Registered[];
|
|
17
|
+
elements?: Registered[];
|
|
18
|
+
collections?: CollectionSchema<any>[];
|
|
19
|
+
pages?: Registered[];
|
|
20
|
+
};
|
|
21
|
+
locales: {
|
|
22
|
+
default: string;
|
|
23
|
+
supported: string[];
|
|
24
|
+
};
|
|
25
|
+
styles?: {
|
|
26
|
+
sections?: Record<string, FieldBuilder<any, any>>;
|
|
27
|
+
elements?: Record<string, FieldBuilder<any, any>>;
|
|
28
|
+
};
|
|
29
|
+
rows?: {
|
|
30
|
+
splits: string[];
|
|
31
|
+
};
|
|
32
|
+
/** Slug of the page record the site root serves. The admin maps this
|
|
33
|
+
* slug to "/" (canvas URL, Pages list) and every other slug to
|
|
34
|
+
* "/<slug>"; the route file serving "/" must declare the same slug. */
|
|
35
|
+
homePageSlug?: string;
|
|
36
|
+
meta?: {
|
|
37
|
+
dataAttributes?: boolean;
|
|
38
|
+
};
|
|
39
|
+
database?: {
|
|
40
|
+
provider: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export type ResolvedConfig = SmoodlyConfig & {
|
|
44
|
+
homePageSlug: string;
|
|
45
|
+
/** The section's styles with the config standard set merged in (section keys win). */
|
|
46
|
+
resolvedStyles(sectionName: string): Record<string, Descriptor>;
|
|
47
|
+
};
|
|
48
|
+
export declare function defineConfig(config: SmoodlyConfig): ResolvedConfig;
|
|
49
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function assertUniqueNames(config) {
|
|
2
|
+
const seen = new Set();
|
|
3
|
+
const all = [
|
|
4
|
+
...(config.registry.sections ?? []).map((s) => s.schema.name),
|
|
5
|
+
...(config.registry.elements ?? []).map((e) => e.schema.name),
|
|
6
|
+
...(config.registry.collections ?? []).map((c) => c.name),
|
|
7
|
+
...(config.registry.pages ?? []).map((p) => p.schema.name),
|
|
8
|
+
];
|
|
9
|
+
for (const name of all) {
|
|
10
|
+
if (seen.has(name))
|
|
11
|
+
throw new Error(`smoodly: duplicate registered name "${name}".`);
|
|
12
|
+
seen.add(name);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** A fixed slug names the ONE page record a route file serves; two
|
|
16
|
+
* registrations claiming it would make which-one-renders undefined. */
|
|
17
|
+
function assertUniqueFixedSlugs(config) {
|
|
18
|
+
const owners = new Map();
|
|
19
|
+
for (const p of config.registry.pages ?? []) {
|
|
20
|
+
const slug = p.schema.slug;
|
|
21
|
+
if (slug === undefined)
|
|
22
|
+
continue;
|
|
23
|
+
const owner = owners.get(slug);
|
|
24
|
+
if (owner) {
|
|
25
|
+
throw new Error(`smoodly: page registrations "${owner}" and "${p.schema.name}" both claim the fixed slug "${slug}".`);
|
|
26
|
+
}
|
|
27
|
+
owners.set(slug, p.schema.name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function defineConfig(config) {
|
|
31
|
+
assertUniqueNames(config);
|
|
32
|
+
assertUniqueFixedSlugs(config);
|
|
33
|
+
const standard = Object.fromEntries(Object.entries(config.styles?.sections ?? {}).map(([k, b]) => [k, b.descriptor]));
|
|
34
|
+
const byName = new Map((config.registry.sections ?? []).map((s) => [s.schema.name, s.schema]));
|
|
35
|
+
return {
|
|
36
|
+
...config,
|
|
37
|
+
homePageSlug: config.homePageSlug ?? "home",
|
|
38
|
+
resolvedStyles(sectionName) {
|
|
39
|
+
const schema = byName.get(sectionName);
|
|
40
|
+
if (!schema)
|
|
41
|
+
throw new Error(`smoodly: unknown section "${sectionName}".`);
|
|
42
|
+
const own = schema.styles ?? {};
|
|
43
|
+
if (schema.styleDefaults === false)
|
|
44
|
+
return { ...own };
|
|
45
|
+
const omitted = new Set(schema.styleDefaults ? schema.styleDefaults.omit : []);
|
|
46
|
+
const merged = {};
|
|
47
|
+
for (const [k, d] of Object.entries(standard)) {
|
|
48
|
+
if (!omitted.has(k))
|
|
49
|
+
merged[k] = d;
|
|
50
|
+
}
|
|
51
|
+
return { ...merged, ...own };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
2
|
+
import { MemoryRefIndex } from "./ref-index.ts";
|
|
3
|
+
export type EntryRecord = {
|
|
4
|
+
id: string;
|
|
5
|
+
collection: string;
|
|
6
|
+
fields: Record<string, unknown>;
|
|
7
|
+
status: "draft" | "published";
|
|
8
|
+
sort: number | null;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
};
|
|
12
|
+
export type EntryUsage = {
|
|
13
|
+
sourceKind: string;
|
|
14
|
+
sourceId: string;
|
|
15
|
+
sourceField: string;
|
|
16
|
+
};
|
|
17
|
+
export type ListOptions = {
|
|
18
|
+
/** "created" (default): oldest first. "updated": recent first. "manual": sort asc. */
|
|
19
|
+
order?: "created" | "updated" | "manual";
|
|
20
|
+
status?: "published";
|
|
21
|
+
};
|
|
22
|
+
export interface EntryStore {
|
|
23
|
+
create(collection: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
24
|
+
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
25
|
+
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
26
|
+
/** Batched read for the resolver: fields keyed by id, missing ids omitted.
|
|
27
|
+
* The signature IS resolveTree's EntryFetcher. The published-route
|
|
28
|
+
* resolver passes { status: "published" } so an entry demoted to draft
|
|
29
|
+
* after a referencing page was published drops out on the next
|
|
30
|
+
* revalidation instead of hydrating draft fields (DESIGN.md OQ #16). */
|
|
31
|
+
getMany(collection: string, ids: string[], options?: {
|
|
32
|
+
status?: "published";
|
|
33
|
+
}): Promise<Record<string, Record<string, unknown>>>;
|
|
34
|
+
update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
35
|
+
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
36
|
+
/** Blocked while anything references the entry (the safe-delete guard). */
|
|
37
|
+
delete(collection: string, id: string): Promise<void>;
|
|
38
|
+
/** Rewrite manual order to 1..N for the given ids, one batched pass. */
|
|
39
|
+
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
40
|
+
/** The refs index's reverse question: who points at this entry? */
|
|
41
|
+
referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
|
|
42
|
+
/** Reverse lookup by id alone (ids are globally unique) — what the
|
|
43
|
+
* fan-out walk queries, since it can't know a source's collection. */
|
|
44
|
+
incoming(targetId: string): Promise<{
|
|
45
|
+
sourceKind: string;
|
|
46
|
+
sourceId: string;
|
|
47
|
+
}[]>;
|
|
48
|
+
}
|
|
49
|
+
export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
|
|
50
|
+
export declare class MemoryEntryStore implements EntryStore {
|
|
51
|
+
private collections;
|
|
52
|
+
private refs;
|
|
53
|
+
private entries;
|
|
54
|
+
private seq;
|
|
55
|
+
private order;
|
|
56
|
+
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex);
|
|
57
|
+
private row;
|
|
58
|
+
private writeRefs;
|
|
59
|
+
create(collection: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
60
|
+
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
61
|
+
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
62
|
+
getMany(collection: string, ids: string[], options?: {
|
|
63
|
+
status?: "published";
|
|
64
|
+
}): Promise<Record<string, Record<string, unknown>>>;
|
|
65
|
+
update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
66
|
+
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
67
|
+
delete(collection: string, id: string): Promise<void>;
|
|
68
|
+
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
69
|
+
referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
|
|
70
|
+
incoming(targetId: string): Promise<{
|
|
71
|
+
sourceKind: string;
|
|
72
|
+
sourceId: string;
|
|
73
|
+
}[]>;
|
|
74
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The collection-entry persistence contract — the entries counterpart
|
|
2
|
+
// to PageStore. Writes always flow through this API (never raw table
|
|
3
|
+
// access), which is what lets the refs index stay correct: every save
|
|
4
|
+
// rewrites the entry's outgoing edges (collectEntryRefs), so
|
|
5
|
+
// referencesTo/safe-delete answer from the index alone. Status-only
|
|
6
|
+
// versioning in v1 (DESIGN.md §3). Reorder rewrites sort 1..N in one
|
|
7
|
+
// pass and NEVER touches updatedAt — order is a property of the
|
|
8
|
+
// collection, not an edit to its entries.
|
|
9
|
+
import { collectEntryRefs } from "./refs.js";
|
|
10
|
+
import { MemoryRefIndex } from "./ref-index.js";
|
|
11
|
+
export function schemaOf(collections, name) {
|
|
12
|
+
const schema = collections.find((c) => c.name === name);
|
|
13
|
+
if (!schema)
|
|
14
|
+
throw new Error(`smoodly: no collection named "${name}".`);
|
|
15
|
+
return schema;
|
|
16
|
+
}
|
|
17
|
+
let counter = 0;
|
|
18
|
+
const uid = () => `entry-${++counter}-${Date.now().toString(36)}`;
|
|
19
|
+
export class MemoryEntryStore {
|
|
20
|
+
constructor(collections,
|
|
21
|
+
// pass the SAME index to MemoryPageStore so cross-kind questions work
|
|
22
|
+
refs = new MemoryRefIndex()) {
|
|
23
|
+
this.collections = collections;
|
|
24
|
+
this.refs = refs;
|
|
25
|
+
this.entries = new Map();
|
|
26
|
+
this.seq = 0; // creation-order tiebreak (Date.now can tie)
|
|
27
|
+
this.order = new Map();
|
|
28
|
+
}
|
|
29
|
+
row(collection, id) {
|
|
30
|
+
const entry = this.entries.get(id);
|
|
31
|
+
return entry && entry.collection === collection ? entry : null;
|
|
32
|
+
}
|
|
33
|
+
writeRefs(collection, id, fields) {
|
|
34
|
+
const schema = schemaOf(this.collections, collection);
|
|
35
|
+
this.refs.rewrite({ sourceKind: "entry", sourceId: id }, collectEntryRefs(fields, schema));
|
|
36
|
+
}
|
|
37
|
+
async create(collection, fields) {
|
|
38
|
+
schemaOf(this.collections, collection);
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const entry = {
|
|
41
|
+
id: uid(),
|
|
42
|
+
collection,
|
|
43
|
+
fields: structuredClone(fields),
|
|
44
|
+
status: "draft",
|
|
45
|
+
sort: null,
|
|
46
|
+
createdAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
};
|
|
49
|
+
this.entries.set(entry.id, entry);
|
|
50
|
+
this.order.set(entry.id, ++this.seq);
|
|
51
|
+
this.writeRefs(collection, entry.id, fields);
|
|
52
|
+
return structuredClone(entry);
|
|
53
|
+
}
|
|
54
|
+
async get(collection, id) {
|
|
55
|
+
const entry = this.row(collection, id);
|
|
56
|
+
return entry ? structuredClone(entry) : null;
|
|
57
|
+
}
|
|
58
|
+
async list(collection, options) {
|
|
59
|
+
schemaOf(this.collections, collection);
|
|
60
|
+
let rows = [...this.entries.values()].filter((e) => e.collection === collection);
|
|
61
|
+
if (options?.status)
|
|
62
|
+
rows = rows.filter((e) => e.status === options.status);
|
|
63
|
+
const order = options?.order ?? "created";
|
|
64
|
+
rows.sort((a, b) => {
|
|
65
|
+
if (order === "updated")
|
|
66
|
+
return b.updatedAt - a.updatedAt;
|
|
67
|
+
if (order === "manual") {
|
|
68
|
+
const sa = a.sort ?? Infinity;
|
|
69
|
+
const sb = b.sort ?? Infinity;
|
|
70
|
+
if (sa !== sb)
|
|
71
|
+
return sa - sb;
|
|
72
|
+
}
|
|
73
|
+
return this.order.get(a.id) - this.order.get(b.id);
|
|
74
|
+
});
|
|
75
|
+
return rows.map((e) => structuredClone(e));
|
|
76
|
+
}
|
|
77
|
+
async getMany(collection, ids, options) {
|
|
78
|
+
schemaOf(this.collections, collection);
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const id of ids) {
|
|
81
|
+
const entry = this.row(collection, id);
|
|
82
|
+
if (!entry)
|
|
83
|
+
continue;
|
|
84
|
+
if (options?.status && entry.status !== options.status)
|
|
85
|
+
continue;
|
|
86
|
+
out[id] = structuredClone(entry.fields);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
async update(collection, id, fields) {
|
|
91
|
+
const entry = this.row(collection, id);
|
|
92
|
+
if (!entry)
|
|
93
|
+
throw new Error(`smoodly: no entry "${id}" in "${collection}".`);
|
|
94
|
+
entry.fields = structuredClone(fields);
|
|
95
|
+
entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
|
|
96
|
+
this.writeRefs(collection, id, fields);
|
|
97
|
+
return structuredClone(entry);
|
|
98
|
+
}
|
|
99
|
+
async setStatus(collection, id, status) {
|
|
100
|
+
const entry = this.row(collection, id);
|
|
101
|
+
if (!entry)
|
|
102
|
+
throw new Error(`smoodly: no entry "${id}" in "${collection}".`);
|
|
103
|
+
entry.status = status;
|
|
104
|
+
entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
|
|
105
|
+
return structuredClone(entry);
|
|
106
|
+
}
|
|
107
|
+
async delete(collection, id) {
|
|
108
|
+
const entry = this.row(collection, id);
|
|
109
|
+
if (!entry)
|
|
110
|
+
return;
|
|
111
|
+
const usage = await this.referencesTo(collection, id);
|
|
112
|
+
if (usage.length > 0) {
|
|
113
|
+
throw new Error(`smoodly: "${collection}/${id}" is referenced by ${usage.length} document(s) — remove those references first.`);
|
|
114
|
+
}
|
|
115
|
+
this.entries.delete(id);
|
|
116
|
+
this.refs.remove({ sourceKind: "entry", sourceId: id });
|
|
117
|
+
}
|
|
118
|
+
async reorder(collection, orderedIds) {
|
|
119
|
+
schemaOf(this.collections, collection);
|
|
120
|
+
orderedIds.forEach((id, i) => {
|
|
121
|
+
const entry = this.row(collection, id);
|
|
122
|
+
if (entry)
|
|
123
|
+
entry.sort = i + 1; // deliberately NOT touching updatedAt
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async referencesTo(collection, id) {
|
|
127
|
+
return this.refs.to(collection, id);
|
|
128
|
+
}
|
|
129
|
+
async incoming(targetId) {
|
|
130
|
+
return this.refs.incoming(targetId);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SmoodlyEnv = {
|
|
2
|
+
/** The content project. */
|
|
3
|
+
url: string;
|
|
4
|
+
/** Server-only: the service-role key (self-host) or the environment's write key (cloud). */
|
|
5
|
+
secretKey: string;
|
|
6
|
+
/** What a browser may hold for the content project: anon/publishable key, or the read key. */
|
|
7
|
+
publishableKey: string;
|
|
8
|
+
/** Where editors sign in. Defaults to `url`. */
|
|
9
|
+
authUrl: string;
|
|
10
|
+
/** That project's publishable key. Defaults to `publishableKey`. */
|
|
11
|
+
authKey: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>): SmoodlyEnv;
|
|
14
|
+
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
15
|
+
* so `next build` without env still succeeds. */
|
|
16
|
+
export declare const smoodlyEnv: () => SmoodlyEnv;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
|
|
2
|
+
// names; the cloud sets five. The two defaults are what keep a single
|
|
3
|
+
// code path: a default for an absent value is not a mode branch.
|
|
4
|
+
const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
|
|
5
|
+
export function resolveSmoodlyEnv(env) {
|
|
6
|
+
const missing = REQUIRED.filter((name) => !env[name]);
|
|
7
|
+
if (missing.length > 0) {
|
|
8
|
+
throw new Error(`smoodly: set ${missing.join(", ")} — see .env.local.example.`);
|
|
9
|
+
}
|
|
10
|
+
const url = env.SMOODLY_URL;
|
|
11
|
+
const publishableKey = env.SMOODLY_PUBLISHABLE_KEY;
|
|
12
|
+
return {
|
|
13
|
+
url,
|
|
14
|
+
secretKey: env.SMOODLY_SECRET_KEY,
|
|
15
|
+
publishableKey,
|
|
16
|
+
authUrl: env.SMOODLY_AUTH_URL || url,
|
|
17
|
+
authKey: env.SMOODLY_AUTH_KEY || publishableKey,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
21
|
+
* so `next build` without env still succeeds. */
|
|
22
|
+
export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type Descriptor = {
|
|
2
|
+
type: string;
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export type FieldBuilder<Value = unknown, Flags extends BuilderFlags = {}> = {
|
|
6
|
+
readonly descriptor: Descriptor;
|
|
7
|
+
optional(): FieldBuilder<Value, Flags & {
|
|
8
|
+
optional: true;
|
|
9
|
+
}>;
|
|
10
|
+
localized(): FieldBuilder<Value, Flags & {
|
|
11
|
+
localized: true;
|
|
12
|
+
}>;
|
|
13
|
+
max(n: number): FieldBuilder<Value, Flags>;
|
|
14
|
+
min(n: number): FieldBuilder<Value, Flags>;
|
|
15
|
+
default(v: Value): FieldBuilder<Value, Flags>;
|
|
16
|
+
/**
|
|
17
|
+
* Resolution depth for this ref (decided at the ref site — a target
|
|
18
|
+
* schema's own annotations never deepen a chain):
|
|
19
|
+
* - `.resolve()` — follow the whole reference chain (cycle-safe)
|
|
20
|
+
* - `.resolve("company")` / `.resolve({ company: true })` — follow only
|
|
21
|
+
* the named refs, one level; object values nest deeper
|
|
22
|
+
* - no call — hydrate the entry itself; its refs stay ids
|
|
23
|
+
*/
|
|
24
|
+
resolve(...projection: (string | Record<string, unknown>)[]): FieldBuilder<Value, Flags>;
|
|
25
|
+
tab(tab: "content" | "styles" | "media" | "meta"): FieldBuilder<Value, Flags>;
|
|
26
|
+
/** phantom, type-level only */
|
|
27
|
+
readonly __value?: Value;
|
|
28
|
+
readonly __flags?: Flags;
|
|
29
|
+
};
|
|
30
|
+
export type BuilderFlags = {
|
|
31
|
+
optional?: true;
|
|
32
|
+
localized?: true;
|
|
33
|
+
};
|
|
34
|
+
export declare const f: {
|
|
35
|
+
text: () => FieldBuilder<string, {}>;
|
|
36
|
+
link: () => FieldBuilder<string, {}>;
|
|
37
|
+
slug: (opts?: {
|
|
38
|
+
from?: string;
|
|
39
|
+
}) => FieldBuilder<string, {}>;
|
|
40
|
+
date: () => FieldBuilder<string, {}>;
|
|
41
|
+
number: () => FieldBuilder<number, {}>;
|
|
42
|
+
image: () => FieldBuilder<{
|
|
43
|
+
url: string;
|
|
44
|
+
alt?: string;
|
|
45
|
+
}, {}>;
|
|
46
|
+
video: () => FieldBuilder<{
|
|
47
|
+
url: string;
|
|
48
|
+
}, {}>;
|
|
49
|
+
file: () => FieldBuilder<{
|
|
50
|
+
url: string;
|
|
51
|
+
}, {}>;
|
|
52
|
+
richtext: (opts?: {
|
|
53
|
+
toolset?: unknown;
|
|
54
|
+
}) => FieldBuilder<unknown, {}>;
|
|
55
|
+
dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
|
|
56
|
+
select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
|
|
57
|
+
object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
|
|
58
|
+
ref: <T>(target: () => T) => FieldBuilder<Resolved<T>, {}>;
|
|
59
|
+
refList: <T>(target: () => T) => FieldBuilder<Resolved<T>[], {}>;
|
|
60
|
+
};
|
|
61
|
+
export type ValueOf<B> = B extends FieldBuilder<infer V, infer Flags> ? Flags extends {
|
|
62
|
+
optional: true;
|
|
63
|
+
} ? V | undefined : V : never;
|
|
64
|
+
/** Resolved entry for a ref target — refined when collections land. */
|
|
65
|
+
export type Resolved<T> = T extends {
|
|
66
|
+
__entry?: infer E;
|
|
67
|
+
} ? E : Record<string, unknown>;
|