@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.
- package/.claude/settings.local.json +8 -0
- package/AGENTS.md +87 -0
- package/README.md +58 -19
- package/package.json +13 -5
- package/scripts/copy-plugin-assets.mjs +30 -0
- package/src/blocks.ts +58 -0
- package/src/index.ts +9 -8
- package/src/link.ts +158 -0
- package/src/pages.ts +1 -1
- package/src/plugins/blog/AGENTS.md +131 -0
- package/src/plugins/random-quote/AGENTS.md +79 -0
- package/src/plugins/random-quote/index.ts +33 -0
- package/src/plugins/random-quote/public_assets/quotes.json +230 -0
- package/src/render-page.ts +1 -1
- package/src/site.ts +7 -0
- package/src/slot-content.ts +10 -10
- package/src/slot-handle.ts +3 -3
- package/test/async-render.test.ts +13 -13
- package/test/blog.test.ts +1 -1
- package/test/plugin-assets.test.ts +80 -0
- package/test/random-quote.test.ts +55 -0
- package/tsconfig.json +1 -0
- package/src/modules.ts +0 -58
- /package/src/{modules/blog/module.ts → plugins/blog/index.ts} +0 -0
- /package/src/{modules → plugins}/blog/posts.ts +0 -0
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
|
|
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
|
-
|
|
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
|
|
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)`
|
|
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
|
-
| **
|
|
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
|
-
**
|
|
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-
|
|
48
|
+
my-site/
|
|
37
49
|
site.config.ts # createSiteConfig({ name, url, ... })
|
|
38
|
-
|
|
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 "@
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
"./
|
|
21
|
-
"types": "./dist/
|
|
22
|
-
"import": "./dist/
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
type
|
|
5
|
-
type
|
|
6
|
-
type
|
|
7
|
-
type
|
|
8
|
-
} from "./
|
|
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
|
-
|
|
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
|
|
183
|
+
`required slot "${required}" must have at least one block or non-empty HTML string`,
|
|
184
184
|
);
|
|
185
185
|
}
|
|
186
186
|
}
|