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
package/dist/fields.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Field builders — inert descriptors by design rule (see DESIGN.md §3):
|
|
2
|
+
// f.text().max(40) is plain data; validation logic lives server/admin-side,
|
|
3
|
+
// keyed by descriptor type. Chaining is immutable.
|
|
4
|
+
function builder(descriptor) {
|
|
5
|
+
const chain = (patch) => builder({ ...descriptor, ...patch });
|
|
6
|
+
return {
|
|
7
|
+
descriptor,
|
|
8
|
+
optional: () => chain({ optional: true }),
|
|
9
|
+
localized: () => chain({ localized: true }),
|
|
10
|
+
max: (n) => chain({ max: n }),
|
|
11
|
+
min: (n) => chain({ min: n }),
|
|
12
|
+
default: (v) => chain({ default: v }),
|
|
13
|
+
resolve: (...projection) => {
|
|
14
|
+
if (projection.length === 0)
|
|
15
|
+
return chain({ resolve: "full" });
|
|
16
|
+
if (typeof projection[0] === "object")
|
|
17
|
+
return chain({ resolve: projection[0] });
|
|
18
|
+
return chain({ resolve: Object.fromEntries(projection.map((k) => [k, true])) });
|
|
19
|
+
},
|
|
20
|
+
tab: (tab) => chain({ tab }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export const f = {
|
|
24
|
+
text: () => builder({ type: "text" }),
|
|
25
|
+
link: () => builder({ type: "link" }),
|
|
26
|
+
slug: (opts) => builder({ type: "slug", ...(opts?.from ? { from: opts.from } : {}) }),
|
|
27
|
+
date: () => builder({ type: "date" }),
|
|
28
|
+
number: () => builder({ type: "number" }),
|
|
29
|
+
image: () => builder({ type: "image" }),
|
|
30
|
+
video: () => builder({ type: "video" }),
|
|
31
|
+
file: () => builder({ type: "file" }),
|
|
32
|
+
richtext: (opts) => builder({ type: "richtext", ...(opts?.toolset !== undefined ? { toolset: opts.toolset } : {}) }),
|
|
33
|
+
dataAttributes: () => builder({ type: "dataAttributes" }),
|
|
34
|
+
select: (options) => builder({ type: "select", options: [...options] }),
|
|
35
|
+
object: (shape) => builder({
|
|
36
|
+
type: "object",
|
|
37
|
+
fields: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, v.descriptor])),
|
|
38
|
+
}),
|
|
39
|
+
ref: (target) => builder({ type: "ref", target }),
|
|
40
|
+
refList: (target) => builder({ type: "refList", target }),
|
|
41
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { section, element } from "./pairing.tsx";
|
|
2
|
+
import { collection, toolset } from "./collections.ts";
|
|
3
|
+
import { Section } from "./section-wrapper.tsx";
|
|
4
|
+
import { page } from "./render.tsx";
|
|
5
|
+
export { Zone, renderPage } from "./render.tsx";
|
|
6
|
+
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry } from "./render.tsx";
|
|
7
|
+
export type { PageSchema } from "./page.ts";
|
|
8
|
+
export { f } from "./fields.ts";
|
|
9
|
+
export { z } from "./zones.ts";
|
|
10
|
+
export { defineConfig } from "./config.ts";
|
|
11
|
+
export { collectionSQL, coreTablesSQL } from "./sql.ts";
|
|
12
|
+
export { MemoryPageStore } from "./store.ts";
|
|
13
|
+
export { SupabasePageStore } from "./supabase-store.ts";
|
|
14
|
+
export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
|
|
15
|
+
export type { RefEdge, SectionLike } from "./refs.ts";
|
|
16
|
+
export { MemoryRefIndex } from "./ref-index.ts";
|
|
17
|
+
export { MemoryEntryStore } from "./entry-store.ts";
|
|
18
|
+
export { SupabaseEntryStore } from "./supabase-entry-store.ts";
|
|
19
|
+
export type { EntryStore, EntryRecord, EntryUsage, ListOptions } from "./entry-store.ts";
|
|
20
|
+
export { affectedPageSlugs } from "./revalidate.ts";
|
|
21
|
+
export { resolveTree } from "./resolve.ts";
|
|
22
|
+
export type { EntryFetcher } from "./resolve.ts";
|
|
23
|
+
export type { PageStore, PageRecord, PageVersion, PageStoreOptions } from "./store.ts";
|
|
24
|
+
export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
25
|
+
export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
|
|
26
|
+
export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
|
|
27
|
+
export type { Props, PairedComponent } from "./pairing.tsx";
|
|
28
|
+
export type { CollectionSchema, Toolset } from "./collections.ts";
|
|
29
|
+
export type { ZonePolicy } from "./zones.ts";
|
|
30
|
+
export declare const smoodly: {
|
|
31
|
+
schema: {
|
|
32
|
+
page: typeof import("./page.ts").pageSchema;
|
|
33
|
+
section<F extends import("./schema.ts").BuilderMap = {}, S extends import("./schema.ts").BuilderMap = {}, M extends import("./schema.ts").BuilderMap = {}>(input: {
|
|
34
|
+
name: string;
|
|
35
|
+
title: string;
|
|
36
|
+
icon?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
fields?: F | undefined;
|
|
39
|
+
styles?: S | undefined;
|
|
40
|
+
meta?: M | undefined;
|
|
41
|
+
sample?: Partial<{ [K_1 in Exclude<keyof F, { [K in keyof F]: F[K] extends {
|
|
42
|
+
__flags?: infer Fl;
|
|
43
|
+
} ? Fl extends {
|
|
44
|
+
optional: true;
|
|
45
|
+
} ? K : never : never; }[keyof F]>]: import("./fields.ts").ValueOf<F[K_1]>; } & { [K_2 in { [K in keyof F]: F[K] extends {
|
|
46
|
+
__flags?: infer Fl;
|
|
47
|
+
} ? Fl extends {
|
|
48
|
+
optional: true;
|
|
49
|
+
} ? K : never : never; }[keyof F]]?: import("./fields.ts").ValueOf<F[K_2]> | undefined; } & { [K_4 in Exclude<keyof S, { [K_3 in keyof S]: S[K_3] extends {
|
|
50
|
+
__flags?: infer Fl;
|
|
51
|
+
} ? Fl extends {
|
|
52
|
+
optional: true;
|
|
53
|
+
} ? K_3 : never : never; }[keyof S]>]: import("./fields.ts").ValueOf<S[K_4]>; } & { [K_5 in { [K_3 in keyof S]: S[K_3] extends {
|
|
54
|
+
__flags?: infer Fl;
|
|
55
|
+
} ? Fl extends {
|
|
56
|
+
optional: true;
|
|
57
|
+
} ? K_3 : never : never; }[keyof S]]?: import("./fields.ts").ValueOf<S[K_5]> | undefined; }> | undefined;
|
|
58
|
+
styleDefaults?: false | {
|
|
59
|
+
omit: string[];
|
|
60
|
+
};
|
|
61
|
+
}): import("./schema.ts").SectionSchema<F, S>;
|
|
62
|
+
element<F extends import("./schema.ts").BuilderMap = {}, S extends import("./schema.ts").BuilderMap = {}, M extends import("./schema.ts").BuilderMap = {}>(input: {
|
|
63
|
+
name: string;
|
|
64
|
+
title: string;
|
|
65
|
+
icon?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
fields?: F | undefined;
|
|
68
|
+
styles?: S | undefined;
|
|
69
|
+
meta?: M | undefined;
|
|
70
|
+
sample?: Partial<{ [K_1 in Exclude<keyof F, { [K in keyof F]: F[K] extends {
|
|
71
|
+
__flags?: infer Fl;
|
|
72
|
+
} ? Fl extends {
|
|
73
|
+
optional: true;
|
|
74
|
+
} ? K : never : never; }[keyof F]>]: import("./fields.ts").ValueOf<F[K_1]>; } & { [K_2 in { [K in keyof F]: F[K] extends {
|
|
75
|
+
__flags?: infer Fl;
|
|
76
|
+
} ? Fl extends {
|
|
77
|
+
optional: true;
|
|
78
|
+
} ? K : never : never; }[keyof F]]?: import("./fields.ts").ValueOf<F[K_2]> | undefined; } & { [K_4 in Exclude<keyof S, { [K_3 in keyof S]: S[K_3] extends {
|
|
79
|
+
__flags?: infer Fl;
|
|
80
|
+
} ? Fl extends {
|
|
81
|
+
optional: true;
|
|
82
|
+
} ? K_3 : never : never; }[keyof S]>]: import("./fields.ts").ValueOf<S[K_4]>; } & { [K_5 in { [K_3 in keyof S]: S[K_3] extends {
|
|
83
|
+
__flags?: infer Fl;
|
|
84
|
+
} ? Fl extends {
|
|
85
|
+
optional: true;
|
|
86
|
+
} ? K_3 : never : never; }[keyof S]]?: import("./fields.ts").ValueOf<S[K_5]> | undefined; }> | undefined;
|
|
87
|
+
styleDefaults?: false | {
|
|
88
|
+
omit: string[];
|
|
89
|
+
};
|
|
90
|
+
} & {
|
|
91
|
+
contexts: ("richtext" | "column")[];
|
|
92
|
+
}): import("./schema.ts").ElementSchema<F, S>;
|
|
93
|
+
};
|
|
94
|
+
section: typeof section;
|
|
95
|
+
element: typeof element;
|
|
96
|
+
page: typeof page;
|
|
97
|
+
collection: typeof collection;
|
|
98
|
+
toolset: typeof toolset;
|
|
99
|
+
Section: typeof Section;
|
|
100
|
+
};
|
|
101
|
+
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|
|
102
|
+
export type { SmoodlyEnv } from "./env.ts";
|
|
103
|
+
export { clientOptions, createSmoodlyClient } from "./client.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { schema } from "./schema.js";
|
|
2
|
+
import { section, element } from "./pairing.js";
|
|
3
|
+
import { collection, toolset } from "./collections.js";
|
|
4
|
+
import { Section } from "./section-wrapper.js";
|
|
5
|
+
import { page } from "./render.js";
|
|
6
|
+
export { Zone, renderPage } from "./render.js";
|
|
7
|
+
export { f } from "./fields.js";
|
|
8
|
+
export { z } from "./zones.js";
|
|
9
|
+
export { defineConfig } from "./config.js";
|
|
10
|
+
export { collectionSQL, coreTablesSQL } from "./sql.js";
|
|
11
|
+
export { MemoryPageStore } from "./store.js";
|
|
12
|
+
export { SupabasePageStore } from "./supabase-store.js";
|
|
13
|
+
export { collectEntryRefs, collectTreeRefs } from "./refs.js";
|
|
14
|
+
export { MemoryRefIndex } from "./ref-index.js";
|
|
15
|
+
export { MemoryEntryStore } from "./entry-store.js";
|
|
16
|
+
export { SupabaseEntryStore } from "./supabase-entry-store.js";
|
|
17
|
+
export { affectedPageSlugs } from "./revalidate.js";
|
|
18
|
+
export { resolveTree } from "./resolve.js";
|
|
19
|
+
export const smoodly = {
|
|
20
|
+
schema,
|
|
21
|
+
section,
|
|
22
|
+
element,
|
|
23
|
+
page,
|
|
24
|
+
collection,
|
|
25
|
+
toolset,
|
|
26
|
+
Section,
|
|
27
|
+
};
|
|
28
|
+
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
29
|
+
export { clientOptions, createSmoodlyClient } from "./client.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type { PageSchema } from "../page.ts";
|
|
3
|
+
/** A route either carries a fixed registration (slug on the schema) or
|
|
4
|
+
* passes the slug from its params. Having neither is a developer wiring
|
|
5
|
+
* error, not runtime data — so this throws rather than returning a
|
|
6
|
+
* result. */
|
|
7
|
+
export declare function pageSlugFor(schema: PageSchema<any>, explicit?: string): string;
|
|
8
|
+
/** Maps the page tree's meta bag onto Next metadata. Absent meta returns
|
|
9
|
+
* {} so the app's own layout.tsx defaults apply untouched. */
|
|
10
|
+
export declare function metadataFrom(meta: unknown): Metadata;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A route either carries a fixed registration (slug on the schema) or
|
|
2
|
+
* passes the slug from its params. Having neither is a developer wiring
|
|
3
|
+
* error, not runtime data — so this throws rather than returning a
|
|
4
|
+
* result. */
|
|
5
|
+
export function pageSlugFor(schema, explicit) {
|
|
6
|
+
if (explicit)
|
|
7
|
+
return explicit;
|
|
8
|
+
if (schema.slug)
|
|
9
|
+
return schema.slug;
|
|
10
|
+
throw new Error(`smoodly: page registration "${schema.name}" has no fixed slug — pass { slug } from the route's params to renderSmoodlyPage.`);
|
|
11
|
+
}
|
|
12
|
+
/** Maps the page tree's meta bag onto Next metadata. Absent meta returns
|
|
13
|
+
* {} so the app's own layout.tsx defaults apply untouched. */
|
|
14
|
+
export function metadataFrom(meta) {
|
|
15
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta))
|
|
16
|
+
return {};
|
|
17
|
+
const m = meta;
|
|
18
|
+
const str = (key) => (typeof m[key] === "string" && m[key] ? m[key] : undefined);
|
|
19
|
+
const title = str("title");
|
|
20
|
+
const description = str("description");
|
|
21
|
+
const ogImage = str("ogImage");
|
|
22
|
+
const out = {};
|
|
23
|
+
if (title)
|
|
24
|
+
out.title = title;
|
|
25
|
+
if (description)
|
|
26
|
+
out.description = description;
|
|
27
|
+
const og = {};
|
|
28
|
+
if (title)
|
|
29
|
+
og.title = title;
|
|
30
|
+
if (description)
|
|
31
|
+
og.description = description;
|
|
32
|
+
if (ogImage)
|
|
33
|
+
og.images = [ogImage];
|
|
34
|
+
if (Object.keys(og).length > 0)
|
|
35
|
+
out.openGraph = og;
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import type { CollectionSchema } from "../collections.ts";
|
|
4
|
+
import type { EntryStore } from "../entry-store.ts";
|
|
5
|
+
import type { Descriptor } from "../fields.ts";
|
|
6
|
+
import { type PairedPage } from "../render.tsx";
|
|
7
|
+
import type { PageStore } from "../store.ts";
|
|
8
|
+
/** A registered section, as both the renderer and the ref resolver need it. */
|
|
9
|
+
type SectionEntry = ComponentType<any> & {
|
|
10
|
+
schema: {
|
|
11
|
+
name: string;
|
|
12
|
+
fields: Record<string, Descriptor>;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type PageRendererOptions = {
|
|
16
|
+
/** Lazy so `next build` never needs env vars at import time. */
|
|
17
|
+
stores: () => {
|
|
18
|
+
pages: PageStore;
|
|
19
|
+
entries: EntryStore;
|
|
20
|
+
};
|
|
21
|
+
sections: SectionEntry[];
|
|
22
|
+
collections: CollectionSchema<any>[];
|
|
23
|
+
/** Cache tag per slug; must match what publish revalidates. */
|
|
24
|
+
tag?: (slug: string) => string;
|
|
25
|
+
};
|
|
26
|
+
export type RenderOptions = {
|
|
27
|
+
slug?: string;
|
|
28
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
29
|
+
};
|
|
30
|
+
export declare function createPageRenderer(options: PageRendererOptions): {
|
|
31
|
+
renderSmoodlyPage<Z extends Record<string, unknown>>(Page: PairedPage<Z>, opts?: RenderOptions): Promise<ReactElement>;
|
|
32
|
+
smoodlyMetadata<Z extends Record<string, unknown>>(Page: PairedPage<Z>, opts?: {
|
|
33
|
+
slug?: string;
|
|
34
|
+
}): Promise<Metadata>;
|
|
35
|
+
};
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { unstable_cache } from "next/cache";
|
|
3
|
+
import { draftMode } from "next/headers";
|
|
4
|
+
import { notFound } from "next/navigation";
|
|
5
|
+
import { connection } from "next/server";
|
|
6
|
+
import { EditorBridge } from "../admin/next/bridge.js";
|
|
7
|
+
import { renderPage } from "../render.js";
|
|
8
|
+
import { resolveTree } from "../resolve.js";
|
|
9
|
+
import { metadataFrom, pageSlugFor } from "./meta.js";
|
|
10
|
+
export function createPageRenderer(options) {
|
|
11
|
+
const tag = options.tag ?? ((slug) => `page:${slug}`);
|
|
12
|
+
const resolved = (tree, o) => resolveTree(tree, {
|
|
13
|
+
sections: options.sections,
|
|
14
|
+
collections: options.collections,
|
|
15
|
+
fetch: (collection, ids) => options.stores().entries.getMany(collection, ids, o),
|
|
16
|
+
});
|
|
17
|
+
// The cached unit is the RESOLVED tree: entry data is frozen with the
|
|
18
|
+
// page, so an entry edit only appears once the revalidation fan-out
|
|
19
|
+
// fires revalidateTag("page:<slug>").
|
|
20
|
+
const cachedPublished = (slug) => unstable_cache(async () => {
|
|
21
|
+
const { pages } = options.stores();
|
|
22
|
+
const record = await pages.getPage(slug);
|
|
23
|
+
const tree = record?.publishedVersionId ? await pages.getPublishedTree(slug) : null;
|
|
24
|
+
return record && tree
|
|
25
|
+
? { template: record.template, tree: await resolved(tree, { status: "published" }) }
|
|
26
|
+
: null;
|
|
27
|
+
},
|
|
28
|
+
// Prefixed so it never collides with the surviving catch-all's own
|
|
29
|
+
// ["resolved-page", slug] cache in the same app.
|
|
30
|
+
["smoodly-resolved-page", slug], { tags: [tag(slug)] })();
|
|
31
|
+
async function load(slug, draft) {
|
|
32
|
+
if (!draft)
|
|
33
|
+
return cachedPublished(slug);
|
|
34
|
+
const { pages } = options.stores();
|
|
35
|
+
const record = await pages.getPage(slug);
|
|
36
|
+
const tree = record ? await pages.getDraftTree(slug) : null;
|
|
37
|
+
return record && tree ? { template: record.template, tree: await resolved(tree) } : null;
|
|
38
|
+
}
|
|
39
|
+
/** A record that exists but belongs to another registration is a 404
|
|
40
|
+
* for THIS route — otherwise the open [slug] route would render a fixed
|
|
41
|
+
* page's record through its own view at a duplicate URL. */
|
|
42
|
+
function forRegistration(loaded, name) {
|
|
43
|
+
return loaded && loaded.template === name ? loaded.tree : null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
async renderSmoodlyPage(Page, opts) {
|
|
47
|
+
// A CMS route is request-rendered by construction: without this a
|
|
48
|
+
// fixed-slug route file has nothing dynamic in it, so `next build`
|
|
49
|
+
// would prerender it against the database (DESIGN.md §3, OQ #19).
|
|
50
|
+
await connection();
|
|
51
|
+
const slug = pageSlugFor(Page.schema, opts?.slug);
|
|
52
|
+
const { isEnabled: draft } = await draftMode();
|
|
53
|
+
const tree = forRegistration(await load(slug, draft), Page.schema.name);
|
|
54
|
+
if (!tree)
|
|
55
|
+
notFound();
|
|
56
|
+
const editor = draft && (await opts?.searchParams)?.["smoodly-editor"] === "1";
|
|
57
|
+
return (_jsxs(_Fragment, { children: [editor ? _jsx(EditorBridge, {}) : null, renderPage(Page, tree, { sections: options.sections }, { editor })] }));
|
|
58
|
+
},
|
|
59
|
+
async smoodlyMetadata(Page, opts) {
|
|
60
|
+
// generateMetadata runs alongside the page render, so it must gate
|
|
61
|
+
// on the request too or a build could still reach the database here.
|
|
62
|
+
await connection();
|
|
63
|
+
const slug = pageSlugFor(Page.schema, opts?.slug);
|
|
64
|
+
const { isEnabled: draft } = await draftMode();
|
|
65
|
+
const tree = forRegistration(await load(slug, draft), Page.schema.name);
|
|
66
|
+
return metadataFrom(tree?.meta);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ZonePolicy } from "./zones.ts";
|
|
2
|
+
export type PageSchema<Zones extends Record<string, unknown> = Record<string, unknown>> = {
|
|
3
|
+
kind: "page";
|
|
4
|
+
name: string;
|
|
5
|
+
title: string;
|
|
6
|
+
/** Fixed: this registration owns exactly ONE page record, at this slug,
|
|
7
|
+
* served by one route file. Absent = open: editors create many.
|
|
8
|
+
* The route file serving "/" must declare the config's `homePageSlug`
|
|
9
|
+
* (default "home") — that is how the admin maps its record to "/". */
|
|
10
|
+
slug?: string;
|
|
11
|
+
zones: Record<keyof Zones & string, ZonePolicy>;
|
|
12
|
+
meta: Record<string, unknown>;
|
|
13
|
+
/** phantom: zone names for Props inference */
|
|
14
|
+
readonly __zones?: Zones;
|
|
15
|
+
};
|
|
16
|
+
export declare function pageSchema<Zones extends Record<string, object>>(input: {
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
slug?: string;
|
|
20
|
+
zones: Zones;
|
|
21
|
+
meta?: Record<string, unknown>;
|
|
22
|
+
}): PageSchema<Zones>;
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Policies arrive as z.* results (possibly carrying builder methods) — store plain data only. */
|
|
2
|
+
function normalizePolicy(p) {
|
|
3
|
+
switch (p.kind) {
|
|
4
|
+
case "sections": {
|
|
5
|
+
const out = { kind: "sections", allow: p.allow };
|
|
6
|
+
if (typeof p.max === "number")
|
|
7
|
+
out.max = p.max;
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
case "freeform":
|
|
11
|
+
return { kind: "freeform", allow: p.allow, rows: p.rows };
|
|
12
|
+
case "locked":
|
|
13
|
+
return { kind: "locked", component: p.component };
|
|
14
|
+
default:
|
|
15
|
+
throw new Error(`smoodly: unknown zone policy kind "${String(p.kind)}".`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function pageSchema(input) {
|
|
19
|
+
if (!input?.name)
|
|
20
|
+
throw new Error("smoodly: a page requires a name.");
|
|
21
|
+
// A fixed slug is decided by `slug !== undefined` at every other hop, but
|
|
22
|
+
// the renderer's path lookup is truthiness-based: an empty string would read
|
|
23
|
+
// as fixed to the admin and blow up in the route. Reject it at construction.
|
|
24
|
+
if (input.slug !== undefined && (typeof input.slug !== "string" || input.slug.trim() === "")) {
|
|
25
|
+
throw new Error(`smoodly: page "${input.name}" declares an invalid slug — a fixed slug must be a non-empty string.`);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
kind: "page",
|
|
29
|
+
name: input.name,
|
|
30
|
+
title: input.title,
|
|
31
|
+
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
|
32
|
+
zones: Object.fromEntries(Object.entries(input.zones).map(([k, p]) => [k, normalizePolicy(p)])),
|
|
33
|
+
meta: input.meta ?? {},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { BuilderMap, ElementSchema, NamespaceValues, SectionSchema } from "./schema.ts";
|
|
3
|
+
import type { ZoneContent } from "./render.tsx";
|
|
4
|
+
/** What the view receives. A page view gets its zones; a section/element
|
|
5
|
+
* view gets field values flat and styles complete (defaults filled).
|
|
6
|
+
* The page branch keys on `kind` — an optional-only check would match
|
|
7
|
+
* section schemas too, since a type lacking `__zones` still extends
|
|
8
|
+
* `{ __zones?: X }`. */
|
|
9
|
+
export type Props<S> = S extends {
|
|
10
|
+
kind: "page";
|
|
11
|
+
__zones?: infer Z extends Record<string, unknown>;
|
|
12
|
+
} ? {
|
|
13
|
+
zones: Record<keyof Z & string, ZoneContent>;
|
|
14
|
+
} : S extends {
|
|
15
|
+
__f?: infer F extends BuilderMap;
|
|
16
|
+
__s?: infer St extends BuilderMap;
|
|
17
|
+
} ? NamespaceValues<F> & {
|
|
18
|
+
styles: NamespaceValues<St>;
|
|
19
|
+
} : never;
|
|
20
|
+
/** What hard-coded JSX may pass: fields flat, styles optional and partial. */
|
|
21
|
+
type ExternalProps<F extends BuilderMap, St extends BuilderMap> = NamespaceValues<F> & {
|
|
22
|
+
styles?: Partial<NamespaceValues<St>>;
|
|
23
|
+
};
|
|
24
|
+
export type PairedComponent<Schema, F extends BuilderMap, St extends BuilderMap> = ((props: ExternalProps<F, St>) => ReactElement) & {
|
|
25
|
+
schema: Schema;
|
|
26
|
+
};
|
|
27
|
+
export declare function section<F extends BuilderMap, St extends BuilderMap>(schema: SectionSchema<F, St>, View: ComponentType<NamespaceValues<F> & {
|
|
28
|
+
styles: NamespaceValues<St>;
|
|
29
|
+
}>): PairedComponent<SectionSchema<F, St>, F, St>;
|
|
30
|
+
export declare function element<F extends BuilderMap, St extends BuilderMap>(schema: ElementSchema<F, St>, View: ComponentType<NamespaceValues<F> & {
|
|
31
|
+
styles: NamespaceValues<St>;
|
|
32
|
+
}>): PairedComponent<ElementSchema<F, St>, F, St>;
|
|
33
|
+
export {};
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
function styleDefaultsOf(styles) {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const [key, d] of Object.entries(styles)) {
|
|
5
|
+
if (d.default !== undefined)
|
|
6
|
+
out[key] = d.default;
|
|
7
|
+
}
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
function pair(schema, View) {
|
|
11
|
+
const defaults = styleDefaultsOf(schema.styles);
|
|
12
|
+
const Component = (props) => {
|
|
13
|
+
const { styles: styleOverrides, ...fields } = props ?? {};
|
|
14
|
+
const styles = { ...defaults, ...styleOverrides };
|
|
15
|
+
const V = View;
|
|
16
|
+
return _jsx(V, { ...fields, styles: styles });
|
|
17
|
+
};
|
|
18
|
+
Component.schema = schema;
|
|
19
|
+
return Component;
|
|
20
|
+
}
|
|
21
|
+
export function section(schema, View) {
|
|
22
|
+
return pair(schema, View);
|
|
23
|
+
}
|
|
24
|
+
export function element(schema, View) {
|
|
25
|
+
return pair(schema, View);
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RefEdge } from "./refs.ts";
|
|
2
|
+
export type RefSource = {
|
|
3
|
+
sourceKind: string;
|
|
4
|
+
sourceId: string;
|
|
5
|
+
};
|
|
6
|
+
export declare class MemoryRefIndex {
|
|
7
|
+
private rows;
|
|
8
|
+
/** Replace a source's outgoing edges wholesale (what every save does). */
|
|
9
|
+
rewrite(source: RefSource, edges: RefEdge[]): void;
|
|
10
|
+
remove({ sourceKind, sourceId }: RefSource): void;
|
|
11
|
+
/** The reverse question: who points at this target? */
|
|
12
|
+
to(targetCollection: string, targetId: string): {
|
|
13
|
+
sourceKind: string;
|
|
14
|
+
sourceId: string;
|
|
15
|
+
sourceField: string;
|
|
16
|
+
}[];
|
|
17
|
+
/** Reverse lookup by id alone (ids are globally unique) — the fan-out walk. */
|
|
18
|
+
incoming(targetId: string): RefSource[];
|
|
19
|
+
}
|
|
Binary file
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Descriptor } from "./fields.ts";
|
|
2
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
+
import type { PageTree } from "./render.tsx";
|
|
4
|
+
export type RefEdge = {
|
|
5
|
+
field: string;
|
|
6
|
+
targetCollection: string;
|
|
7
|
+
targetId: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function collectEntryRefs(fields: Record<string, unknown>, schema: CollectionSchema<any>): RefEdge[];
|
|
10
|
+
/** A section schema, or anything carrying one (a paired component). */
|
|
11
|
+
export type SectionLike = {
|
|
12
|
+
name: string;
|
|
13
|
+
fields: Record<string, Descriptor>;
|
|
14
|
+
} | {
|
|
15
|
+
schema: {
|
|
16
|
+
name: string;
|
|
17
|
+
fields: Record<string, Descriptor>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare function collectTreeRefs(tree: PageTree, sections: SectionLike[]): RefEdge[];
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The refs index feeders (DESIGN.md §3): walk a document against its
|
|
2
|
+
// schema and list every outgoing reference edge. Colocated schemas mean
|
|
3
|
+
// ref fields are always known, so the index is derived, rebuildable,
|
|
4
|
+
// and never the source of truth. Stores rewrite a source's edges on
|
|
5
|
+
// every save; `refs` answers only the REVERSE question (who depends on
|
|
6
|
+
// this?) — forward resolution stays resolveTree's job.
|
|
7
|
+
function targetOf(d) {
|
|
8
|
+
const t = d.target?.();
|
|
9
|
+
if (!t?.name)
|
|
10
|
+
throw new Error("smoodly: ref descriptor has no resolvable target.");
|
|
11
|
+
return t.name;
|
|
12
|
+
}
|
|
13
|
+
function collectFrom(values, descriptors, out) {
|
|
14
|
+
for (const [field, d] of Object.entries(descriptors)) {
|
|
15
|
+
const value = values[field];
|
|
16
|
+
if (value == null)
|
|
17
|
+
continue;
|
|
18
|
+
if (d.type === "ref" && typeof value === "string") {
|
|
19
|
+
out.push({ field, targetCollection: targetOf(d), targetId: value });
|
|
20
|
+
}
|
|
21
|
+
else if (d.type === "refList" && Array.isArray(value)) {
|
|
22
|
+
for (const id of value) {
|
|
23
|
+
if (typeof id === "string")
|
|
24
|
+
out.push({ field, targetCollection: targetOf(d), targetId: id });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function collectEntryRefs(fields, schema) {
|
|
30
|
+
const out = [];
|
|
31
|
+
collectFrom(fields, schema.fields, out);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export function collectTreeRefs(tree, sections) {
|
|
35
|
+
const byName = new Map(sections.map((s) => {
|
|
36
|
+
const schema = "schema" in s ? s.schema : s;
|
|
37
|
+
return [schema.name, schema];
|
|
38
|
+
}));
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const nodes of Object.values(tree.zones ?? {})) {
|
|
41
|
+
for (const node of nodes) {
|
|
42
|
+
const schema = byName.get(node.type);
|
|
43
|
+
if (!schema)
|
|
44
|
+
continue; // fail-soft, same as renderPage
|
|
45
|
+
collectFrom(node.fields, schema.fields, out);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { PageSchema } from "./page.ts";
|
|
3
|
+
export type TreeNode = {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
fields: Record<string, unknown>;
|
|
7
|
+
styles?: Record<string, unknown>;
|
|
8
|
+
meta?: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
/** `meta` is the page's own metadata (SEO title, description, og image).
|
|
11
|
+
* It rides inside the tree deliberately: trees are already JSONB in
|
|
12
|
+
* page_versions, so this needs no DDL change, and page meta becomes
|
|
13
|
+
* versioned with content — draft SEO edits stay unpublished, and publish
|
|
14
|
+
* moves content and meta together. */
|
|
15
|
+
export type PageTree = {
|
|
16
|
+
zones: Record<string, TreeNode[]>;
|
|
17
|
+
meta?: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
type AnyPaired = ComponentType<any> & {
|
|
20
|
+
schema: {
|
|
21
|
+
name: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type ZoneContent = {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly nodes: TreeNode[];
|
|
27
|
+
readonly components: ReadonlyMap<string, AnyPaired>;
|
|
28
|
+
readonly editor: boolean;
|
|
29
|
+
};
|
|
30
|
+
export declare function Zone({ of: content }: {
|
|
31
|
+
of: ZoneContent;
|
|
32
|
+
}): import("react").JSX.Element | null;
|
|
33
|
+
export type PairedPage<Zones extends Record<string, unknown>> = ((props: {
|
|
34
|
+
zones: Record<keyof Zones & string, ZoneContent>;
|
|
35
|
+
}) => ReactElement) & {
|
|
36
|
+
schema: PageSchema<Zones>;
|
|
37
|
+
};
|
|
38
|
+
export declare function page<Zones extends Record<string, unknown>>(schema: PageSchema<Zones>, View: ComponentType<{
|
|
39
|
+
zones: Record<keyof Zones & string, ZoneContent>;
|
|
40
|
+
}>): PairedPage<Zones>;
|
|
41
|
+
export type Registry = {
|
|
42
|
+
sections?: AnyPaired[];
|
|
43
|
+
elements?: AnyPaired[];
|
|
44
|
+
};
|
|
45
|
+
export declare function renderPage<Zones extends Record<string, unknown>>(Page: PairedPage<Zones>, tree: PageTree, registry: Registry, options?: {
|
|
46
|
+
editor?: boolean;
|
|
47
|
+
}): ReactElement;
|
|
48
|
+
export {};
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
export function Zone({ of: content }) {
|
|
3
|
+
// A malformed tree (bad data, a client that skipped isPageTree) must not
|
|
4
|
+
// crash the render — treat a non-array nodes as empty rather than throw.
|
|
5
|
+
const nodes = Array.isArray(content?.nodes) ? content.nodes : [];
|
|
6
|
+
if (!nodes.length) {
|
|
7
|
+
// In editor mode an empty zone still needs a measurable box for the
|
|
8
|
+
// overlay's "+ add" affordance; in production it renders nothing.
|
|
9
|
+
return content?.editor ? (_jsx("div", { "data-smoodly-zone": content.name, "data-smoodly-empty": "", style: { minHeight: 48 } })) : null;
|
|
10
|
+
}
|
|
11
|
+
return (_jsx(_Fragment, { children: nodes.map((node) => {
|
|
12
|
+
const Component = content.components.get(node.type);
|
|
13
|
+
if (!Component) {
|
|
14
|
+
console.warn(`smoodly: no registered component for node type "${node.type}" — skipping.`);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const styles = content.editor
|
|
18
|
+
? { ...(node.styles ?? {}), __smoodlyNode: node.id }
|
|
19
|
+
: (node.styles ?? {});
|
|
20
|
+
return _jsx(Component, { ...node.fields, styles: styles }, node.id);
|
|
21
|
+
}) }));
|
|
22
|
+
}
|
|
23
|
+
export function page(schema, View) {
|
|
24
|
+
const Component = ((props) => {
|
|
25
|
+
const V = View;
|
|
26
|
+
return _jsx(V, { zones: props.zones });
|
|
27
|
+
});
|
|
28
|
+
Component.schema = schema;
|
|
29
|
+
return Component;
|
|
30
|
+
}
|
|
31
|
+
export function renderPage(Page, tree, registry, options) {
|
|
32
|
+
const components = new Map();
|
|
33
|
+
for (const c of [...(registry.sections ?? []), ...(registry.elements ?? [])]) {
|
|
34
|
+
components.set(c.schema.name, c);
|
|
35
|
+
}
|
|
36
|
+
const zones = Object.fromEntries(Object.keys(Page.schema.zones).map((zone) => [
|
|
37
|
+
zone,
|
|
38
|
+
{ name: zone, nodes: tree.zones?.[zone] ?? [], components, editor: options?.editor ?? false },
|
|
39
|
+
]));
|
|
40
|
+
return _jsx(Page, { zones: zones });
|
|
41
|
+
}
|