@stackbox/cms 0.0.4 → 0.0.5

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.
Files changed (113) hide show
  1. package/dist/blocks.d.ts +18 -0
  2. package/dist/blocks.d.ts.map +1 -0
  3. package/dist/blocks.js +25 -0
  4. package/dist/blocks.js.map +1 -0
  5. package/dist/index.d.ts +12 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +12 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/link.d.ts +10 -0
  10. package/dist/link.d.ts.map +1 -0
  11. package/dist/link.js +128 -0
  12. package/dist/link.js.map +1 -0
  13. package/dist/modules/blog/module.d.ts +20 -0
  14. package/dist/modules/blog/module.d.ts.map +1 -0
  15. package/dist/modules/blog/module.js +52 -0
  16. package/dist/modules/blog/module.js.map +1 -0
  17. package/dist/modules/blog/posts.d.ts +15 -0
  18. package/dist/modules/blog/posts.d.ts.map +1 -0
  19. package/dist/modules/blog/posts.js +46 -0
  20. package/dist/modules/blog/posts.js.map +1 -0
  21. package/dist/modules.d.ts +18 -0
  22. package/dist/modules.d.ts.map +1 -0
  23. package/dist/modules.js +25 -0
  24. package/dist/modules.js.map +1 -0
  25. package/dist/pages.d.ts +55 -0
  26. package/dist/pages.d.ts.map +1 -0
  27. package/dist/pages.js +85 -0
  28. package/dist/pages.js.map +1 -0
  29. package/dist/plugins/blog/index.d.ts +20 -0
  30. package/dist/plugins/blog/index.d.ts.map +1 -0
  31. package/dist/plugins/blog/index.js +52 -0
  32. package/dist/plugins/blog/index.js.map +1 -0
  33. package/dist/plugins/blog/module.d.ts +20 -0
  34. package/dist/plugins/blog/module.d.ts.map +1 -0
  35. package/dist/plugins/blog/module.js +52 -0
  36. package/dist/plugins/blog/module.js.map +1 -0
  37. package/dist/plugins/blog/plugin.d.ts +20 -0
  38. package/dist/plugins/blog/plugin.d.ts.map +1 -0
  39. package/dist/plugins/blog/plugin.js +52 -0
  40. package/dist/plugins/blog/plugin.js.map +1 -0
  41. package/dist/plugins/blog/posts.d.ts +15 -0
  42. package/dist/plugins/blog/posts.d.ts.map +1 -0
  43. package/dist/plugins/blog/posts.js +46 -0
  44. package/dist/plugins/blog/posts.js.map +1 -0
  45. package/dist/plugins/random-quote/index.d.ts +12 -0
  46. package/dist/plugins/random-quote/index.d.ts.map +1 -0
  47. package/dist/plugins/random-quote/index.js +21 -0
  48. package/dist/plugins/random-quote/index.js.map +1 -0
  49. package/dist/plugins/random-quote/module.d.ts +9 -0
  50. package/dist/plugins/random-quote/module.d.ts.map +1 -0
  51. package/dist/plugins/random-quote/module.js +31 -0
  52. package/dist/plugins/random-quote/module.js.map +1 -0
  53. package/dist/plugins/random-quote/plugin.d.ts +8 -0
  54. package/dist/plugins/random-quote/plugin.d.ts.map +1 -0
  55. package/dist/plugins/random-quote/plugin.js +31 -0
  56. package/dist/plugins/random-quote/plugin.js.map +1 -0
  57. package/dist/plugins/random-quote/public_assets/quotes.json +230 -0
  58. package/dist/plugins/random-quote/quotes.json +230 -0
  59. package/dist/public/_sb/plugins/random-quote/quotes.json +230 -0
  60. package/dist/render-head.d.ts +7 -0
  61. package/dist/render-head.d.ts.map +1 -0
  62. package/dist/render-head.js +27 -0
  63. package/dist/render-head.js.map +1 -0
  64. package/dist/render-page.d.ts +6 -0
  65. package/dist/render-page.d.ts.map +1 -0
  66. package/dist/render-page.js +30 -0
  67. package/dist/render-page.js.map +1 -0
  68. package/dist/routing.d.ts +3 -0
  69. package/dist/routing.d.ts.map +1 -0
  70. package/dist/routing.js +11 -0
  71. package/dist/routing.js.map +1 -0
  72. package/dist/site.d.ts +21 -0
  73. package/dist/site.d.ts.map +1 -0
  74. package/dist/site.js +102 -0
  75. package/dist/site.js.map +1 -0
  76. package/dist/slot-content.d.ts +30 -0
  77. package/dist/slot-content.d.ts.map +1 -0
  78. package/dist/slot-content.js +34 -0
  79. package/dist/slot-content.js.map +1 -0
  80. package/dist/slot-handle.d.ts +32 -0
  81. package/dist/slot-handle.d.ts.map +1 -0
  82. package/dist/slot-handle.js +68 -0
  83. package/dist/slot-handle.js.map +1 -0
  84. package/dist/stackbox/context.d.ts +52 -0
  85. package/dist/stackbox/context.d.ts.map +1 -0
  86. package/dist/stackbox/context.js +165 -0
  87. package/dist/stackbox/context.js.map +1 -0
  88. package/dist/templates.d.ts +63 -0
  89. package/dist/templates.d.ts.map +1 -0
  90. package/dist/templates.js +106 -0
  91. package/dist/templates.js.map +1 -0
  92. package/package.json +7 -2
  93. package/.claude/settings.local.json +0 -8
  94. package/scripts/copy-plugin-assets.mjs +0 -30
  95. package/src/blocks.ts +0 -58
  96. package/src/index.ts +0 -67
  97. package/src/link.ts +0 -158
  98. package/src/pages.ts +0 -198
  99. package/src/render-head.ts +0 -32
  100. package/src/render-page.ts +0 -51
  101. package/src/routing.ts +0 -14
  102. package/src/site.ts +0 -160
  103. package/src/slot-content.ts +0 -77
  104. package/src/slot-handle.ts +0 -131
  105. package/src/stackbox/context.ts +0 -257
  106. package/src/templates.ts +0 -230
  107. package/test/async-render.test.ts +0 -103
  108. package/test/blog.test.ts +0 -93
  109. package/test/fetch-routing.test.ts +0 -76
  110. package/test/plugin-assets.test.ts +0 -80
  111. package/test/random-quote.test.ts +0 -55
  112. package/test/stackbox-context.test.ts +0 -44
  113. package/tsconfig.json +0 -20
@@ -1,131 +0,0 @@
1
- import { html, type HSHtml } from "@hyperspan/html";
2
- import type { z } from "zod";
3
- import { isBlock } from "./blocks.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 (isBlock(item)) {
66
- return item.render(ctx);
67
- }
68
- throw new RenderError(
69
- "invalid slot content; expected block 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
- }
@@ -1,257 +0,0 @@
1
- export namespace Stackbox {
2
- export type CookieOptions = {
3
- maxAge?: number;
4
- domain?: string;
5
- path?: string;
6
- expires?: Date;
7
- httpOnly?: boolean;
8
- secure?: boolean;
9
- sameSite?: "lax" | "strict" | true;
10
- };
11
-
12
- export type Cookies = {
13
- _req: globalThis.Request;
14
- _responseHeaders: Headers | undefined;
15
- _parsedCookies: Record<string, string>;
16
- _encrypt: ((str: string) => string) | undefined;
17
- _decrypt: ((str: string) => string) | undefined;
18
- get: (name: string) => string | undefined;
19
- set: (name: string, value: string, options?: CookieOptions) => void;
20
- delete: (name: string) => void;
21
- };
22
-
23
- export type Request = {
24
- url: URL;
25
- raw: globalThis.Request;
26
- method: string;
27
- headers: Headers;
28
- query: URLSearchParams;
29
- cookies: Cookies;
30
- text: () => Promise<string>;
31
- json: <T = unknown>() => Promise<T>;
32
- formData: () => Promise<FormData>;
33
- urlencoded: () => Promise<URLSearchParams>;
34
- };
35
-
36
- export type Response = {
37
- cookies: Cookies;
38
- headers: Headers;
39
- status: number | undefined;
40
- html: (
41
- html: string,
42
- options?: ResponseInit,
43
- ) => Promise<globalThis.Response>;
44
- json: (
45
- json: unknown,
46
- options?: ResponseInit,
47
- ) => Promise<globalThis.Response>;
48
- text: (
49
- text: string,
50
- options?: ResponseInit,
51
- ) => Promise<globalThis.Response>;
52
- redirect: (
53
- url: string,
54
- options?: ResponseInit,
55
- ) => Promise<globalThis.Response>;
56
- error: (
57
- error: Error,
58
- options?: ResponseInit,
59
- ) => Promise<globalThis.Response>;
60
- notFound: (options?: ResponseInit) => Promise<globalThis.Response>;
61
- merge: (response: globalThis.Response) => Promise<globalThis.Response>;
62
- };
63
-
64
- export interface Context<
65
- TEnv extends Record<string, unknown> = Record<string, unknown>,
66
- > {
67
- env: TEnv;
68
- req: Request;
69
- res: Response;
70
- }
71
- }
72
-
73
- function parseCookieHeader(header: string): Record<string, string> {
74
- const cookies: Record<string, string> = {};
75
- for (const part of header.split(";")) {
76
- const trimmed = part.trim();
77
- if (!trimmed) continue;
78
- const eq = trimmed.indexOf("=");
79
- if (eq === -1) continue;
80
- const name = trimmed.slice(0, eq).trim();
81
- const value = trimmed.slice(eq + 1).trim();
82
- if (name) cookies[name] = decodeURIComponent(value);
83
- }
84
- return cookies;
85
- }
86
-
87
- function serializeCookie(
88
- name: string,
89
- value: string,
90
- options?: Stackbox.CookieOptions,
91
- ): string {
92
- let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
93
- if (options?.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`;
94
- if (options?.domain) cookie += `; Domain=${options.domain}`;
95
- if (options?.path) cookie += `; Path=${options.path}`;
96
- else cookie += "; Path=/";
97
- if (options?.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
98
- if (options?.httpOnly) cookie += "; HttpOnly";
99
- if (options?.secure) cookie += "; Secure";
100
- if (options?.sameSite === true) cookie += "; SameSite=Strict";
101
- else if (options?.sameSite) {
102
- const s = options.sameSite === "strict" ? "Strict" : "Lax";
103
- cookie += `; SameSite=${s}`;
104
- }
105
- return cookie;
106
- }
107
-
108
- function createCookies(
109
- req: globalThis.Request,
110
- responseHeaders: Headers | undefined,
111
- parsed: Record<string, string>,
112
- ): Stackbox.Cookies {
113
- return {
114
- _req: req,
115
- _responseHeaders: responseHeaders,
116
- _parsedCookies: parsed,
117
- _encrypt: undefined,
118
- _decrypt: undefined,
119
- get(name: string) {
120
- return parsed[name];
121
- },
122
- set(name: string, value: string, options?: Stackbox.CookieOptions) {
123
- if (!responseHeaders) return;
124
- parsed[name] = value;
125
- responseHeaders.append(
126
- "Set-Cookie",
127
- serializeCookie(name, value, options),
128
- );
129
- },
130
- delete(name: string) {
131
- if (!responseHeaders) return;
132
- delete parsed[name];
133
- responseHeaders.append(
134
- "Set-Cookie",
135
- serializeCookie(name, "", { maxAge: 0, path: "/" }),
136
- );
137
- },
138
- };
139
- }
140
-
141
- function buildResponse(
142
- res: Stackbox.Response,
143
- body: BodyInit | null,
144
- init: ResponseInit = {},
145
- ): globalThis.Response {
146
- const headers = new Headers(init.headers);
147
- res.headers.forEach((value, key) => {
148
- if (!headers.has(key)) headers.set(key, value);
149
- });
150
- for (const value of res.headers.getSetCookie?.() ?? []) {
151
- headers.append("Set-Cookie", value);
152
- }
153
- if (body !== null && !headers.has("Content-Type") && typeof body === "string") {
154
- headers.set("Content-Type", "text/plain; charset=utf-8");
155
- }
156
- const status = init.status ?? res.status ?? 200;
157
- return new globalThis.Response(body, { ...init, status, headers });
158
- }
159
-
160
- function createStackboxResponse(
161
- reqCookies: Stackbox.Cookies,
162
- ): Stackbox.Response {
163
- const headers = new Headers();
164
- const resCookies = createCookies(
165
- reqCookies._req,
166
- headers,
167
- { ...reqCookies._parsedCookies },
168
- );
169
-
170
- const res: Stackbox.Response = {
171
- cookies: resCookies,
172
- headers,
173
- status: undefined,
174
- async html(html, options = {}) {
175
- res.status = options.status ?? 200;
176
- const outHeaders = new Headers(options.headers);
177
- if (!outHeaders.has("Content-Type")) {
178
- outHeaders.set("Content-Type", "text/html; charset=utf-8");
179
- }
180
- return buildResponse(res, html, { ...options, headers: outHeaders });
181
- },
182
- async json(json, options = {}) {
183
- res.status = options.status ?? 200;
184
- const outHeaders = new Headers(options.headers);
185
- if (!outHeaders.has("Content-Type")) {
186
- outHeaders.set("Content-Type", "application/json; charset=utf-8");
187
- }
188
- return buildResponse(res, JSON.stringify(json), {
189
- ...options,
190
- headers: outHeaders,
191
- });
192
- },
193
- async text(text, options = {}) {
194
- res.status = options.status ?? 200;
195
- return buildResponse(res, text, options);
196
- },
197
- async redirect( url, options = {}) {
198
- const outHeaders = new Headers(options.headers);
199
- outHeaders.set("Location", url);
200
- res.status = options.status ?? 302;
201
- return buildResponse(res, null, { ...options, headers: outHeaders });
202
- },
203
- async error(error, options = {}) {
204
- res.status = options.status ?? 500;
205
- return buildResponse(res, error.message, options);
206
- },
207
- async notFound(options = {}) {
208
- res.status = options.status ?? 400 + 4;
209
- return buildResponse(res, "Not Found", { ...options, status: res.status });
210
- },
211
- async merge(response) {
212
- const merged = new Headers(response.headers);
213
- res.headers.forEach((value, key) => {
214
- if (!merged.has(key)) merged.set(key, value);
215
- });
216
- for (const value of res.headers.getSetCookie?.() ?? []) {
217
- merged.append("Set-Cookie", value);
218
- }
219
- return new globalThis.Response(response.body, {
220
- status: response.status,
221
- statusText: response.statusText,
222
- headers: merged,
223
- });
224
- },
225
- };
226
-
227
- return res;
228
- }
229
-
230
- export function createContext<
231
- TEnv extends Record<string, unknown> = Record<string, unknown>,
232
- >(raw: globalThis.Request, env: TEnv = {} as TEnv): Stackbox.Context<TEnv> {
233
- const url = new URL(raw.url);
234
- const parsed = parseCookieHeader(raw.headers.get("Cookie") ?? "");
235
- const reqHeaders = new Headers(raw.headers);
236
- const reqCookies = createCookies(raw, undefined, parsed);
237
-
238
- const req: Stackbox.Request = {
239
- url,
240
- raw,
241
- method: raw.method.toUpperCase(),
242
- headers: reqHeaders,
243
- query: url.searchParams,
244
- cookies: reqCookies,
245
- text: () => raw.text(),
246
- json: <T = unknown>() => raw.json() as Promise<T>,
247
- formData: () => raw.formData(),
248
- urlencoded: async () => {
249
- const text = await raw.text();
250
- return new URLSearchParams(text);
251
- },
252
- };
253
-
254
- const res = createStackboxResponse(reqCookies);
255
-
256
- return { env, req, res };
257
- }
package/src/templates.ts DELETED
@@ -1,230 +0,0 @@
1
- import { render, type HSHtml } from "@hyperspan/html";
2
- import type { z } from "zod";
3
- import { isSiteConfig, type SiteConfig } from "./site.js";
4
- import type { PageRenderView } from "./pages.js";
5
- import {
6
- buildStubSlots,
7
- slotSentinel,
8
- type TemplateSlotsFrom,
9
- } from "./slot-handle.js";
10
-
11
- export type SlotOptions = {
12
- required?: boolean;
13
- primary?: true;
14
- schema?: z.ZodTypeAny;
15
- };
16
-
17
- export type SlotDefinition = {
18
- name: string;
19
- options?: SlotOptions;
20
- };
21
-
22
- export type SlotMeta = {
23
- isDefault: boolean;
24
- required: boolean;
25
- schema?: z.ZodTypeAny;
26
- };
27
-
28
- export type TemplateRenderContext<
29
- S extends readonly SlotDefinition[] = readonly SlotDefinition[],
30
- > = {
31
- head?: HSHtml;
32
- siteConfig: SiteConfig;
33
- page: PageRenderView;
34
- slots: TemplateSlotsFrom<S>;
35
- };
36
-
37
- /** Widened render signature stored on descriptors at runtime. */
38
- export type TemplateRenderFn = (ctx: {
39
- head?: HSHtml;
40
- siteConfig: SiteConfig;
41
- page: PageRenderView;
42
- slots: Record<string, import("./slot-handle.js").Slot>;
43
- }) => HSHtml;
44
-
45
- export type TemplateDescriptor<
46
- Slots extends string = string,
47
- RequiredSlots extends Slots = never,
48
- Definitions extends readonly SlotDefinition[] = readonly SlotDefinition[],
49
- > = {
50
- readonly __kind: "template";
51
- siteConfig: SiteConfig;
52
- formatPageTitle: (title: string) => string;
53
- render: TemplateRenderFn;
54
- slots: Record<Slots, SlotMeta>;
55
- requiredSlots: readonly RequiredSlots[];
56
- readonly __definitions?: Definitions;
57
- };
58
-
59
- export type SlotNamesFrom<S extends readonly SlotDefinition[]> =
60
- S[number]["name"];
61
-
62
- export type RequiredSlotNamesFrom<S extends readonly SlotDefinition[]> =
63
- Extract<
64
- S[number],
65
- { options: { required: true } } | { options: { primary: true } }
66
- >["name"];
67
-
68
- export function isTemplate(value: unknown): value is TemplateDescriptor {
69
- return (
70
- typeof value === "object" &&
71
- value !== null &&
72
- (value as TemplateDescriptor).__kind === "template" &&
73
- isSiteConfig((value as TemplateDescriptor).siteConfig) &&
74
- typeof (value as TemplateDescriptor).formatPageTitle === "function" &&
75
- typeof (value as TemplateDescriptor).render === "function" &&
76
- typeof (value as TemplateDescriptor).slots === "object" &&
77
- (value as TemplateDescriptor).slots !== null &&
78
- Array.isArray((value as TemplateDescriptor).requiredSlots)
79
- );
80
- }
81
-
82
- function defaultFormatPageTitle(
83
- pageTitle: string,
84
- siteConfig: SiteConfig,
85
- ): string {
86
- const suffix = siteConfig.config.titleSuffix;
87
- return typeof suffix === "string" ? pageTitle + suffix : pageTitle;
88
- }
89
-
90
- type SlotRegistryEntry = {
91
- name: string;
92
- isDefault: boolean;
93
- required: boolean;
94
- schema?: z.ZodTypeAny;
95
- };
96
-
97
- export class TemplateBuildError extends Error {
98
- constructor(message: string) {
99
- super(message);
100
- this.name = "TemplateBuildError";
101
- }
102
- }
103
-
104
- function validateSlotDefinitions(
105
- definitions: readonly SlotDefinition[],
106
- ): SlotRegistryEntry[] {
107
- if (definitions.length === 0) {
108
- throw new TemplateBuildError("template must define at least one slot");
109
- }
110
-
111
- const entries: SlotRegistryEntry[] = [];
112
- const seen = new Set<string>();
113
- let primaryCount = 0;
114
-
115
- for (const def of definitions) {
116
- if (!def.name || def.name.length === 0) {
117
- throw new TemplateBuildError("slot name must be non-empty");
118
- }
119
- if (seen.has(def.name)) {
120
- throw new TemplateBuildError(`duplicate slot name "${def.name}"`);
121
- }
122
- seen.add(def.name);
123
-
124
- const opts = def.options ?? {};
125
- const isDefault = opts.primary === true;
126
- if (isDefault) {
127
- primaryCount++;
128
- }
129
-
130
- entries.push({
131
- name: def.name,
132
- isDefault,
133
- required: opts.required === true || opts.primary === true,
134
- schema: opts.schema,
135
- });
136
- }
137
-
138
- if (primaryCount !== 1) {
139
- throw new TemplateBuildError(
140
- `exactly one slot must have options.primary: true; found ${primaryCount}`,
141
- );
142
- }
143
-
144
- return entries;
145
- }
146
-
147
- function validateRenderOutput(
148
- html: string,
149
- slotNames: readonly string[],
150
- ): void {
151
- for (const name of slotNames) {
152
- if (!html.includes(slotSentinel(name))) {
153
- throw new TemplateBuildError(
154
- `slot "${name}" must be rendered via slots.${name}.render()`,
155
- );
156
- }
157
- }
158
- }
159
-
160
- const stubPage: PageRenderView = {
161
- __kind: "page",
162
- path: "/",
163
- title: "Template validation",
164
- };
165
-
166
- export function createTemplate<const S extends readonly SlotDefinition[]>(
167
- def: {
168
- siteConfig: SiteConfig;
169
- slots: S;
170
- title?: (pageTitle: string, siteConfig: SiteConfig) => string;
171
- render: (ctx: TemplateRenderContext<S>) => HSHtml;
172
- },
173
- ): TemplateDescriptor<
174
- SlotNamesFrom<S>,
175
- RequiredSlotNamesFrom<S>,
176
- S
177
- > {
178
- if (!isSiteConfig(def.siteConfig)) {
179
- throw new TemplateBuildError(
180
- "createTemplate({ siteConfig }): siteConfig must be from createSiteConfig()",
181
- );
182
- }
183
-
184
- const formatPageTitle = def.title
185
- ? (pageTitle: string) => def.title!(pageTitle, def.siteConfig)
186
- : (pageTitle: string) =>
187
- defaultFormatPageTitle(pageTitle, def.siteConfig);
188
-
189
- const entries = validateSlotDefinitions(def.slots);
190
- const stubSlots = buildStubSlots(def.slots);
191
-
192
- const validationHtml = render(
193
- def.render({
194
- siteConfig: def.siteConfig,
195
- page: stubPage,
196
- slots: stubSlots,
197
- }),
198
- );
199
-
200
- validateRenderOutput(
201
- validationHtml,
202
- entries.map((entry) => entry.name),
203
- );
204
-
205
- const slots = {} as Record<SlotNamesFrom<S>, SlotMeta>;
206
- const requiredSlots: RequiredSlotNamesFrom<S>[] = [];
207
-
208
- for (const entry of entries) {
209
- slots[entry.name as SlotNamesFrom<S>] = {
210
- isDefault: entry.isDefault,
211
- required: entry.required,
212
- schema: entry.schema,
213
- };
214
- if (entry.required) {
215
- requiredSlots.push(entry.name as RequiredSlotNamesFrom<S>);
216
- }
217
- }
218
-
219
- return {
220
- __kind: "template" as const,
221
- siteConfig: def.siteConfig,
222
- formatPageTitle,
223
- render: def.render as TemplateRenderFn,
224
- slots,
225
- requiredSlots,
226
- __definitions: def.slots,
227
- };
228
- }
229
-
230
- export type { Slot, TemplateSlotsFrom } from "./slot-handle.js";