@stackbox/cms 0.0.2

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/src/pages.ts ADDED
@@ -0,0 +1,198 @@
1
+ import type { SiteConfig } from "./site.js";
2
+ import type { Stackbox } from "./stackbox/context.js";
3
+ import {
4
+ isTemplate,
5
+ type SlotDefinition,
6
+ type SlotNamesFrom,
7
+ type RequiredSlotNamesFrom,
8
+ type TemplateDescriptor,
9
+ } from "./templates.js";
10
+ import {
11
+ type DefaultSlotContent,
12
+ type PageSlotsInput,
13
+ slotHasContent,
14
+ SlotContentValidationError,
15
+ validateSlotContentItem,
16
+ } from "./slot-content.js";
17
+
18
+ export type PageMeta = {
19
+ description?: string;
20
+ robots?: string;
21
+ canonical?: string;
22
+ ogTitle?: string;
23
+ ogDescription?: string;
24
+ ogImage?: string;
25
+ twitterCard?: string;
26
+ };
27
+
28
+ export type RenderContext = {
29
+ siteConfig: SiteConfig;
30
+ ctx?: Stackbox.Context;
31
+ };
32
+
33
+ export type Page<
34
+ Slots extends string = string,
35
+ RequiredSlots extends Slots = never,
36
+ Definitions extends readonly SlotDefinition[] = readonly SlotDefinition[],
37
+ > = {
38
+ readonly __kind: "page";
39
+ path: string;
40
+ template: TemplateDescriptor<Slots, RequiredSlots, Definitions>;
41
+ title: string;
42
+ meta?: PageMeta;
43
+ slots: PageSlotsInput<Definitions, RequiredSlots>;
44
+ };
45
+
46
+ /** Widened page type for site builds mixing templates and required slots. */
47
+ export type SitePage = {
48
+ readonly __kind: "page";
49
+ path: string;
50
+ template: TemplateDescriptor<string, string>;
51
+ title: string;
52
+ meta?: PageMeta;
53
+ slots: Partial<Record<string, DefaultSlotContent[]>>;
54
+ };
55
+
56
+ /** Page fields passed to template render (slot content lives on `slots`). */
57
+ export type PageRenderView = Omit<Page, "template" | "slots">;
58
+
59
+ export function toPageRenderView({
60
+ template: _template,
61
+ slots: _slots,
62
+ ...view
63
+ }: SitePage): PageRenderView {
64
+ return view;
65
+ }
66
+
67
+ export function isPage(value: unknown): value is Page {
68
+ return (
69
+ typeof value === "object" &&
70
+ value !== null &&
71
+ (value as Page).__kind === "page" &&
72
+ typeof (value as Page).path === "string" &&
73
+ (value as Page).path.length > 0 &&
74
+ isTemplate((value as Page).template) &&
75
+ typeof (value as Page).title === "string" &&
76
+ (value as Page).title.length > 0 &&
77
+ typeof (value as Page).slots === "object" &&
78
+ (value as Page).slots !== null
79
+ );
80
+ }
81
+
82
+ export class PageValidationError extends Error {
83
+ constructor(message: string) {
84
+ super(message);
85
+ this.name = "PageValidationError";
86
+ }
87
+ }
88
+
89
+ function validatePagePath(path: string): void {
90
+ if (!path || !path.startsWith("/")) {
91
+ throw new PageValidationError('path must start with "/"');
92
+ }
93
+ if (path.includes("..")) {
94
+ throw new PageValidationError("path must not contain '..'");
95
+ }
96
+ const lastSegment = path.split("/").pop() ?? "";
97
+ if (lastSegment.includes(".")) {
98
+ throw new PageValidationError("path must not include a file extension");
99
+ }
100
+ }
101
+
102
+ function validatePageSlotContent(
103
+ template: TemplateDescriptor<string, string>,
104
+ slots: Partial<Record<string, DefaultSlotContent[]>>,
105
+ ): void {
106
+ const templateSlotNames = new Set(Object.keys(template.slots));
107
+
108
+ for (const key of Object.keys(slots)) {
109
+ if (!templateSlotNames.has(key)) {
110
+ throw new PageValidationError(`unknown slot "${key}" for this template`);
111
+ }
112
+
113
+ const items = slots[key];
114
+ if (!Array.isArray(items)) {
115
+ throw new PageValidationError(
116
+ `slot "${key}" must be an array of content items`,
117
+ );
118
+ }
119
+
120
+ const schema = template.slots[key]?.schema;
121
+ for (const item of items) {
122
+ try {
123
+ validateSlotContentItem(key, item, schema);
124
+ } catch (err) {
125
+ if (err instanceof SlotContentValidationError) {
126
+ throw new PageValidationError(err.message);
127
+ }
128
+ throw err;
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ export function createPage(
135
+ template: TemplateDescriptor<string, string>,
136
+ def: {
137
+ path: string;
138
+ title: string;
139
+ meta?: PageMeta;
140
+ slots: Partial<Record<string, DefaultSlotContent[]>>;
141
+ },
142
+ ): SitePage;
143
+ export function createPage<const S extends readonly SlotDefinition[]>(
144
+ template: TemplateDescriptor<
145
+ SlotNamesFrom<S>,
146
+ RequiredSlotNamesFrom<S>,
147
+ S
148
+ >,
149
+ def: {
150
+ path: string;
151
+ title: string;
152
+ meta?: PageMeta;
153
+ slots: PageSlotsInput<S, RequiredSlotNamesFrom<S>>;
154
+ },
155
+ ): Page<SlotNamesFrom<S>, RequiredSlotNamesFrom<S>, S>;
156
+ export function createPage(
157
+ template: TemplateDescriptor<string, string>,
158
+ def: {
159
+ path: string;
160
+ title: string;
161
+ meta?: PageMeta;
162
+ slots: Partial<Record<string, DefaultSlotContent[]>>;
163
+ },
164
+ ): SitePage {
165
+ if (!isTemplate(template)) {
166
+ throw new PageValidationError(
167
+ "template must be created with createTemplate()",
168
+ );
169
+ }
170
+
171
+ validatePagePath(def.path);
172
+
173
+ if (!def.title || def.title.trim().length === 0) {
174
+ throw new PageValidationError("title is required");
175
+ }
176
+
177
+ validatePageSlotContent(template, def.slots);
178
+
179
+ for (const required of template.requiredSlots) {
180
+ const items = def.slots[required as keyof typeof def.slots];
181
+ if (!items || items.length === 0 || !slotHasContent(items)) {
182
+ throw new PageValidationError(
183
+ `required slot "${required}" must have at least one module or non-empty HTML string`,
184
+ );
185
+ }
186
+ }
187
+
188
+ return {
189
+ __kind: "page" as const,
190
+ path: def.path,
191
+ template,
192
+ title: def.title,
193
+ meta: def.meta,
194
+ slots: def.slots,
195
+ };
196
+ }
197
+
198
+ export { validateSlotContentItem } from "./slot-content.js";
@@ -0,0 +1,32 @@
1
+ import { html, type HSHtml } from "@hyperspan/html";
2
+ import type { PageMeta } from "./pages.js";
3
+
4
+ export function renderStandardHead(input: {
5
+ title: string;
6
+ meta?: PageMeta;
7
+ }): HSHtml {
8
+ const { title, meta = {} } = input;
9
+ const ogTitle = meta.ogTitle ?? title;
10
+ const ogDescription = meta.ogDescription ?? meta.description;
11
+
12
+ return html`<meta charset="utf-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1">
14
+ <title>${title}</title>
15
+ ${meta.description
16
+ ? html`<meta name="description" content="${meta.description}">`
17
+ : ""}
18
+ ${meta.robots ? html`<meta name="robots" content="${meta.robots}">` : ""}
19
+ ${meta.canonical
20
+ ? html`<link rel="canonical" href="${meta.canonical}">`
21
+ : ""}
22
+ <meta property="og:title" content="${ogTitle}">
23
+ ${ogDescription
24
+ ? html`<meta property="og:description" content="${ogDescription}">`
25
+ : ""}
26
+ ${meta.ogImage
27
+ ? html`<meta property="og:image" content="${meta.ogImage}">`
28
+ : ""}
29
+ ${meta.twitterCard
30
+ ? html`<meta name="twitter:card" content="${meta.twitterCard}">`
31
+ : ""}`;
32
+ }
@@ -0,0 +1,51 @@
1
+ import { renderAsync } from "@hyperspan/html";
2
+ import {
3
+ type SitePage,
4
+ type RenderContext,
5
+ toPageRenderView,
6
+ PageValidationError,
7
+ } from "./pages.js";
8
+ import type { SiteConfig } from "./site.js";
9
+ import type { Stackbox } from "./stackbox/context.js";
10
+ import { slotHasContent } from "./slot-content.js";
11
+ import { buildPageSlots, RenderError } from "./slot-handle.js";
12
+ import { renderStandardHead } from "./render-head.js";
13
+
14
+ export { RenderError } from "./slot-handle.js";
15
+
16
+ function validateRequiredSlots(page: SitePage): void {
17
+ for (const required of page.template.requiredSlots) {
18
+ const items = page.slots[required as keyof typeof page.slots];
19
+ if (!items || items.length === 0 || !slotHasContent(items)) {
20
+ throw new PageValidationError(
21
+ `required slot "${required}" must have at least one module or non-empty HTML string`,
22
+ );
23
+ }
24
+ }
25
+ }
26
+
27
+ export async function renderPage(
28
+ page: SitePage,
29
+ siteConfig: SiteConfig,
30
+ ctx?: Stackbox.Context,
31
+ ): Promise<string> {
32
+ validateRequiredSlots(page);
33
+
34
+ const renderCtx: RenderContext = { siteConfig, ctx };
35
+
36
+ const slots = buildPageSlots(page, renderCtx);
37
+
38
+ const head = renderStandardHead({
39
+ title: page.template.formatPageTitle(page.title),
40
+ meta: page.meta,
41
+ });
42
+
43
+ return renderAsync(
44
+ page.template.render({
45
+ head,
46
+ siteConfig,
47
+ page: toPageRenderView(page),
48
+ slots,
49
+ }),
50
+ );
51
+ }
package/src/routing.ts ADDED
@@ -0,0 +1,14 @@
1
+ export function normalizePathname(pathname: string): string {
2
+ if (pathname === "" || pathname === "/") {
3
+ return "/";
4
+ }
5
+ const trimmed = pathname.replace(/\/+$/, "");
6
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
7
+ }
8
+
9
+ export function matchPagePath(
10
+ pathname: string,
11
+ pagePath: string,
12
+ ): boolean {
13
+ return normalizePathname(pathname) === normalizePathname(pagePath);
14
+ }
package/src/site.ts ADDED
@@ -0,0 +1,153 @@
1
+ import type { SitePage } from "./pages.js";
2
+ import { isPage } from "./pages.js";
3
+ import { renderPage } from "./render-page.js";
4
+ import { normalizePathname } from "./routing.js";
5
+ import { createContext } from "./stackbox/context.js";
6
+
7
+ export type SiteConfig<
8
+ T extends Record<string, unknown> = Record<string, unknown>,
9
+ > = {
10
+ readonly __kind: "siteConfig";
11
+ readonly config: T;
12
+ };
13
+
14
+ export type Site<
15
+ T extends Record<string, unknown> = Record<string, unknown>,
16
+ > = {
17
+ readonly __kind: "site";
18
+ readonly siteConfig: SiteConfig<T>;
19
+ readonly pages: readonly SitePage[];
20
+ fetch(
21
+ request: globalThis.Request,
22
+ env?: Record<string, unknown>,
23
+ ): Promise<globalThis.Response>;
24
+ };
25
+
26
+ export class SiteError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = "SiteError";
30
+ }
31
+ }
32
+
33
+ function validateConfigObject(config: unknown, label: string): void {
34
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
35
+ throw new SiteError(`${label}: config must be a non-null object`);
36
+ }
37
+ if (Object.keys(config).length === 0) {
38
+ throw new SiteError(`${label}: config must not be empty`);
39
+ }
40
+ }
41
+
42
+ export function createSiteConfig<T extends Record<string, unknown>>(
43
+ config: T,
44
+ ): SiteConfig<T> {
45
+ validateConfigObject(config, "createSiteConfig(config)");
46
+ return {
47
+ __kind: "siteConfig" as const,
48
+ config,
49
+ };
50
+ }
51
+
52
+ export function isSiteConfig(value: unknown): value is SiteConfig {
53
+ return (
54
+ typeof value === "object" &&
55
+ value !== null &&
56
+ (value as SiteConfig).__kind === "siteConfig" &&
57
+ typeof (value as SiteConfig).config === "object" &&
58
+ (value as SiteConfig).config !== null &&
59
+ !Array.isArray((value as SiteConfig).config)
60
+ );
61
+ }
62
+
63
+ function assertUniquePaths(pages: SitePage[]): void {
64
+ const seen = new Map<string, string>();
65
+
66
+ for (const page of pages) {
67
+ const existing = seen.get(page.path);
68
+ if (existing) {
69
+ throw new SiteError(
70
+ `duplicate page path "${page.path}" (${existing} and ${page.title})`,
71
+ );
72
+ }
73
+ seen.set(page.path, page.title);
74
+ }
75
+ }
76
+
77
+ function buildPageMap(pages: readonly SitePage[]): Map<string, SitePage> {
78
+ const map = new Map<string, SitePage>();
79
+ for (const page of pages) {
80
+ map.set(normalizePathname(page.path), page);
81
+ }
82
+ return map;
83
+ }
84
+
85
+ export function createSite<T extends Record<string, unknown>>(
86
+ siteConfig: SiteConfig<T>,
87
+ options: { pages: [SitePage, ...SitePage[]] },
88
+ ): Site<T> {
89
+ if (!isSiteConfig(siteConfig)) {
90
+ throw new SiteError(
91
+ "createSite(siteConfig, options): siteConfig must be from createSiteConfig()",
92
+ );
93
+ }
94
+
95
+ const { pages } = options;
96
+ if (!Array.isArray(pages) || pages.length === 0) {
97
+ throw new SiteError(
98
+ "createSite(siteConfig, options): pages must be a non-empty array",
99
+ );
100
+ }
101
+
102
+ for (const page of pages) {
103
+ if (!isPage(page)) {
104
+ throw new SiteError(
105
+ "createSite(siteConfig, options): every page must be from createPage()",
106
+ );
107
+ }
108
+ }
109
+
110
+ assertUniquePaths(pages);
111
+
112
+ const pageMap = buildPageMap(pages);
113
+
114
+ return {
115
+ __kind: "site" as const,
116
+ siteConfig,
117
+ pages,
118
+ async fetch(request, env = {}) {
119
+ const ctx = createContext(request, env);
120
+ const method = ctx.req.method;
121
+
122
+ if (method !== "GET" && method !== "HEAD") {
123
+ return ctx.res.text("Method Not Allowed", { status: 405 });
124
+ }
125
+
126
+ const pathname = normalizePathname(ctx.req.url.pathname);
127
+ const page = pageMap.get(pathname);
128
+
129
+ if (!page) {
130
+ return ctx.res.notFound();
131
+ }
132
+
133
+ const html = await renderPage(page, siteConfig, ctx);
134
+
135
+ if (method === "HEAD") {
136
+ return ctx.res.html("", { status: 200 });
137
+ }
138
+
139
+ return ctx.res.html(html);
140
+ },
141
+ };
142
+ }
143
+
144
+ export function isSite(value: unknown): value is Site {
145
+ return (
146
+ typeof value === "object" &&
147
+ value !== null &&
148
+ (value as Site).__kind === "site" &&
149
+ isSiteConfig((value as Site).siteConfig) &&
150
+ Array.isArray((value as Site).pages) &&
151
+ typeof (value as Site).fetch === "function"
152
+ );
153
+ }
@@ -0,0 +1,77 @@
1
+ import type { z } from "zod";
2
+ import { z as zod } from "zod";
3
+ import { isModule, type Module } from "./modules.js";
4
+ import type { SlotDefinition, SlotNamesFrom } from "./templates.js";
5
+
6
+ export type DefaultSlotContent = Module | string;
7
+
8
+ /** Accepts any module or HTML string (default slot content). */
9
+ export const anySlotContentSchema: z.ZodType<DefaultSlotContent> = zod.union([
10
+ zod.string(),
11
+ zod.custom<Module>((value) => isModule(value)),
12
+ ]);
13
+
14
+ /** Accepts only HTML strings. */
15
+ export const stringSlotContentSchema = zod.string();
16
+
17
+ /** Accepts only CMS modules. */
18
+ export const moduleSlotContentSchema: z.ZodType<Module> = zod.custom<Module>(
19
+ (value) => isModule(value),
20
+ );
21
+
22
+ export type SlotContentFromDefinition<D extends SlotDefinition> =
23
+ D extends { options: { schema: infer Schema extends z.ZodTypeAny } }
24
+ ? z.input<Schema>
25
+ : DefaultSlotContent;
26
+
27
+ export type PageSlotsInput<
28
+ S extends readonly SlotDefinition[],
29
+ Required extends string,
30
+ > = {
31
+ [K in Required]: SlotContentFromDefinition<
32
+ Extract<S[number], { name: K }>
33
+ >[];
34
+ } & Partial<{
35
+ [K in Exclude<SlotNamesFrom<S>, Required>]: SlotContentFromDefinition<
36
+ Extract<S[number], { name: K }>
37
+ >[];
38
+ }>;
39
+
40
+ export function validateSlotContentItem(
41
+ slotName: string,
42
+ item: unknown,
43
+ schema: z.ZodTypeAny | undefined,
44
+ ): void {
45
+ if (schema) {
46
+ const result = schema.safeParse(item);
47
+ if (!result.success) {
48
+ throw new SlotContentValidationError(
49
+ slotName,
50
+ result.error.message,
51
+ );
52
+ }
53
+ return;
54
+ }
55
+
56
+ if (typeof item !== "string" && !isModule(item)) {
57
+ throw new SlotContentValidationError(
58
+ slotName,
59
+ "expected module or HTML string",
60
+ );
61
+ }
62
+ }
63
+
64
+ export class SlotContentValidationError extends Error {
65
+ constructor(slotName: string, message: string) {
66
+ super(`slot "${slotName}" content failed validation: ${message}`);
67
+ this.name = "SlotContentValidationError";
68
+ }
69
+ }
70
+
71
+ export function slotHasContent(items: unknown[]): boolean {
72
+ return items.some(
73
+ (item) =>
74
+ isModule(item) ||
75
+ (typeof item === "string" && item.trim().length > 0),
76
+ );
77
+ }
@@ -0,0 +1,131 @@
1
+ import { html, type HSHtml } from "@hyperspan/html";
2
+ import type { z } from "zod";
3
+ import { isModule } from "./modules.js";
4
+ import type { SitePage, RenderContext } from "./pages.js";
5
+ import {
6
+ type DefaultSlotContent,
7
+ type SlotContentFromDefinition,
8
+ validateSlotContentItem,
9
+ } from "./slot-content.js";
10
+ import type { SlotDefinition, SlotNamesFrom } from "./templates.js";
11
+
12
+ export class RenderError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "RenderError";
16
+ }
17
+ }
18
+
19
+ export const SLOT_SENTINEL_PREFIX = "<!--__STACKBOX_SLOT__:";
20
+
21
+ export function slotSentinel(name: string): string {
22
+ return `${SLOT_SENTINEL_PREFIX}${name}-->`;
23
+ }
24
+
25
+ type HtmlSafe = ReturnType<typeof html.raw>;
26
+ export type { HSHtml } from "@hyperspan/html";
27
+ export type SlotRenderValue = HSHtml | HtmlSafe | Promise<HSHtml>;
28
+
29
+ export type Slot<
30
+ TContent = DefaultSlotContent,
31
+ D extends SlotDefinition = SlotDefinition,
32
+ > = {
33
+ readonly name: D["name"];
34
+ readonly definition: D;
35
+ readonly content: readonly TContent[];
36
+ render(): SlotRenderValue;
37
+ };
38
+
39
+ export type TemplateSlotsFrom<S extends readonly SlotDefinition[]> = {
40
+ [K in SlotNamesFrom<S>]: Slot<
41
+ SlotContentFromDefinition<Extract<S[number], { name: K }>>,
42
+ Extract<S[number], { name: K }>
43
+ >;
44
+ };
45
+
46
+ export function renderSlotContent(
47
+ slotName: string,
48
+ items: readonly unknown[],
49
+ ctx: RenderContext,
50
+ schema: z.ZodTypeAny | undefined,
51
+ ): Promise<HSHtml> {
52
+ return Promise.all(
53
+ items.map(async (item) => {
54
+ try {
55
+ validateSlotContentItem(slotName, item, schema);
56
+ } catch (err) {
57
+ throw new RenderError(
58
+ err instanceof Error ? err.message : String(err),
59
+ );
60
+ }
61
+
62
+ if (typeof item === "string") {
63
+ return html.raw(item);
64
+ }
65
+ if (isModule(item)) {
66
+ return item.render(ctx);
67
+ }
68
+ throw new RenderError(
69
+ "invalid slot content; expected module or HTML string",
70
+ );
71
+ }),
72
+ ).then((chunks) => html`${chunks}`);
73
+ }
74
+
75
+ export function createStubSlot<
76
+ D extends SlotDefinition,
77
+ >(definition: D): Slot<DefaultSlotContent, D> {
78
+ return {
79
+ name: definition.name,
80
+ definition,
81
+ content: [],
82
+ render() {
83
+ return html.raw(slotSentinel(definition.name));
84
+ },
85
+ };
86
+ }
87
+
88
+ export function createPageSlot<
89
+ D extends SlotDefinition,
90
+ >(
91
+ definition: D,
92
+ content: readonly SlotContentFromDefinition<D>[],
93
+ ctx: RenderContext,
94
+ ): Slot<SlotContentFromDefinition<D>, D> {
95
+ const schema = definition.options?.schema;
96
+ return {
97
+ name: definition.name,
98
+ definition,
99
+ content,
100
+ render() {
101
+ return renderSlotContent(definition.name, content, ctx, schema);
102
+ },
103
+ };
104
+ }
105
+
106
+ export function buildStubSlots<S extends readonly SlotDefinition[]>(
107
+ definitions: S,
108
+ ): TemplateSlotsFrom<S> {
109
+ const slots = {} as TemplateSlotsFrom<S>;
110
+ for (const def of definitions) {
111
+ slots[def.name as SlotNamesFrom<S>] = createStubSlot(
112
+ def,
113
+ ) as TemplateSlotsFrom<S>[SlotNamesFrom<S>];
114
+ }
115
+ return slots;
116
+ }
117
+
118
+ export function buildPageSlots(
119
+ page: SitePage,
120
+ ctx: RenderContext,
121
+ ): Record<string, Slot> {
122
+ const slots: Record<string, Slot> = {};
123
+ const definitions = page.template.__definitions ?? [];
124
+
125
+ for (const def of definitions) {
126
+ const content = page.slots[def.name] ?? [];
127
+ slots[def.name] = createPageSlot(def, content, ctx);
128
+ }
129
+
130
+ return slots;
131
+ }