@stackbox/cms 0.0.2 → 0.0.4

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,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(node -e \"import\\('@hyperspan/html'\\).then\\(m => console.log\\('html export:', typeof m.html\\)\\)\")",
5
+ "Bash(npm run *)"
6
+ ]
7
+ }
8
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,87 @@
1
+ # @stackbox/cms — agent instructions
2
+
3
+ Stackbox is a code-first CMS that assembles TypeScript into a **fetch-handler-compatible server**. Use these conventions when building or extending a site.
4
+
5
+ ## Site conventions
6
+
7
+ | Primitive | Factory | Purpose |
8
+ | --- | --- | --- |
9
+ | Site config | `createSiteConfig(config)` | Definition-time settings shared by templates and pages |
10
+ | Site | `createSite(siteConfig, { pages })` | Runtime router with `fetch(request, env)` |
11
+ | Template | `createTemplate({ siteConfig, slots, render })` | Reusable layout with named slots |
12
+ | Page | `createPage(template, { path, title, slots })` | A single URL built from a template + slot content |
13
+ | Block | `createBlock({ name, render })` | A content block placed into a slot at request time |
14
+
15
+ **Project layout:**
16
+
17
+ ```
18
+ site.config.ts # createSiteConfig
19
+ server.ts # createSite(...) — default export is the fetch handler
20
+ templates/ # shared createTemplate() layouts
21
+ pages/ # createPage() per route or feature
22
+ content/ # markdown or other bundle-time content (per feature)
23
+ ```
24
+
25
+ The default export from `server.ts` implements `fetch(request, env)` and returns a `Response`. It works on Cloudflare Workers, Bun, Deno, and any runtime that speaks the fetch-handler pattern.
26
+
27
+ ## Plugins vs blocks
28
+
29
+ - **Plugin** — a packaged use-case under `@stackbox/cms/plugins/<name>` (e.g. blog). May export factories, content objects, blocks, types, and helpers.
30
+ - **Block** — a core primitive via `createBlock()`. Renders HTML into a template slot. Usable inside or outside plugins.
31
+
32
+ Slot content is `Block | string`. Plugin content arrays can interleave blocks and HTML strings.
33
+
34
+ ## Plugin catalog
35
+
36
+ Before implementing a use-case (blog, newsletter, docs, …), check this table. If the user's request matches a row, **read only that plugin's AGENTS.md** and follow it. Do not invent a parallel implementation.
37
+
38
+ | Plugin | Import | Read when the user mentions | Instructions |
39
+ | --- | --- | --- | --- |
40
+ | blog | `@stackbox/cms/plugins/blog` | blog, posts, articles, journal, markdown posts, blog listing, blog page | `src/plugins/blog/AGENTS.md` |
41
+ | random-quote | `@stackbox/cms/plugins/random-quote` | random quote, quote of the day, inspirational quote, sidebar quote | `src/plugins/random-quote/AGENTS.md` |
42
+
43
+ When adding a new plugin to this package, add a row here and create `src/plugins/<name>/AGENTS.md` using the same heading structure as the blog plugin. Document all export kinds (factory, types, blocks, helpers).
44
+
45
+ ## Plugin public assets
46
+
47
+ Runtime files that must be **publicly available** (images, CSS, fonts, JSON served over HTTP) go in:
48
+
49
+ ```
50
+ src/plugins/<name>/public_assets/
51
+ ```
52
+
53
+ **Opt-in:** only plugins with a `public_assets/` folder get copied at build time. Do not edit the copy script when adding files.
54
+
55
+ Build copies each `public_assets/` folder to:
56
+
57
+ - `dist/public/_sb/plugins/<name>/` — served at `/_sb/plugins/<name>/...`
58
+ - `dist/plugins/<name>/public_assets/` — so `import "./public_assets/..." with { type: "json" }` works in compiled JS
59
+
60
+ Use `pluginAssetPath()` from `@stackbox/cms/link` for `<img src>`, `<link href>`, etc. Do not put site files under `/_sb/` — that prefix is reserved for Stackbox plugin assets.
61
+
62
+ ```ts
63
+ import { pluginAssetPath } from "@stackbox/cms/link";
64
+
65
+ pluginAssetPath("random-quote", "photo.jpg");
66
+ // → "/_sb/plugins/random-quote/photo.jpg"
67
+ ```
68
+
69
+ For data bundled into JS at build time, import from `./public_assets/...` in the plugin's `index.ts`.
70
+
71
+ ## For site agents
72
+
73
+ If you are working in a **consumer site repo** (not this package), read this file from the installed package:
74
+
75
+ ```
76
+ node_modules/@stackbox/cms/AGENTS.md
77
+ ```
78
+
79
+ Recommended snippet for the site's own `AGENTS.md`:
80
+
81
+ ```md
82
+ This site uses @stackbox/cms. Before adding features, read
83
+ `node_modules/@stackbox/cms/AGENTS.md` and follow its plugin catalog.
84
+ Do not reimplement bundled plugins.
85
+ ```
86
+
87
+ When a request matches a catalog row, open **only** that plugin's instructions file (e.g. `node_modules/@stackbox/cms/src/plugins/blog/AGENTS.md`).
package/README.md CHANGED
@@ -1,12 +1,22 @@
1
1
  # @stackbox/cms
2
2
 
3
- A small, code-first CMS engine for building dynamic [Cloudflare Worker](https://developers.cloudflare.com/workers/) sites. Stackbox is designed to be driven by AI: pages, templates, and content modules are plain TypeScript files with typed, composable APIs, so an agent can author and assemble a site without a database, admin UI, or hand-written backend.
3
+ A small, code-first CMS engine for building dynamic sites behind a standard **fetch handler**. Stackbox is designed to be driven by AI: pages, templates, blocks, and plugins are plain TypeScript files with typed, composable APIs, so an agent can author and assemble a site without a database, admin UI, or hand-written backend.
4
4
 
5
- Pages are rendered on each request inside a Cloudflare Worker, so content, templates, and modules can be fully dynamic — driven by request data, bindings (KV, D1, R2), and async data fetching.
5
+ `createSite()` returns an object with a `fetch(request, env)` method — the same shape used by Cloudflare Workers, Bun, Deno, and other runtimes that serve HTTP via the Fetch API:
6
+
7
+ ```ts
8
+ export default {
9
+ fetch(req: Request): Response | Promise<Response> {
10
+ return new Response(...);
11
+ },
12
+ };
13
+ ```
14
+
15
+ Pages are rendered on each request, so content, templates, blocks, and plugins can be fully dynamic — driven by request data, environment bindings, and async data fetching.
6
16
 
7
17
  ## Why this exists
8
18
 
9
- Traditional CMSes assume a human clicking around an admin panel. Stackbox inverts that: a site is TypeScript modules assembled into a Worker. Every primitive (`createSiteConfig`, `createSite`, `createTemplate`, `createPage`, `createModule`) is a typed factory suited for AI to generate, edit, and validate site content as code — and the same files render dynamically on Cloudflare Workers at request time.
19
+ Traditional CMSes assume a human clicking around an admin panel. Stackbox inverts that: a site is TypeScript assembled into a fetch handler. Every primitive (`createSiteConfig`, `createSite`, `createTemplate`, `createPage`, `createBlock`) is a typed factory suited for AI to generate, edit, and validate site content as code — and the same files render dynamically at request time.
10
20
 
11
21
  ## Requirements
12
22
 
@@ -23,19 +33,21 @@ npm install @stackbox/cms
23
33
  | Primitive | Factory | Purpose |
24
34
  | --- | --- | --- |
25
35
  | **Site config** | `createSiteConfig(config)` | Definition-time settings shared by templates and pages. |
26
- | **Site** | `createSite(siteConfig, { pages }` | Runtime router with `fetch(request, env)` for Cloudflare Workers. |
27
- | **Template** | `createTemplate({ siteConfig, slots, render }` | A reusable page layout that declares named **slots**. |
28
- | **Page** | `createPage(template, { path, title, slots }` | A single URL, built by filling a template's slots with content. |
29
- | **Module** | `createModule({ name, render }` | A self-contained content block placed into a slot. |
36
+ | **Site** | `createSite(siteConfig, { pages })` | Runtime router with `fetch(request, env)` — a fetch-handler-compatible server. |
37
+ | **Template** | `createTemplate({ siteConfig, slots, render })` | A reusable page layout that declares named **slots**. |
38
+ | **Page** | `createPage(template, { path, title, slots })` | A single URL, built by filling a template's slots with content. |
39
+ | **Block** | `createBlock({ name, render })` | A self-contained content block placed into a slot at request time. |
30
40
 
31
- **Slots** are named regions in a template. Page content — strings, HTML, or modules — is dropped into slots, and the engine resolves and renders everything (including `async` modules, concurrently) to a single HTML string.
41
+ **Plugins vs blocks:** **Plugins** package whole features (blog, newsletter) under `@stackbox/cms/plugins/<name>` — they may export factories, content objects, blocks, types, and helpers. **Blocks** are the core slot primitive via `createBlock()`; plugins can ship blocks alongside other exports.
42
+
43
+ **Slots** are named regions in a template. Page content — strings, HTML, or blocks — is dropped into slots, and the engine resolves and renders everything (including async blocks, concurrently) to a single HTML string.
32
44
 
33
45
  ## Project layout
34
46
 
35
47
  ```
36
- my-worker/
48
+ my-site/
37
49
  site.config.ts # createSiteConfig({ name, url, ... })
38
- worker.ts # createSite(siteConfig, { pages }) — default export for Cloudflare
50
+ server.ts # createSite(...) — default export is your fetch handler
39
51
  templates/
40
52
  site-template.ts # shared createTemplate() layouts
41
53
  pages/
@@ -60,8 +72,7 @@ export default createSiteConfig({
60
72
  `templates/site-template.ts`:
61
73
 
62
74
  ```ts
63
- import { html } from "@hyperspan/html";
64
- import { createTemplate } from "@stackbox/cms";
75
+ import { createTemplate, html } from "@stackbox/cms";
65
76
  import siteConfig from "../site.config";
66
77
 
67
78
  export const siteTemplate = createTemplate({
@@ -88,7 +99,7 @@ const homePage = createPage(siteTemplate, {
88
99
  export default homePage;
89
100
  ```
90
101
 
91
- `worker.ts`:
102
+ `server.ts`:
92
103
 
93
104
  ```ts
94
105
  import { createSite } from "@stackbox/cms";
@@ -101,9 +112,13 @@ export default createSite(siteConfig, {
101
112
  });
102
113
  ```
103
114
 
104
- Deploy with [`wrangler`](https://developers.cloudflare.com/workers/wrangler/). The default export's `fetch(request, env)` handles each request.
115
+ The default export implements `fetch(request, env)` and returns a `Response` — drop it into any runtime that speaks the fetch-handler pattern. For example:
116
+
117
+ - **[Cloudflare Workers](https://developers.cloudflare.com/workers/)** — deploy with [`wrangler`](https://developers.cloudflare.com/workers/wrangler/) (often as `worker.ts`)
118
+ - **[Bun](https://bun.sh/docs/api/http#fetch-handler)** — `Bun.serve({ fetch: site.fetch })`
119
+ - **[Deno](https://docs.deno.com/runtime/fundamentals/http_server/)** — `Deno.serve(site.fetch)`
105
120
 
106
- ## Blog module
121
+ ## Blog plugin
107
122
 
108
123
  `createBlog()` loads markdown at bundle time and returns **content objects** you wire into your own pages with `createPage()` — so you control templates, slots, and any extra content alongside blog output.
109
124
 
@@ -111,7 +126,7 @@ Deploy with [`wrangler`](https://developers.cloudflare.com/workers/wrangler/). T
111
126
  // pages/blog.ts
112
127
  import { join } from "node:path";
113
128
  import { createPage } from "@stackbox/cms";
114
- import { createBlog } from "@stackbox/cms/modules/blog";
129
+ import { createBlog } from "@stackbox/cms/plugins/blog";
115
130
  import { siteTemplate } from "../templates/site-template";
116
131
 
117
132
  const blog = createBlog({
@@ -141,7 +156,7 @@ export const blogPostPages = blog.posts.map((post) =>
141
156
  ```
142
157
 
143
158
  ```ts
144
- // worker.ts
159
+ // server.ts
145
160
  import { blogListingPages, blogPostPages } from "./pages/blog";
146
161
 
147
162
  export default createSite(siteConfig, {
@@ -149,10 +164,34 @@ export default createSite(siteConfig, {
149
164
  });
150
165
  ```
151
166
 
152
- ## Bundled modules
167
+ ## Bundled plugins
168
+
169
+ **Blog** — content objects wired into pages:
170
+
171
+ ```ts
172
+ import { createBlog } from "@stackbox/cms/plugins/blog";
173
+ ```
174
+
175
+ **Random quote** — block-only plugin (drop into any slot):
153
176
 
154
177
  ```ts
155
- import { createBlog } from "@stackbox/cms/modules/blog";
178
+ import { randomQuoteBlock } from "@stackbox/cms/plugins/random-quote";
179
+ import myQuotes from "../content/quotes.json" with { type: "json" };
180
+
181
+ slots: { sidebar: [randomQuoteBlock()] } // bundled quotes
182
+ slots: { sidebar: [randomQuoteBlock({ quotes: myQuotes })] } // your own
183
+ ```
184
+
185
+ ## AI agents
186
+
187
+ Bundled plugins include agent playbooks. See [`AGENTS.md`](AGENTS.md) for site conventions and a plugin catalog. When a user asks for a feature (e.g. "add a blog"), read **only** the matching plugin's `AGENTS.md` — do not load every plugin file.
188
+
189
+ If you are building a site that uses this package, add this to your project's `AGENTS.md`:
190
+
191
+ ```md
192
+ This site uses @stackbox/cms. Before adding features, read
193
+ `node_modules/@stackbox/cms/AGENTS.md` and follow its plugin catalog.
194
+ Do not reimplement bundled plugins.
156
195
  ```
157
196
 
158
197
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbox/cms",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Stackbox CMS engine",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -17,13 +17,21 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js"
19
19
  },
20
- "./modules/blog": {
21
- "types": "./dist/modules/blog/module.d.ts",
22
- "import": "./dist/modules/blog/module.js"
20
+ "./plugins/blog": {
21
+ "types": "./dist/plugins/blog/index.d.ts",
22
+ "import": "./dist/plugins/blog/index.js"
23
+ },
24
+ "./plugins/random-quote": {
25
+ "types": "./dist/plugins/random-quote/index.d.ts",
26
+ "import": "./dist/plugins/random-quote/index.js"
27
+ },
28
+ "./link": {
29
+ "types": "./dist/link.d.ts",
30
+ "import": "./dist/link.js"
23
31
  }
24
32
  },
25
33
  "scripts": {
26
- "build": "tsc -p tsconfig.json",
34
+ "build": "tsc -p tsconfig.json && node scripts/copy-plugin-assets.mjs",
27
35
  "test": "npm run build && node --import tsx --test test/*.test.ts",
28
36
  "typecheck": "tsc --noEmit -p tsconfig.json",
29
37
  "prepublishOnly": "npm run build"
@@ -0,0 +1,30 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
6
+ const pluginsDir = join(root, "src/plugins");
7
+
8
+ if (!existsSync(pluginsDir)) {
9
+ process.exit(0);
10
+ }
11
+
12
+ for (const name of readdirSync(pluginsDir)) {
13
+ const pluginDir = join(pluginsDir, name);
14
+ if (!statSync(pluginDir).isDirectory()) {
15
+ continue;
16
+ }
17
+
18
+ const publicAssets = join(pluginDir, "public_assets");
19
+ if (!existsSync(publicAssets)) {
20
+ continue;
21
+ }
22
+
23
+ const publicDest = join(root, "dist/public/_sb/plugins", name);
24
+ mkdirSync(dirname(publicDest), { recursive: true });
25
+ cpSync(publicAssets, publicDest, { recursive: true });
26
+
27
+ const importDest = join(root, "dist/plugins", name, "public_assets");
28
+ mkdirSync(dirname(importDest), { recursive: true });
29
+ cpSync(publicAssets, importDest, { recursive: true });
30
+ }
package/src/blocks.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { RenderContext } from "./pages.js";
2
+ import type { HSHtml } from "@hyperspan/html";
3
+
4
+ export type BlockRenderResult = HSHtml | Promise<HSHtml>;
5
+
6
+ export type Block = {
7
+ readonly __kind: "block";
8
+ readonly name: string;
9
+ render(ctx: RenderContext): BlockRenderResult;
10
+ };
11
+
12
+ type BlockDef<TOptions = undefined> = {
13
+ name: string;
14
+ render(options: TOptions, ctx?: RenderContext): BlockRenderResult;
15
+ };
16
+
17
+ export type BlockOptionsOf<F> = F extends (options?: infer O) => unknown
18
+ ? [O] extends [undefined]
19
+ ? undefined
20
+ : O
21
+ : never;
22
+
23
+ export type BlockFactory<TOptions = undefined> = (
24
+ options?: TOptions,
25
+ ) => Block;
26
+
27
+ function validateBlockName(name: string): void {
28
+ if (!name || name.length === 0) {
29
+ throw new Error("createBlock(def): name is required");
30
+ }
31
+ }
32
+
33
+ export function createBlock<TOptions = undefined>(
34
+ def: BlockDef<TOptions>,
35
+ ): BlockFactory<TOptions> {
36
+ validateBlockName(def.name);
37
+
38
+ if (typeof def.render !== "function") {
39
+ throw new Error("createBlock(def): render is required");
40
+ }
41
+
42
+ return ((options?: TOptions) => ({
43
+ __kind: "block" as const,
44
+ name: def.name,
45
+ render: (ctx: RenderContext) => def.render(options as TOptions, ctx),
46
+ })) as BlockFactory<TOptions>;
47
+ }
48
+
49
+ export function isBlock(value: unknown): value is Block {
50
+ return (
51
+ typeof value === "object" &&
52
+ value !== null &&
53
+ (value as Block).__kind === "block" &&
54
+ typeof (value as Block).name === "string" &&
55
+ (value as Block).name.length > 0 &&
56
+ typeof (value as Block).render === "function"
57
+ );
58
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,12 @@
1
+ export { html } from "@hyperspan/html";
1
2
  export {
2
- createModule,
3
- isModule,
4
- type Module,
5
- type ModuleFactory,
6
- type ModuleOptionsOf,
7
- type ModuleRenderResult,
8
- } from "./modules.js";
3
+ createBlock,
4
+ isBlock,
5
+ type Block,
6
+ type BlockFactory,
7
+ type BlockOptionsOf,
8
+ type BlockRenderResult,
9
+ } from "./blocks.js";
9
10
  export {
10
11
  createPage,
11
12
  isPage,
@@ -33,7 +34,7 @@ export {
33
34
  export { createContext, Stackbox } from "./stackbox/context.js";
34
35
  export {
35
36
  anySlotContentSchema,
36
- moduleSlotContentSchema,
37
+ blockSlotContentSchema,
37
38
  slotHasContent,
38
39
  SlotContentValidationError,
39
40
  stringSlotContentSchema,
package/src/link.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, normalize, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { normalizePathname } from "./routing.js";
5
+
6
+ export const PLUGIN_PUBLIC_PREFIX = "/_sb/plugins";
7
+
8
+ const CONTENT_TYPES: Record<string, string> = {
9
+ ".css": "text/css; charset=utf-8",
10
+ ".gif": "image/gif",
11
+ ".html": "text/html; charset=utf-8",
12
+ ".jpeg": "image/jpeg",
13
+ ".jpg": "image/jpeg",
14
+ ".js": "text/javascript; charset=utf-8",
15
+ ".json": "application/json; charset=utf-8",
16
+ ".png": "image/png",
17
+ ".svg": "image/svg+xml",
18
+ ".txt": "text/plain; charset=utf-8",
19
+ ".webp": "image/webp",
20
+ ".woff": "font/woff",
21
+ ".woff2": "font/woff2",
22
+ };
23
+
24
+ function assertSafeSegment(segment: string, label: string): void {
25
+ if (
26
+ !segment ||
27
+ segment === "." ||
28
+ segment === ".." ||
29
+ segment.includes("/") ||
30
+ segment.includes("\\")
31
+ ) {
32
+ throw new Error(`pluginAssetPath: invalid ${label}`);
33
+ }
34
+ }
35
+
36
+ export function pluginAssetPath(pluginName: string, relativePath: string): string {
37
+ assertSafeSegment(pluginName, "plugin name");
38
+ const segments = relativePath
39
+ .split(/[/\\]+/)
40
+ .filter((segment) => segment.length > 0);
41
+ for (const segment of segments) {
42
+ assertSafeSegment(segment, "relative path segment");
43
+ }
44
+ return `${PLUGIN_PUBLIC_PREFIX}/${pluginName}/${segments.join("/")}`;
45
+ }
46
+
47
+ export type PluginAssetRequest = {
48
+ plugin: string;
49
+ relativePath: string;
50
+ };
51
+
52
+ export function parsePluginAssetRequest(
53
+ pathname: string,
54
+ ): PluginAssetRequest | null {
55
+ const normalized = normalizePathname(pathname);
56
+ const prefix = `${PLUGIN_PUBLIC_PREFIX}/`;
57
+ if (!normalized.startsWith(prefix)) {
58
+ return null;
59
+ }
60
+
61
+ const rest = normalized.slice(prefix.length);
62
+ const slash = rest.indexOf("/");
63
+ if (slash <= 0 || slash === rest.length - 1) {
64
+ return null;
65
+ }
66
+
67
+ const plugin = rest.slice(0, slash);
68
+ const relativePath = rest.slice(slash + 1);
69
+ if (
70
+ !plugin ||
71
+ !relativePath ||
72
+ relativePath.includes("..") ||
73
+ relativePath.includes("\\")
74
+ ) {
75
+ return null;
76
+ }
77
+
78
+ try {
79
+ assertSafeSegment(plugin, "plugin name");
80
+ for (const segment of relativePath.split("/")) {
81
+ assertSafeSegment(segment, "relative path segment");
82
+ }
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ return { plugin, relativePath };
88
+ }
89
+
90
+ export function getPluginPublicRootDir(): string {
91
+ const here = dirname(fileURLToPath(import.meta.url));
92
+ const fromDist = join(here, "public", "_sb", "plugins");
93
+ if (existsSync(fromDist)) {
94
+ return fromDist;
95
+ }
96
+
97
+ const fromSrcDev = join(here, "..", "dist", "public", "_sb", "plugins");
98
+ if (existsSync(fromSrcDev)) {
99
+ return fromSrcDev;
100
+ }
101
+
102
+ return fromDist;
103
+ }
104
+
105
+ function contentTypeForPath(relativePath: string): string {
106
+ const dot = relativePath.lastIndexOf(".");
107
+ if (dot === -1) {
108
+ return "application/octet-stream";
109
+ }
110
+ return CONTENT_TYPES[relativePath.slice(dot).toLowerCase()] ??
111
+ "application/octet-stream";
112
+ }
113
+
114
+ function resolvePluginAssetFile(request: PluginAssetRequest): string | null {
115
+ const root = getPluginPublicRootDir();
116
+ const absolute = normalize(join(root, request.plugin, request.relativePath));
117
+ const rootWithSep = root.endsWith(sep) ? root : `${root}${sep}`;
118
+ if (!absolute.startsWith(rootWithSep)) {
119
+ return null;
120
+ }
121
+ if (!existsSync(absolute)) {
122
+ return null;
123
+ }
124
+ return absolute;
125
+ }
126
+
127
+ export function servePluginAsset(
128
+ pathname: string,
129
+ method: string,
130
+ ): globalThis.Response | null {
131
+ const request = parsePluginAssetRequest(pathname);
132
+ if (!request) {
133
+ return null;
134
+ }
135
+
136
+ const filePath = resolvePluginAssetFile(request);
137
+ if (!filePath) {
138
+ return new globalThis.Response("Not Found", { status: 404 });
139
+ }
140
+
141
+ const contentType = contentTypeForPath(request.relativePath);
142
+ if (method === "HEAD") {
143
+ const body = readFileSync(filePath);
144
+ return new globalThis.Response(null, {
145
+ status: 200,
146
+ headers: {
147
+ "content-type": contentType,
148
+ "content-length": String(body.byteLength),
149
+ },
150
+ });
151
+ }
152
+
153
+ const body = readFileSync(filePath);
154
+ return new globalThis.Response(body, {
155
+ status: 200,
156
+ headers: { "content-type": contentType },
157
+ });
158
+ }
package/src/pages.ts CHANGED
@@ -180,7 +180,7 @@ export function createPage(
180
180
  const items = def.slots[required as keyof typeof def.slots];
181
181
  if (!items || items.length === 0 || !slotHasContent(items)) {
182
182
  throw new PageValidationError(
183
- `required slot "${required}" must have at least one module or non-empty HTML string`,
183
+ `required slot "${required}" must have at least one block or non-empty HTML string`,
184
184
  );
185
185
  }
186
186
  }