@stackbox/cms 0.0.5 → 0.1.1

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 (119) hide show
  1. package/README.md +158 -10
  2. package/dist/AGENTS.md +218 -0
  3. package/dist/blocks.d.ts +6 -13
  4. package/dist/blocks.d.ts.map +1 -1
  5. package/dist/blocks.js +2 -0
  6. package/dist/blocks.js.map +1 -1
  7. package/dist/build.d.ts +14 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +145 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/cache.d.ts +14 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +93 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/cli.d.ts +3 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +16 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/index.d.ts +12 -8
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/link.d.ts +3 -7
  24. package/dist/link.d.ts.map +1 -1
  25. package/dist/link.js +12 -19
  26. package/dist/link.js.map +1 -1
  27. package/dist/merge-hooks.d.ts +3 -0
  28. package/dist/merge-hooks.d.ts.map +1 -0
  29. package/dist/merge-hooks.js +54 -0
  30. package/dist/merge-hooks.js.map +1 -0
  31. package/dist/pages.d.ts +15 -46
  32. package/dist/pages.d.ts.map +1 -1
  33. package/dist/pages.js +2 -0
  34. package/dist/pages.js.map +1 -1
  35. package/dist/plugin.d.ts +22 -0
  36. package/dist/plugin.d.ts.map +1 -0
  37. package/dist/plugin.js +156 -0
  38. package/dist/plugin.js.map +1 -0
  39. package/dist/plugins/blog/index.d.ts +6 -5
  40. package/dist/plugins/blog/index.d.ts.map +1 -1
  41. package/dist/plugins/blog/index.js +16 -0
  42. package/dist/plugins/blog/index.js.map +1 -1
  43. package/dist/plugins/random-quote/assets/quotes.json +230 -0
  44. package/dist/plugins/random-quote/index.d.ts +3 -1
  45. package/dist/plugins/random-quote/index.d.ts.map +1 -1
  46. package/dist/plugins/random-quote/index.js +15 -1
  47. package/dist/plugins/random-quote/index.js.map +1 -1
  48. package/dist/plugins/sitemap/index.d.ts +14 -0
  49. package/dist/plugins/sitemap/index.d.ts.map +1 -0
  50. package/dist/plugins/sitemap/index.js +119 -0
  51. package/dist/plugins/sitemap/index.js.map +1 -0
  52. package/dist/render-head.d.ts +2 -2
  53. package/dist/render-head.d.ts.map +1 -1
  54. package/dist/render-page.d.ts +2 -4
  55. package/dist/render-page.d.ts.map +1 -1
  56. package/dist/render-page.js +7 -3
  57. package/dist/render-page.js.map +1 -1
  58. package/dist/site.d.ts +10 -17
  59. package/dist/site.d.ts.map +1 -1
  60. package/dist/site.js +155 -7
  61. package/dist/site.js.map +1 -1
  62. package/dist/slot-content.d.ts +3 -19
  63. package/dist/slot-content.d.ts.map +1 -1
  64. package/dist/slot-content.js.map +1 -1
  65. package/dist/slot-handle.d.ts +7 -25
  66. package/dist/slot-handle.d.ts.map +1 -1
  67. package/dist/slot-handle.js +29 -14
  68. package/dist/slot-handle.js.map +1 -1
  69. package/dist/stackbox/context.d.ts +2 -51
  70. package/dist/stackbox/context.d.ts.map +1 -1
  71. package/dist/stackbox/context.js.map +1 -1
  72. package/dist/templates.d.ts +7 -57
  73. package/dist/templates.d.ts.map +1 -1
  74. package/dist/templates.js.map +1 -1
  75. package/dist/types.d.ts +281 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +2 -0
  78. package/dist/types.js.map +1 -0
  79. package/package.json +17 -6
  80. package/src/plugins/blog/AGENTS.md +5 -1
  81. package/src/plugins/blog/index.ts +22 -5
  82. package/src/plugins/random-quote/AGENTS.md +30 -7
  83. package/src/plugins/random-quote/index.ts +17 -1
  84. package/src/plugins/random-quote/public_assets/widget.css +5 -0
  85. package/src/plugins/sitemap/AGENTS.md +82 -0
  86. package/src/plugins/sitemap/index.ts +156 -0
  87. package/AGENTS.md +0 -87
  88. package/dist/modules/blog/module.d.ts +0 -20
  89. package/dist/modules/blog/module.d.ts.map +0 -1
  90. package/dist/modules/blog/module.js +0 -52
  91. package/dist/modules/blog/module.js.map +0 -1
  92. package/dist/modules/blog/posts.d.ts +0 -15
  93. package/dist/modules/blog/posts.d.ts.map +0 -1
  94. package/dist/modules/blog/posts.js +0 -46
  95. package/dist/modules/blog/posts.js.map +0 -1
  96. package/dist/modules.d.ts +0 -18
  97. package/dist/modules.d.ts.map +0 -1
  98. package/dist/modules.js +0 -25
  99. package/dist/modules.js.map +0 -1
  100. package/dist/plugins/blog/module.d.ts +0 -20
  101. package/dist/plugins/blog/module.d.ts.map +0 -1
  102. package/dist/plugins/blog/module.js +0 -52
  103. package/dist/plugins/blog/module.js.map +0 -1
  104. package/dist/plugins/blog/plugin.d.ts +0 -20
  105. package/dist/plugins/blog/plugin.d.ts.map +0 -1
  106. package/dist/plugins/blog/plugin.js +0 -52
  107. package/dist/plugins/blog/plugin.js.map +0 -1
  108. package/dist/plugins/random-quote/module.d.ts +0 -9
  109. package/dist/plugins/random-quote/module.d.ts.map +0 -1
  110. package/dist/plugins/random-quote/module.js +0 -31
  111. package/dist/plugins/random-quote/module.js.map +0 -1
  112. package/dist/plugins/random-quote/plugin.d.ts +0 -8
  113. package/dist/plugins/random-quote/plugin.d.ts.map +0 -1
  114. package/dist/plugins/random-quote/plugin.js +0 -31
  115. package/dist/plugins/random-quote/plugin.js.map +0 -1
  116. package/dist/plugins/random-quote/quotes.json +0 -230
  117. package/dist/public/_sb/plugins/random-quote/quotes.json +0 -230
  118. package/src/plugins/random-quote/public_assets/quotes.json +0 -230
  119. /package/{dist/plugins/random-quote/public_assets → src/plugins/random-quote/assets}/quotes.json +0 -0
package/README.md CHANGED
@@ -12,7 +12,7 @@ export default {
12
12
  };
13
13
  ```
14
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.
15
+ GET/HEAD page renders are cached in memory by default (stale-while-revalidate, single-flight refresh). Set `cache: false` on the site config, a page, or any block on that page to render every request. Plugin routes and plugin assets are always uncached.
16
16
 
17
17
  ## Why this exists
18
18
 
@@ -33,15 +33,121 @@ npm install @stackbox/cms
33
33
  | Primitive | Factory | Purpose |
34
34
  | --- | --- | --- |
35
35
  | **Site config** | `createSiteConfig(config)` | Definition-time settings shared by templates and pages. |
36
- | **Site** | `createSite(siteConfig, { pages })` | Runtime router with `fetch(request, env)` — a fetch-handler-compatible server. |
36
+ | **Site** | `createSite(siteConfig, { pages, plugins? })` | Runtime router with `fetch(request, env)` — a fetch-handler-compatible server. |
37
37
  | **Template** | `createTemplate({ siteConfig, slots, render })` | A reusable page layout that declares named **slots**. |
38
38
  | **Page** | `createPage(template, { path, title, slots })` | A single URL, built by filling a template's slots with content. |
39
39
  | **Block** | `createBlock({ name, render })` | A self-contained content block placed into a slot at request time. |
40
+ | **Plugin** | `createPlugin({ name, description, version, keywords, root })` | A packaged feature. Register it on `createSite({ plugins })` when you use it. |
40
41
 
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
+ **Plugins vs blocks:** **Plugins** package whole features (blog, newsletter) — bundled under `@stackbox/cms/plugins/<name>` or a third-party package with the same shape. Each plugin **must** export a `plugin` object from `createPlugin()` (name, description, version, keywords). **Blocks** are the core slot primitive via `createBlock()`; plugins can ship blocks alongside other exports.
43
+
44
+ Importing a plugin does not enable it. Pass `plugin` into `createSite({ plugins })`. Only registered plugins have `public_assets/` served or copied.
42
45
 
43
46
  **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.
44
47
 
48
+ ## Page cache
49
+
50
+ Optional `cache: { min?: number; max?: number } | false` on site config, pages, and blocks (milliseconds). Omit = no opinion; `false` = never cache that request.
51
+
52
+ ```ts
53
+ createSiteConfig({
54
+ name: "My Site",
55
+ url: "https://example.com",
56
+ cache: { min: 60_000, max: 7 * 24 * 60 * 60 * 1000 },
57
+ });
58
+
59
+ createPage(template, {
60
+ path: "/live",
61
+ title: "Live",
62
+ cache: false,
63
+ slots: { content: [...] },
64
+ });
65
+
66
+ createBlock({
67
+ name: "ticker",
68
+ cache: { max: 5 * 60 * 1000 },
69
+ render() { ... },
70
+ });
71
+ ```
72
+
73
+ TTL merges settings from the site config, the page, and on-page blocks: highest `min` floors the result, lowest `max` caps it (default 1 day, hard cap 30 days). Responses include `Cache-Control`. Expired entries are served immediately while one background refresh runs per key.
74
+
75
+ ## Site hooks
76
+
77
+ Extend rendering at runtime with an optional `hooks` object on `createSite`:
78
+
79
+ ```ts
80
+ export default createSite(siteConfig, {
81
+ pages: [homePage],
82
+ hooks: {
83
+ shouldCache(request) {
84
+ return true;
85
+ },
86
+ renderSlotItem(html, info) {
87
+ return html;
88
+ },
89
+ afterRender(html, info) {
90
+ return html;
91
+ },
92
+ beforeResponse(response, info) {
93
+ return response;
94
+ },
95
+ },
96
+ });
97
+ ```
98
+
99
+ - **`shouldCache(request)`** — return `false` to skip the page cache for that request (page/block TTL still applies when it returns `true`)
100
+ - **`renderSlotItem(html, info)`** — transform each slot item after it renders
101
+ - **`afterRender(html, info)`** — transform the full page HTML
102
+ - **`beforeResponse(response, info)`** — adjust page and 404 responses before they are returned
103
+
104
+ Pages and blocks may optionally set `source` (for example `import.meta.url`) so hooks can point editors or agents at the defining file.
105
+
106
+ Plugins can register hooks too — pass the plugin to `createSite({ plugins })` and its hooks merge with any site-level hooks:
107
+
108
+ ```ts
109
+ createPlugin({
110
+ name: "my-plugin",
111
+ // ...metadata...
112
+ hooks({ site }) {
113
+ const { url } = site.siteConfig.config;
114
+ return {
115
+ afterRender(html) {
116
+ return html;
117
+ },
118
+ };
119
+ },
120
+ });
121
+ ```
122
+
123
+ Plugin hooks run in registration order; site `hooks` run last. `shouldCache` uses AND semantics across all hooks.
124
+
125
+ ### Edit plugin
126
+
127
+ Enable in-iframe editing with a single plugin registration:
128
+
129
+ ```ts
130
+ import editPlugin from "@stackbox/edit-mode-plugin";
131
+
132
+ export default createSite(siteConfig, {
133
+ pages: [homePage],
134
+ plugins: [editPlugin],
135
+ });
136
+ ```
137
+
138
+ Visit any page with `?sbedit=1` to activate edit mode.
139
+
140
+ ## Types
141
+
142
+ Core types are exported as a single namespace:
143
+
144
+ ```ts
145
+ import type { Stackbox } from "@stackbox/cms";
146
+
147
+ type Page = Stackbox.Page;
148
+ type SiteHooks = Stackbox.SiteHooks;
149
+ ```
150
+
45
151
  ## Project layout
46
152
 
47
153
  ```
@@ -109,6 +215,7 @@ import aboutPage from "./pages/about";
109
215
 
110
216
  export default createSite(siteConfig, {
111
217
  pages: [homePage, aboutPage],
218
+ // plugins: [blogPlugin, randomQuotePlugin], // only plugins this site uses
112
219
  });
113
220
  ```
114
221
 
@@ -157,10 +264,12 @@ export const blogPostPages = blog.posts.map((post) =>
157
264
 
158
265
  ```ts
159
266
  // server.ts
267
+ import blogPlugin from "@stackbox/cms/plugins/blog";
160
268
  import { blogListingPages, blogPostPages } from "./pages/blog";
161
269
 
162
270
  export default createSite(siteConfig, {
163
271
  pages: [homePage, ...blogListingPages, ...blogPostPages],
272
+ plugins: [blogPlugin],
164
273
  });
165
274
  ```
166
275
 
@@ -169,37 +278,76 @@ export default createSite(siteConfig, {
169
278
  **Blog** — content objects wired into pages:
170
279
 
171
280
  ```ts
172
- import { createBlog } from "@stackbox/cms/plugins/blog";
281
+ import blogPlugin, { createBlog } from "@stackbox/cms/plugins/blog";
173
282
  ```
174
283
 
175
284
  **Random quote** — block-only plugin (drop into any slot):
176
285
 
177
286
  ```ts
178
- import { randomQuoteBlock } from "@stackbox/cms/plugins/random-quote";
287
+ import randomQuotePlugin, {
288
+ randomQuoteBlock,
289
+ } from "@stackbox/cms/plugins/random-quote";
179
290
  import myQuotes from "../content/quotes.json" with { type: "json" };
180
291
 
181
292
  slots: { sidebar: [randomQuoteBlock()] } // bundled quotes
182
293
  slots: { sidebar: [randomQuoteBlock({ quotes: myQuotes })] } // your own
183
294
  ```
184
295
 
296
+ Register every plugin you use:
297
+
298
+ ```ts
299
+ export default createSite(siteConfig, {
300
+ pages: [homePage, ...blogListingPages, ...blogPostPages],
301
+ plugins: [blogPlugin, randomQuotePlugin],
302
+ });
303
+ ```
304
+
305
+ Private plugin files live in `assets/` (imported by JS). Files served over HTTP live in `public_assets/` and are copied or served only for registered plugins — including third-party packages that follow the same layout.
306
+
307
+ Plugins may also register **`routes`** (served by `fetch()`) and **`build`** hooks (run by `stackbox-cms build`). The **sitemap** plugin uses both to serve and write `/sitemap.xml`:
308
+
309
+ ```ts
310
+ import sitemapPlugin from "@stackbox/cms/plugins/sitemap";
311
+
312
+ export default createSite(siteConfig, {
313
+ pages: [homePage, aboutPage],
314
+ plugins: [sitemapPlugin],
315
+ });
316
+ ```
317
+
318
+ Run the package build script so registered plugin `public_assets/` land in the site public directory:
319
+
320
+ ```bash
321
+ npx stackbox-cms build
322
+ # or: npx stackbox-cms build --site server.ts --outDir dist --publicDir dist/public
323
+ ```
324
+
325
+ ```json
326
+ "scripts": {
327
+ "build": "stackbox-cms build"
328
+ }
329
+ ```
330
+
185
331
  ## AI agents
186
332
 
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.
333
+ Bundled plugins include agent playbooks. See [`dist/AGENTS.md`](dist/AGENTS.md) (generated on `pnpm run build`) for site conventions and a plugin keyword 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
334
 
189
335
  If you are building a site that uses this package, add this to your project's `AGENTS.md`:
190
336
 
191
337
  ```md
192
338
  This site uses @stackbox/cms. Before adding features, read
193
- `node_modules/@stackbox/cms/AGENTS.md` and follow its plugin catalog.
339
+ `node_modules/@stackbox/cms/dist/AGENTS.md` and follow its plugin catalog.
194
340
  Do not reimplement bundled plugins.
195
341
  ```
196
342
 
197
343
  ## Development
198
344
 
345
+ From the repository root:
346
+
199
347
  ```bash
200
- npm run build # compile the package
201
- npm run typecheck # type-check without emitting
202
- npm test # build, then run the test suite
348
+ pnpm --filter @stackbox/cms build
349
+ pnpm --filter @stackbox/cms typecheck
350
+ pnpm --filter @stackbox/cms test
203
351
  ```
204
352
 
205
353
  ## License
package/dist/AGENTS.md ADDED
@@ -0,0 +1,218 @@
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, plugins? })` | 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
+ | Plugin | `createPlugin({ name, description, version, keywords, root })` | Packaged feature; register on the site when used |
15
+
16
+ **Project layout:**
17
+
18
+ ```
19
+ site.config.ts # createSiteConfig
20
+ server.ts # createSite(...) — default export is the fetch handler
21
+ templates/ # shared createTemplate() layouts
22
+ pages/ # createPage() per route or feature
23
+ content/ # markdown or other bundle-time content (per feature)
24
+ package.json # "build": "stackbox-cms build"
25
+ ```
26
+
27
+ 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.
28
+
29
+ ## Page request cache
30
+
31
+ GET/HEAD page renders are cached in memory by default. Keys are pathname + query string (`/about?preview=1` is separate from `/about`). Plugin routes and `/_sb/plugins/` assets are never cached.
32
+
33
+ Optional `cache` on site config, pages, and blocks — same shape everywhere:
34
+
35
+ ```ts
36
+ export type CacheConfig = { min?: number; max?: number } | false;
37
+ ```
38
+
39
+ Milliseconds. Omit = no opinion. `cache: false` = fully dynamic (do not cache that request).
40
+
41
+ ```ts
42
+ createSiteConfig({
43
+ name: "My Site",
44
+ url: "https://example.com",
45
+ cache: { min: 60_000, max: 7 * 24 * 60 * 60 * 1000 },
46
+ })
47
+
48
+ createPage(template, {
49
+ path: "/live",
50
+ title: "Live",
51
+ cache: false,
52
+ slots: { content: [...] },
53
+ })
54
+
55
+ createBlock({
56
+ name: "random-quote",
57
+ cache: { max: 5 * 60 * 1000 },
58
+ render() { ... },
59
+ })
60
+ ```
61
+
62
+ TTL is derived from the site config, the page, and every block on that page. If **any** of those is `false`, the request is not cached. Otherwise merge `{ min, max }` (unset fields ignored): highest `min` floors the result, lowest `max` caps it (default TTL 1 day, hard cap 30 days). Registering a plugin does not change cache times — only blocks (or pages) with `cache` on that page count.
63
+
64
+ Stale entries are served immediately (stale-while-revalidate); one background refresh runs per key. Concurrent misses share a single render (single-flight). Failed refreshes keep the last good HTML.
65
+
66
+ Disable caching for a whole site with `createSiteConfig({ cache: false })`. `createSite` accepts an optional `cacheAdapter` for tests or custom stores — do not overload `cache` on `createSite`.
67
+
68
+ ## Site hooks
69
+
70
+ Optional runtime hooks on `createSite(siteConfig, { pages, plugins?, hooks? })`. All hooks are optional; omitting them preserves default behavior. Page/block `cache` TTL is unchanged — hooks do not replace that policy.
71
+
72
+ ```ts
73
+ createSite(siteConfig, {
74
+ pages: [homePage],
75
+ hooks: {
76
+ shouldCache(request) {
77
+ return !new URL(request.url).searchParams.has("preview");
78
+ },
79
+ renderSlotItem(html, { item, slot, index, page, ctx }) {
80
+ return html;
81
+ },
82
+ afterRender(html, { page, ctx }) {
83
+ return html;
84
+ },
85
+ beforeResponse(response, { request, page, ctx }) {
86
+ return response;
87
+ },
88
+ },
89
+ });
90
+ ```
91
+
92
+ | Hook | When | Signature |
93
+ | --- | --- | --- |
94
+ | `shouldCache` | Before the page cache adapter runs | `(request: Request) => boolean` — `false` skips the adapter for that request; `true` uses page/block TTL |
95
+ | `renderSlotItem` | After each slot item (block or HTML string) renders | `(html, info) => string` |
96
+ | `afterRender` | After the full page HTML is assembled | `(html, info) => string` |
97
+ | `beforeResponse` | Before `fetch` returns a page or 404 response | `(response, info) => Response` |
98
+
99
+ Plugin routes and `/_sb/plugins/` assets do not run render hooks. `beforeResponse` does not wrap plugin route responses.
100
+
101
+ Optional `source?: string` on `createPage` and `createBlock` (e.g. `import.meta.url`) is metadata for hook consumers — the engine stores it as given.
102
+
103
+ ## Plugins vs blocks
104
+
105
+ - **Plugin** — a packaged use-case (bundled under `@stackbox/cms/plugins/<name>`, or a third-party package with the same shape). **Default-exports** a `createPlugin()` registration object, plus named factories, content objects, blocks, types, and helpers.
106
+ - **Block** — a core primitive via `createBlock()`. Renders HTML into a template slot. Usable inside or outside plugins.
107
+
108
+ Slot content is `Block | string`. Plugin content arrays can interleave blocks and HTML strings.
109
+
110
+ **Register plugins when you use them.** Importing a plugin module is not enough — pass its default export to `createSite({ plugins })`. Only registered plugins have their `public_assets/` copied or served. Third-party packages are registered the same way; they are not discovered from a `plugins/` folder.
111
+
112
+ ```ts
113
+ import blogPlugin from "@stackbox/cms/plugins/blog";
114
+ import randomQuotePlugin from "@stackbox/cms/plugins/random-quote";
115
+
116
+ export default createSite(siteConfig, {
117
+ pages: [homePage, ...blogListingPages, ...blogPostPages],
118
+ plugins: [blogPlugin, randomQuotePlugin],
119
+ });
120
+ ```
121
+
122
+ `createPlugin({ name, description, version, keywords, root })` is **required**. `createSite` throws if a registered value is missing that metadata. `keywords` tell agents when to reach for the plugin.
123
+
124
+ Optional hooks on `createPlugin()`:
125
+
126
+ - **`routes({ site })`** — site-level paths served by `fetch()` before the GET/HEAD gate, plugin assets, and pages (e.g. `/sitemap.xml`). The route handler decides which HTTP methods are allowed. Must return synchronously. Paths must not collide with pages or other plugin routes.
127
+ - **`build({ site, outDir, publicDir })`** — called by `stackbox-cms build` after copying registered `public_assets/`. Write generated files into `publicDir` (or elsewhere under `outDir`).
128
+ - **`hooks({ site })`** — return `SiteHooks` to register cache/render/response behavior when the plugin is added to `createSite({ plugins })`. Must return synchronously. `{ site }` exposes `site.siteConfig.config`, `site.pages`, and `site.plugins`.
129
+
130
+ Plugin hooks merge with site-level `hooks` on `createSite`: `renderSlotItem`, `afterRender`, and `beforeResponse` run in plugin registration order, then site hooks last; `shouldCache` uses AND semantics (every hook must return `true` to cache).
131
+
132
+ ## Plugin catalog
133
+
134
+ 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.
135
+
136
+ <!-- plugin-catalog:start -->
137
+ | Plugin | Import | Read when the user mentions | Instructions |
138
+ | --- | --- | --- | --- |
139
+ | blog | `@stackbox/cms/plugins/blog` | blog, posts, articles, journal, markdown posts, blog listing, blog page | `src/plugins/blog/AGENTS.md` |
140
+ | random-quote | `@stackbox/cms/plugins/random-quote` | random quote, quote of the day, inspirational quote, sidebar quote, quotation | `src/plugins/random-quote/AGENTS.md` |
141
+ | sitemap | `@stackbox/cms/plugins/sitemap` | sitemap, sitemap.xml, seo, search engines, xml sitemap | `src/plugins/sitemap/AGENTS.md` |
142
+ <!-- plugin-catalog:end -->
143
+
144
+ When adding a new plugin to this package, add a folder under `src/plugins/<name>/`, **default-export** the `createPlugin()` registration from `index.ts`, and create `src/plugins/<name>/AGENTS.md` using the same heading structure as the blog plugin. Run `pnpm run build` — the package scans `dist/plugins/` and writes the keyword catalog to `dist/AGENTS.md` from each plugin's `keywords`. Document all export kinds (factory, types, blocks, helpers).
145
+
146
+ ## Plugin layout and assets
147
+
148
+ Every plugin — bundled or third-party — has the same shape:
149
+
150
+ ```
151
+ index.ts # default export: createPlugin({ ... }); named exports: factories, types, blocks
152
+ AGENTS.md # agent playbook
153
+ assets/ # private files imported by JS (JSON, templates, …)
154
+ public_assets/ # files served over HTTP (images, CSS, fonts, …)
155
+ ```
156
+
157
+ `root` is the directory that contains `assets/` and `public_assets/`. Pass `import.meta.dirname` when those folders sit next to the plugin entry. A third-party package may pass its package root instead.
158
+
159
+ | Folder | Copied to | When |
160
+ | --- | --- | --- |
161
+ | `assets/` | `{plugin.root}/assets` (next to compiled JS) | Package build of that plugin (`copyPluginPrivateAssets`) |
162
+ | `public_assets/` | `{publicDir}/_sb/plugins/{name}/` | Site build, **only for plugins registered on the site** (`copyRegisteredPluginAssets`) |
163
+
164
+ `public_assets/` is no longer mirrored into the plugin's compiled folder. Import private data from `./assets/...`, not from `public_assets/`.
165
+
166
+ At request time, `createSite.fetch` order is: **plugin routes** → **plugin public assets** (`/_sb/plugins/...`) → **pages**.
167
+
168
+ For a static `public/` directory, run the package build script — it loads the site, reads `site.plugins`, copies registered `public_assets/`, then runs each plugin's `build` hook:
169
+
170
+ ```bash
171
+ npx stackbox-cms build
172
+ ```
173
+
174
+ ```json
175
+ "scripts": {
176
+ "build": "stackbox-cms build"
177
+ }
178
+ ```
179
+
180
+ ```ts
181
+ import { build } from "@stackbox/cms/build";
182
+ import site from "./server.ts";
183
+
184
+ await build({
185
+ site,
186
+ outDir: "dist",
187
+ publicDir: "dist/public",
188
+ });
189
+ ```
190
+
191
+ Defaults: `--site` looks for `server.ts` (then `server.js`, `src/server.ts`, `src/server.js`); `outDir` is `dist`; `publicDir` is `{outDir}/public`. Flags: `--site`, `--outDir`, `--publicDir`.
192
+
193
+ 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.
194
+
195
+ ```ts
196
+ import { pluginAssetPath } from "@stackbox/cms/link";
197
+
198
+ pluginAssetPath("sb-random-quote", "widget.css");
199
+ // → "/_sb/plugins/sb-random-quote/widget.css"
200
+ ```
201
+
202
+ ## For site agents
203
+
204
+ If you are working in a **consumer site repo** (not this package), read the generated agent instructions from the installed package:
205
+
206
+ ```
207
+ node_modules/@stackbox/cms/dist/AGENTS.md
208
+ ```
209
+
210
+ Recommended snippet for the site's own `AGENTS.md`:
211
+
212
+ ```md
213
+ This site uses @stackbox/cms. Before adding features, read
214
+ `node_modules/@stackbox/cms/dist/AGENTS.md` and follow its plugin catalog.
215
+ Do not reimplement bundled plugins.
216
+ ```
217
+
218
+ 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/dist/blocks.d.ts CHANGED
@@ -1,18 +1,11 @@
1
- import type { RenderContext } from "./pages.js";
2
- import type { HSHtml } from "@hyperspan/html";
3
- export type BlockRenderResult = HSHtml | Promise<HSHtml>;
4
- export type Block = {
5
- readonly __kind: "block";
6
- readonly name: string;
7
- render(ctx: RenderContext): BlockRenderResult;
8
- };
1
+ import type { Stackbox as SB } from "./types.js";
9
2
  type BlockDef<TOptions = undefined> = {
10
3
  name: string;
11
- render(options: TOptions, ctx?: RenderContext): BlockRenderResult;
4
+ cache?: SB.CacheConfig;
5
+ source?: string;
6
+ render(options: TOptions, ctx?: SB.RenderContext): SB.BlockRenderResult;
12
7
  };
13
- export type BlockOptionsOf<F> = F extends (options?: infer O) => unknown ? [O] extends [undefined] ? undefined : O : never;
14
- export type BlockFactory<TOptions = undefined> = (options?: TOptions) => Block;
15
- export declare function createBlock<TOptions = undefined>(def: BlockDef<TOptions>): BlockFactory<TOptions>;
16
- export declare function isBlock(value: unknown): value is Block;
8
+ export declare function createBlock<TOptions = undefined>(def: BlockDef<TOptions>): SB.BlockFactory<TOptions>;
9
+ export declare function isBlock(value: unknown): value is SB.Block;
17
10
  export {};
18
11
  //# sourceMappingURL=blocks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"blocks.d.ts","sourceRoot":"","sources":["../src/blocks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAE9C,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AAEzD,MAAM,MAAM,KAAK,GAAG;IAClB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,GAAG,EAAE,aAAa,GAAG,iBAAiB,CAAC;CAC/C,CAAC;AAEF,KAAK,QAAQ,CAAC,QAAQ,GAAG,SAAS,IAAI;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,EAAE,aAAa,GAAG,iBAAiB,CAAC;CACnE,CAAC;AAEF,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,KAAK,OAAO,GACpE,CAAC,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,GACrB,SAAS,GACT,CAAC,GACH,KAAK,CAAC;AAEV,MAAM,MAAM,YAAY,CAAC,QAAQ,GAAG,SAAS,IAAI,CAC/C,OAAO,CAAC,EAAE,QAAQ,KACf,KAAK,CAAC;AAQX,wBAAgB,WAAW,CAAC,QAAQ,GAAG,SAAS,EAC9C,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,GACtB,YAAY,CAAC,QAAQ,CAAC,CAYxB;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAStD"}
1
+ {"version":3,"file":"blocks.d.ts","sourceRoot":"","sources":["../src/blocks.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,YAAY,CAAC;AAEjD,KAAK,QAAQ,CAAC,QAAQ,GAAG,SAAS,IAAI;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,aAAa,GAAG,EAAE,CAAC,iBAAiB,CAAC;CACzE,CAAC;AAQF,wBAAgB,WAAW,CAAC,QAAQ,GAAG,SAAS,EAC9C,GAAG,EAAE,QAAQ,CAAC,QAAQ,CAAC,GACtB,EAAE,CAAC,YAAY,CAAC,QAAQ,CAAC,CAc3B;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,EAAE,CAAC,KAAK,CASzD"}
package/dist/blocks.js CHANGED
@@ -11,6 +11,8 @@ export function createBlock(def) {
11
11
  return ((options) => ({
12
12
  __kind: "block",
13
13
  name: def.name,
14
+ ...(def.cache !== undefined ? { cache: def.cache } : {}),
15
+ ...(def.source !== undefined ? { source: def.source } : {}),
14
16
  render: (ctx) => def.render(options, ctx),
15
17
  }));
16
18
  }
@@ -1 +1 @@
1
- {"version":3,"file":"blocks.js","sourceRoot":"","sources":["../src/blocks.ts"],"names":[],"mappings":"AA0BA,SAAS,iBAAiB,CAAC,IAAY;IACrC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IACxD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CACzB,GAAuB;IAEvB,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE5B,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;IAC1D,CAAC;IAED,OAAO,CAAC,CAAC,OAAkB,EAAE,EAAE,CAAC,CAAC;QAC/B,MAAM,EAAE,OAAgB;QACxB,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,MAAM,EAAE,CAAC,GAAkB,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,OAAmB,EAAE,GAAG,CAAC;KACrE,CAAC,CAA2B,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,KAAc;IACpC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACb,KAAe,CAAC,MAAM,KAAK,OAAO;QACnC,OAAQ,KAAe,CAAC,IAAI,KAAK,QAAQ;QACxC,KAAe,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC;QAChC,OAAQ,KAAe,CAAC,MAAM,KAAK,UAAU,CAC9C,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"blocks.js","sourceRoot":"","sources":["../src/blocks.ts"],"names":[],"mappings":"AAUA,SAAS,iBAAiB,CAAC,IAAY;IACrC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IACxD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CACzB,GAAuB;IAEvB,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE5B,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;IAC1D,CAAC;IAED,OAAO,CAAC,CAAC,OAAkB,EAAE,EAAE,CAAC,CAAC;QAC/B,MAAM,EAAE,OAAgB;QACxB,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,GAAG,CAAC,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACxD,GAAG,CAAC,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,MAAM,EAAE,CAAC,GAAqB,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,OAAmB,EAAE,GAAG,CAAC;KACxE,CAAC,CAA8B,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,KAAc;IACpC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACb,KAAkB,CAAC,MAAM,KAAK,OAAO;QACtC,OAAQ,KAAkB,CAAC,IAAI,KAAK,QAAQ;QAC3C,KAAkB,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC;QACnC,OAAQ,KAAkB,CAAC,MAAM,KAAK,UAAU,CACjD,CAAC;AACJ,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { Stackbox as SB } from "./types.js";
2
+ export declare class BuildError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare function resolveBuildSettings(options: Omit<SB.BuildOptions, "site">): SB.BuildSettings;
6
+ /**
7
+ * Run the site build. Copies each registered plugin's `public_assets/` to
8
+ * `{publicDir}/_sb/plugins/{name}/`, then runs plugin `build` hooks.
9
+ */
10
+ export declare function build(options: SB.BuildOptions): Promise<SB.BuildResult>;
11
+ export declare function parseBuildCliArgs(argv: string[]): SB.BuildCliArgs;
12
+ export declare function loadSiteFromEntry(entry: string, cwd?: string): Promise<SB.Site>;
13
+ export declare function runBuildCli(argv: string[], cwd?: string): Promise<SB.BuildResult>;
14
+ //# sourceMappingURL=build.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,YAAY,CAAC;AASjD,qBAAa,UAAW,SAAQ,KAAK;gBACvB,OAAO,EAAE,MAAM;CAI5B;AASD,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,CAAC,GACrC,EAAE,CAAC,aAAa,CAOlB;AAED;;;GAGG;AACH,wBAAsB,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC,YAAY,GAAG,OAAO,CAAC,EAAE,CAAC,WAAW,CAAC,CAmB7E;AAcD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,YAAY,CAmCjE;AAkCD,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,GAAG,SAAgB,GAClB,OAAO,CAAC,EAAE,CAAC,IAAI,CAAC,CAuBlB;AAED,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,SAAgB,GAClB,OAAO,CAAC,EAAE,CAAC,WAAW,CAAC,CAWzB"}
package/dist/build.js ADDED
@@ -0,0 +1,145 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { register } from "node:module";
5
+ import { copyRegisteredPluginAssets, runRegisteredPluginBuilds, } from "./plugin.js";
6
+ import { isSite } from "./site.js";
7
+ const DEFAULT_SITE_ENTRIES = [
8
+ "server.ts",
9
+ "server.js",
10
+ "src/server.ts",
11
+ "src/server.js",
12
+ ];
13
+ export class BuildError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "BuildError";
17
+ }
18
+ }
19
+ function requireDirOption(value, label) {
20
+ if (typeof value !== "string" || value.trim().length === 0) {
21
+ throw new BuildError(`build(): ${label} must be a non-empty string`);
22
+ }
23
+ return resolve(value.trim());
24
+ }
25
+ export function resolveBuildSettings(options) {
26
+ const outDir = requireDirOption(options.outDir ?? "dist", "outDir");
27
+ const publicDir = requireDirOption(options.publicDir ?? join(outDir, "public"), "publicDir");
28
+ return { outDir, publicDir };
29
+ }
30
+ /**
31
+ * Run the site build. Copies each registered plugin's `public_assets/` to
32
+ * `{publicDir}/_sb/plugins/{name}/`, then runs plugin `build` hooks.
33
+ */
34
+ export async function build(options) {
35
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
36
+ throw new BuildError("build(): site is required");
37
+ }
38
+ if (!isSite(options.site)) {
39
+ throw new BuildError("build(): site must be from createSite()");
40
+ }
41
+ const settings = resolveBuildSettings(options);
42
+ mkdirSync(settings.outDir, { recursive: true });
43
+ mkdirSync(settings.publicDir, { recursive: true });
44
+ copyRegisteredPluginAssets(options.site.plugins, settings.publicDir);
45
+ await runRegisteredPluginBuilds(options.site.plugins, {
46
+ site: options.site,
47
+ outDir: settings.outDir,
48
+ publicDir: settings.publicDir,
49
+ });
50
+ return { settings, plugins: options.site.plugins };
51
+ }
52
+ function readFlag(args, index, name) {
53
+ const value = args[index + 1];
54
+ if (!value || value.startsWith("-")) {
55
+ throw new BuildError(`stackbox-cms build: ${name} requires a value`);
56
+ }
57
+ return { value, next: index + 2 };
58
+ }
59
+ export function parseBuildCliArgs(argv) {
60
+ const args = argv.slice(2);
61
+ let index = 0;
62
+ if (args[0] && !args[0].startsWith("-")) {
63
+ if (args[0] !== "build") {
64
+ throw new BuildError(`stackbox-cms: unknown command "${args[0]}" (expected "build")`);
65
+ }
66
+ index = 1;
67
+ }
68
+ let site;
69
+ let outDir;
70
+ let publicDir;
71
+ while (index < args.length) {
72
+ const arg = args[index];
73
+ if (arg === "--site") {
74
+ ({ value: site, next: index } = readFlag(args, index, "--site"));
75
+ continue;
76
+ }
77
+ if (arg === "--outDir") {
78
+ ({ value: outDir, next: index } = readFlag(args, index, "--outDir"));
79
+ continue;
80
+ }
81
+ if (arg === "--publicDir") {
82
+ ({ value: publicDir, next: index } = readFlag(args, index, "--publicDir"));
83
+ continue;
84
+ }
85
+ throw new BuildError(`stackbox-cms build: unknown option "${arg}"`);
86
+ }
87
+ return { command: "build", site, outDir, publicDir };
88
+ }
89
+ function defaultSiteEntry(cwd) {
90
+ for (const candidate of DEFAULT_SITE_ENTRIES) {
91
+ const absolute = resolve(cwd, candidate);
92
+ if (existsSync(absolute)) {
93
+ return absolute;
94
+ }
95
+ }
96
+ throw new BuildError(`stackbox-cms build: no site entry found (looked for ${DEFAULT_SITE_ENTRIES.join(", ")}). Pass --site`);
97
+ }
98
+ let tsxRegistered = false;
99
+ function tsxAlreadyActive() {
100
+ const flags = [...process.execArgv, process.env.NODE_OPTIONS ?? ""].join(" ");
101
+ return flags.includes("tsx");
102
+ }
103
+ function ensureTsxRegistered() {
104
+ if (tsxRegistered || tsxAlreadyActive()) {
105
+ tsxRegistered = true;
106
+ return;
107
+ }
108
+ try {
109
+ register("tsx", import.meta.url);
110
+ }
111
+ catch {
112
+ // Loader already registered in this process.
113
+ }
114
+ tsxRegistered = true;
115
+ }
116
+ export async function loadSiteFromEntry(entry, cwd = process.cwd()) {
117
+ const absolute = resolve(cwd, entry);
118
+ if (!existsSync(absolute)) {
119
+ throw new BuildError(`stackbox-cms build: site entry not found: ${absolute}`);
120
+ }
121
+ ensureTsxRegistered();
122
+ const mod = (await import(pathToFileURL(absolute).href));
123
+ const candidate = isSite(mod.default)
124
+ ? mod.default
125
+ : isSite(mod.site)
126
+ ? mod.site
127
+ : null;
128
+ if (!candidate) {
129
+ throw new BuildError(`stackbox-cms build: ${absolute} must default-export a site from createSite()`);
130
+ }
131
+ return candidate;
132
+ }
133
+ export async function runBuildCli(argv, cwd = process.cwd()) {
134
+ const args = parseBuildCliArgs(argv);
135
+ const entry = args.site
136
+ ? resolve(cwd, args.site)
137
+ : defaultSiteEntry(cwd);
138
+ const site = await loadSiteFromEntry(entry, cwd);
139
+ return build({
140
+ site,
141
+ outDir: args.outDir ? resolve(cwd, args.outDir) : resolve(cwd, "dist"),
142
+ publicDir: args.publicDir ? resolve(cwd, args.publicDir) : undefined,
143
+ });
144
+ }
145
+ //# sourceMappingURL=build.js.map