@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.
@@ -0,0 +1,257 @@
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
+ }
@@ -0,0 +1,230 @@
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";
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { html, renderAsync } from "@hyperspan/html";
4
+ import { createModule } from "../src/modules.js";
5
+ import { createPage } from "../src/pages.js";
6
+ import { renderPage } from "../src/render-page.js";
7
+ import { createSiteConfig } from "../src/site.js";
8
+ import { renderSlotContent } from "../src/slot-handle.js";
9
+ import { createTemplate } from "../src/templates.js";
10
+
11
+ function sleep(ms: number): Promise<void> {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+
15
+ describe("async module rendering", () => {
16
+ it("await async modules in slot content", async () => {
17
+ const siteConfig = createSiteConfig({ name: "Test" });
18
+ const ctx = { siteConfig };
19
+
20
+ const slowModule = createModule({
21
+ name: "slow",
22
+ async render() {
23
+ await sleep(25);
24
+ return html`<p>slow module</p>`;
25
+ },
26
+ })();
27
+
28
+ const htmlOut = await renderAsync(
29
+ await renderSlotContent("content", [slowModule], ctx, undefined),
30
+ );
31
+
32
+ assert.match(htmlOut, /<p>slow module<\/p>/);
33
+ assert.doesNotMatch(htmlOut, /hs:loading/);
34
+ });
35
+
36
+ it("resolves multiple async modules concurrently", async () => {
37
+ const siteConfig = createSiteConfig({ name: "Test" });
38
+ const ctx = { siteConfig };
39
+ const order: string[] = [];
40
+
41
+ const first = createModule({
42
+ name: "first",
43
+ async render() {
44
+ await sleep(30);
45
+ order.push("first");
46
+ return html`<p>first</p>`;
47
+ },
48
+ })();
49
+
50
+ const second = createModule({
51
+ name: "second",
52
+ async render() {
53
+ await sleep(10);
54
+ order.push("second");
55
+ return html`<p>second</p>`;
56
+ },
57
+ })();
58
+
59
+ const htmlOut = await renderAsync(
60
+ await renderSlotContent(
61
+ "content",
62
+ ["<p>before</p>", first, second],
63
+ ctx,
64
+ undefined,
65
+ ),
66
+ );
67
+
68
+ assert.match(htmlOut, /<p>before<\/p>/);
69
+ assert.match(htmlOut, /<p>first<\/p>/);
70
+ assert.match(htmlOut, /<p>second<\/p>/);
71
+ assert.deepEqual(order, ["second", "first"]);
72
+ });
73
+
74
+ it("renders async modules through the full page pipeline", async () => {
75
+ const siteConfig = createSiteConfig({ name: "Test Site" });
76
+ const template = createTemplate({
77
+ siteConfig,
78
+ slots: [{ name: "content", options: { required: true, primary: true } }],
79
+ render({ slots }: { slots: { content: { render: () => unknown } } }): ReturnType<typeof html> {
80
+ return html`<main>${slots.content.render()}</main>`;
81
+ },
82
+ });
83
+
84
+ const asyncModule = createModule({
85
+ name: "page-async",
86
+ async render() {
87
+ await sleep(15);
88
+ return html`<p>async page content</p>`;
89
+ },
90
+ })();
91
+
92
+ const page = createPage(template, {
93
+ path: "/async-test",
94
+ title: "Async test",
95
+ slots: { content: [asyncModule] },
96
+ });
97
+
98
+ const htmlOut = await renderPage(page, siteConfig);
99
+
100
+ assert.match(htmlOut, /<main><p>async page content<\/p><\/main>/);
101
+ assert.doesNotMatch(htmlOut, /hs:loading/);
102
+ });
103
+ });
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { mkdtempSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { describe, it } from "node:test";
7
+ import { createBlog } from "../src/modules/blog/module.js";
8
+
9
+ function makeBlogDir(posts: Record<string, string>): string {
10
+ const root = mkdtempSync(join(tmpdir(), "stackbox-blog-"));
11
+ const contentPath = join(root, "content");
12
+ mkdirSync(contentPath, { recursive: true });
13
+ for (const [filename, body] of Object.entries(posts)) {
14
+ writeFileSync(join(contentPath, filename), body, "utf8");
15
+ }
16
+ return contentPath;
17
+ }
18
+
19
+ describe("createBlog", () => {
20
+ it("returns post content objects with path, title, meta, and content", () => {
21
+ const contentPath = makeBlogDir({
22
+ "hello.md": `---
23
+ title: Hello World
24
+ description: A greeting post
25
+ slug: hello
26
+ ---
27
+ <p>Hello body</p>`,
28
+ });
29
+
30
+ const blog = createBlog({ contentPath, pathPrefix: "/blog" });
31
+
32
+ assert.strictEqual(blog.posts.length, 1);
33
+ const post = blog.posts[0]!;
34
+ assert.strictEqual(post.path, "/blog/hello");
35
+ assert.strictEqual(post.title, "Hello World");
36
+ assert.deepEqual(post.meta, { description: "A greeting post" });
37
+ assert.strictEqual(post.content.length, 1);
38
+ assert.match(String(post.content[0]), /data-slug="hello"/);
39
+ assert.match(String(post.content[0]), /<h1>Hello World<\/h1>/);
40
+ assert.match(String(post.content[0]), /<p>Hello body<\/p>/);
41
+ });
42
+
43
+ it("returns a single listing page with normalized path and post links", () => {
44
+ const contentPath = makeBlogDir({
45
+ "a.md": "---\ntitle: Post A\n---\nBody A",
46
+ "b.md": "---\ntitle: Post B\n---\nBody B",
47
+ });
48
+
49
+ const blog = createBlog({ contentPath, pathPrefix: "/blog/" });
50
+
51
+ assert.strictEqual(blog.listings.length, 1);
52
+ assert.strictEqual(blog.listings[0]!.path, "/blog");
53
+ assert.strictEqual(blog.listings[0]!.content.length, 1);
54
+ const html = String(blog.listings[0]!.content[0]);
55
+ assert.match(html, /<a href="\/blog\/a">Post A<\/a>/);
56
+ assert.match(html, /<a href="\/blog\/b">Post B<\/a>/);
57
+ });
58
+
59
+ it("returns empty listing message when there are no posts", () => {
60
+ const contentPath = makeBlogDir({});
61
+
62
+ const blog = createBlog({ contentPath, pathPrefix: "/blog" });
63
+
64
+ assert.strictEqual(blog.posts.length, 0);
65
+ assert.strictEqual(blog.listings.length, 1);
66
+ assert.match(String(blog.listings[0]!.content[0]), /No blog posts found/);
67
+ });
68
+
69
+ it("returns multiple listing pages when postsPerPage is set", () => {
70
+ const contentPath = makeBlogDir({
71
+ "a.md": "---\ntitle: Post A\n---\n",
72
+ "b.md": "---\ntitle: Post B\n---\n",
73
+ "c.md": "---\ntitle: Post C\n---\n",
74
+ });
75
+
76
+ const blog = createBlog({
77
+ contentPath,
78
+ pathPrefix: "/blog",
79
+ postsPerPage: 2,
80
+ });
81
+
82
+ assert.strictEqual(blog.listings.length, 2);
83
+ assert.strictEqual(blog.listings[0]!.path, "/blog");
84
+ assert.strictEqual(blog.listings[1]!.path, "/blog/page/2");
85
+
86
+ const page1 = String(blog.listings[0]!.content[0]);
87
+ const page2 = String(blog.listings[1]!.content[0]);
88
+ assert.match(page1, /Post A/);
89
+ assert.match(page1, /Post B/);
90
+ assert.doesNotMatch(page1, /Post C/);
91
+ assert.match(page2, /Post C/);
92
+ });
93
+ });