@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.
package/LICENSE.md ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Vance Lucas
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # @stackbox/cms
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.
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.
6
+
7
+ ## Why this exists
8
+
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.
10
+
11
+ ## Requirements
12
+
13
+ - Node.js >= 20
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @stackbox/cms
19
+ ```
20
+
21
+ ## Concepts
22
+
23
+ | Primitive | Factory | Purpose |
24
+ | --- | --- | --- |
25
+ | **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. |
30
+
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.
32
+
33
+ ## Project layout
34
+
35
+ ```
36
+ my-worker/
37
+ site.config.ts # createSiteConfig({ name, url, ... })
38
+ worker.ts # createSite(siteConfig, { pages }) — default export for Cloudflare
39
+ templates/
40
+ site-template.ts # shared createTemplate() layouts
41
+ pages/
42
+ home.ts # exports homePage
43
+ blog.ts # createBlog() + createPage() for listing and posts
44
+ content/blog/ # markdown posts (read at bundle time)
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ `site.config.ts`:
50
+
51
+ ```ts
52
+ import { createSiteConfig } from "@stackbox/cms";
53
+
54
+ export default createSiteConfig({
55
+ name: "My Site",
56
+ url: "https://example.com",
57
+ });
58
+ ```
59
+
60
+ `templates/site-template.ts`:
61
+
62
+ ```ts
63
+ import { html } from "@hyperspan/html";
64
+ import { createTemplate } from "@stackbox/cms";
65
+ import siteConfig from "../site.config";
66
+
67
+ export const siteTemplate = createTemplate({
68
+ siteConfig,
69
+ slots: [{ name: "content", options: { required: true, primary: true } }],
70
+ render({ slots }) {
71
+ return html`<main>${slots.content.render()}</main>`;
72
+ },
73
+ });
74
+ ```
75
+
76
+ `pages/home.ts`:
77
+
78
+ ```ts
79
+ import { createPage } from "@stackbox/cms";
80
+ import { siteTemplate } from "../templates/site-template";
81
+
82
+ const homePage = createPage(siteTemplate, {
83
+ path: "/",
84
+ title: "Home",
85
+ slots: { content: ["<p>Welcome to my site.</p>"] },
86
+ });
87
+
88
+ export default homePage;
89
+ ```
90
+
91
+ `worker.ts`:
92
+
93
+ ```ts
94
+ import { createSite } from "@stackbox/cms";
95
+ import siteConfig from "./site.config";
96
+ import homePage from "./pages/home";
97
+ import aboutPage from "./pages/about";
98
+
99
+ export default createSite(siteConfig, {
100
+ pages: [homePage, aboutPage],
101
+ });
102
+ ```
103
+
104
+ Deploy with [`wrangler`](https://developers.cloudflare.com/workers/wrangler/). The default export's `fetch(request, env)` handles each request.
105
+
106
+ ## Blog module
107
+
108
+ `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
+
110
+ ```ts
111
+ // pages/blog.ts
112
+ import { join } from "node:path";
113
+ import { createPage } from "@stackbox/cms";
114
+ import { createBlog } from "@stackbox/cms/modules/blog";
115
+ import { siteTemplate } from "../templates/site-template";
116
+
117
+ const blog = createBlog({
118
+ contentPath: join(import.meta.dirname, "../content/blog"),
119
+ pathPrefix: "/blog",
120
+ postsPerPage: 10, // optional — omit to put all posts on one listing page
121
+ });
122
+
123
+ export const blogListingPages = blog.listings.map((listing, index) =>
124
+ createPage(siteTemplate, {
125
+ path: listing.path,
126
+ title: index === 0 ? "Blog" : `Blog — page ${index + 1}`,
127
+ slots: {
128
+ content: [...listing.content, "<p>Subscribe for updates</p>"],
129
+ },
130
+ }),
131
+ );
132
+
133
+ export const blogPostPages = blog.posts.map((post) =>
134
+ createPage(siteTemplate, {
135
+ path: post.path,
136
+ title: post.title,
137
+ meta: post.meta,
138
+ slots: { content: [...post.content] },
139
+ }),
140
+ );
141
+ ```
142
+
143
+ ```ts
144
+ // worker.ts
145
+ import { blogListingPages, blogPostPages } from "./pages/blog";
146
+
147
+ export default createSite(siteConfig, {
148
+ pages: [homePage, ...blogListingPages, ...blogPostPages],
149
+ });
150
+ ```
151
+
152
+ ## Bundled modules
153
+
154
+ ```ts
155
+ import { createBlog } from "@stackbox/cms/modules/blog";
156
+ ```
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ npm run build # compile the package
162
+ npm run typecheck # type-check without emitting
163
+ npm test # build, then run the test suite
164
+ ```
165
+
166
+ ## License
167
+
168
+ BSD-3-Clause
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@stackbox/cms",
3
+ "version": "0.0.2",
4
+ "description": "Stackbox CMS engine",
5
+ "type": "module",
6
+ "license": "BSD-3-Clause",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "homepage": "https://github.com/stackboxcms/cms",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/stackboxcms/cms.git"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./modules/blog": {
21
+ "types": "./dist/modules/blog/module.d.ts",
22
+ "import": "./dist/modules/blog/module.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "test": "npm run build && node --import tsx --test test/*.test.ts",
28
+ "typecheck": "tsc --noEmit -p tsconfig.json",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "dependencies": {
32
+ "@hyperspan/html": "^1.0.3",
33
+ "marked": "^15.0.12",
34
+ "tsx": "^4.19.4",
35
+ "zod": "^4.4.3"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.15.21",
39
+ "typescript": "^5.8.3"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ export {
2
+ createModule,
3
+ isModule,
4
+ type Module,
5
+ type ModuleFactory,
6
+ type ModuleOptionsOf,
7
+ type ModuleRenderResult,
8
+ } from "./modules.js";
9
+ export {
10
+ createPage,
11
+ isPage,
12
+ PageValidationError,
13
+ toPageRenderView,
14
+ validateSlotContentItem,
15
+ type Page,
16
+ type PageMeta,
17
+ type PageRenderView,
18
+ type RenderContext,
19
+ type SitePage,
20
+ } from "./pages.js";
21
+ export { renderStandardHead } from "./render-head.js";
22
+ export { renderPage, RenderError } from "./render-page.js";
23
+ export { matchPagePath, normalizePathname } from "./routing.js";
24
+ export {
25
+ createSite,
26
+ createSiteConfig,
27
+ isSite,
28
+ isSiteConfig,
29
+ SiteError,
30
+ type Site,
31
+ type SiteConfig,
32
+ } from "./site.js";
33
+ export { createContext, Stackbox } from "./stackbox/context.js";
34
+ export {
35
+ anySlotContentSchema,
36
+ moduleSlotContentSchema,
37
+ slotHasContent,
38
+ SlotContentValidationError,
39
+ stringSlotContentSchema,
40
+ type DefaultSlotContent,
41
+ type PageSlotsInput,
42
+ type SlotContentFromDefinition,
43
+ } from "./slot-content.js";
44
+ export {
45
+ buildPageSlots,
46
+ buildStubSlots,
47
+ renderSlotContent,
48
+ slotSentinel,
49
+ SLOT_SENTINEL_PREFIX,
50
+ type Slot,
51
+ type SlotRenderValue,
52
+ type TemplateSlotsFrom,
53
+ } from "./slot-handle.js";
54
+ export {
55
+ createTemplate,
56
+ isTemplate,
57
+ TemplateBuildError,
58
+ type RequiredSlotNamesFrom,
59
+ type SlotDefinition,
60
+ type SlotMeta,
61
+ type SlotNamesFrom,
62
+ type SlotOptions,
63
+ type TemplateDescriptor,
64
+ type TemplateRenderContext,
65
+ type TemplateRenderFn,
66
+ } from "./templates.js";
@@ -0,0 +1,86 @@
1
+ import type { PageMeta } from "../../pages.js";
2
+ import type { DefaultSlotContent } from "../../slot-content.js";
3
+ import { loadPosts, type BlogOptions, type BlogPost } from "./posts.js";
4
+
5
+ export type { BlogPost, BlogOptions } from "./posts.js";
6
+
7
+ export type BlogPostContent = {
8
+ path: string;
9
+ title: string;
10
+ meta?: PageMeta;
11
+ content: DefaultSlotContent[];
12
+ };
13
+
14
+ export type BlogListingContent = {
15
+ path: string;
16
+ content: DefaultSlotContent[];
17
+ };
18
+
19
+ export type Blog = {
20
+ readonly posts: readonly BlogPostContent[];
21
+ readonly listings: readonly BlogListingContent[];
22
+ };
23
+
24
+ function postToContent(post: BlogPost): BlogPostContent {
25
+ return {
26
+ path: post.path,
27
+ title: post.title,
28
+ meta: post.description ? { description: post.description } : undefined,
29
+ content: [
30
+ `<article data-content-type="article" data-slug="${post.slug}">
31
+ <h1>${post.title}</h1>
32
+ ${post.bodyHtml}
33
+ </article>`,
34
+ ],
35
+ };
36
+ }
37
+
38
+ function listingPath(pathPrefix: string, page: number): string {
39
+ return page === 1 ? pathPrefix : `${pathPrefix}/page/${page}`;
40
+ }
41
+
42
+ function buildPostListHtml(posts: BlogPost[]): string {
43
+ return `<ul>${posts
44
+ .map((post) => `<li><a href="${post.path}">${post.title}</a></li>`)
45
+ .join("")}</ul>`;
46
+ }
47
+
48
+ function buildListingPages(
49
+ posts: BlogPost[],
50
+ pathPrefix: string,
51
+ postsPerPage?: number,
52
+ ): BlogListingContent[] {
53
+ if (posts.length === 0) {
54
+ return [
55
+ {
56
+ path: pathPrefix,
57
+ content: ["<p>No blog posts found.</p>"],
58
+ },
59
+ ];
60
+ }
61
+
62
+ const perPage =
63
+ postsPerPage && postsPerPage > 0 ? postsPerPage : posts.length;
64
+ const pages: BlogListingContent[] = [];
65
+
66
+ for (let i = 0; i < posts.length; i += perPage) {
67
+ const chunk = posts.slice(i, i + perPage);
68
+ const page = i / perPage + 1;
69
+ pages.push({
70
+ path: listingPath(pathPrefix, page),
71
+ content: [buildPostListHtml(chunk)],
72
+ });
73
+ }
74
+
75
+ return pages;
76
+ }
77
+
78
+ export function createBlog(options: BlogOptions): Blog {
79
+ const loaded = loadPosts(options);
80
+ const pathPrefix = options.pathPrefix.replace(/\/+$/, "") || "/";
81
+
82
+ return {
83
+ posts: loaded.map(postToContent),
84
+ listings: buildListingPages(loaded, pathPrefix, options.postsPerPage),
85
+ };
86
+ }
@@ -0,0 +1,69 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { basename, extname, join, resolve } from "node:path";
3
+ import { marked } from "marked";
4
+
5
+ export type BlogPost = {
6
+ slug: string;
7
+ title: string;
8
+ description?: string;
9
+ path: string;
10
+ bodyHtml: string;
11
+ };
12
+
13
+ export type BlogOptions = {
14
+ contentPath: string;
15
+ pathPrefix: string;
16
+ /** Max posts per listing page. Omit to put all posts on one page. */
17
+ postsPerPage?: number;
18
+ };
19
+
20
+ function parseFrontmatter(raw: string): {
21
+ meta: Record<string, string>;
22
+ body: string;
23
+ } {
24
+ if (!raw.startsWith("---")) {
25
+ return { meta: {}, body: raw };
26
+ }
27
+
28
+ const end = raw.indexOf("\n---", 3);
29
+ if (end === -1) {
30
+ return { meta: {}, body: raw };
31
+ }
32
+
33
+ const frontmatter = raw.slice(3, end).trim();
34
+ const body = raw.slice(end + 4).replace(/^\n/, "");
35
+ const meta: Record<string, string> = {};
36
+
37
+ for (const line of frontmatter.split("\n")) {
38
+ const colon = line.indexOf(":");
39
+ if (colon === -1) continue;
40
+ const key = line.slice(0, colon).trim();
41
+ const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, "");
42
+ if (key) meta[key] = value;
43
+ }
44
+
45
+ return { meta, body };
46
+ }
47
+
48
+ export function loadPosts(options: BlogOptions): BlogPost[] {
49
+ const absoluteDir = resolve(options.contentPath);
50
+ const files = readdirSync(absoluteDir)
51
+ .filter((name) => extname(name) === ".md")
52
+ .sort();
53
+
54
+ return files.map((filename) => {
55
+ const raw = readFileSync(join(absoluteDir, filename), "utf8");
56
+ const { meta, body } = parseFrontmatter(raw);
57
+ const slug = meta.slug ?? basename(filename, ".md");
58
+ const title = meta.title ?? slug;
59
+ const prefix = options.pathPrefix.replace(/\/+$/, "");
60
+
61
+ return {
62
+ slug,
63
+ title,
64
+ description: meta.description,
65
+ path: `${prefix}/${slug}`,
66
+ bodyHtml: marked.parse(body) as string,
67
+ };
68
+ });
69
+ }
package/src/modules.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { RenderContext } from "./pages.js";
2
+ import type { HSHtml } from "@hyperspan/html";
3
+
4
+ export type ModuleRenderResult = HSHtml | Promise<HSHtml>;
5
+
6
+ export type Module = {
7
+ readonly __kind: "module";
8
+ readonly name: string;
9
+ render(ctx: RenderContext): ModuleRenderResult;
10
+ };
11
+
12
+ type ModuleDef<TOptions = undefined> = {
13
+ name: string;
14
+ render(options: TOptions, ctx?: RenderContext): ModuleRenderResult;
15
+ };
16
+
17
+ export type ModuleOptionsOf<F> = F extends (options?: infer O) => unknown
18
+ ? [O] extends [undefined]
19
+ ? undefined
20
+ : O
21
+ : never;
22
+
23
+ export type ModuleFactory<TOptions = undefined> = (
24
+ options?: TOptions,
25
+ ) => Module;
26
+
27
+ function validateModuleName(name: string): void {
28
+ if (!name || name.length === 0) {
29
+ throw new Error("createModule(def): name is required");
30
+ }
31
+ }
32
+
33
+ export function createModule<TOptions = undefined>(
34
+ def: ModuleDef<TOptions>,
35
+ ): ModuleFactory<TOptions> {
36
+ validateModuleName(def.name);
37
+
38
+ if (typeof def.render !== "function") {
39
+ throw new Error("createModule(def): render is required");
40
+ }
41
+
42
+ return ((options?: TOptions) => ({
43
+ __kind: "module" as const,
44
+ name: def.name,
45
+ render: (ctx: RenderContext) => def.render(options as TOptions, ctx),
46
+ })) as ModuleFactory<TOptions>;
47
+ }
48
+
49
+ export function isModule(value: unknown): value is Module {
50
+ return (
51
+ typeof value === "object" &&
52
+ value !== null &&
53
+ (value as Module).__kind === "module" &&
54
+ typeof (value as Module).name === "string" &&
55
+ (value as Module).name.length > 0 &&
56
+ typeof (value as Module).render === "function"
57
+ );
58
+ }