@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 +28 -0
- package/README.md +168 -0
- package/package.json +44 -0
- package/src/index.ts +66 -0
- package/src/modules/blog/module.ts +86 -0
- package/src/modules/blog/posts.ts +69 -0
- package/src/modules.ts +58 -0
- package/src/pages.ts +198 -0
- package/src/render-head.ts +32 -0
- package/src/render-page.ts +51 -0
- package/src/routing.ts +14 -0
- package/src/site.ts +153 -0
- package/src/slot-content.ts +77 -0
- package/src/slot-handle.ts +131 -0
- package/src/stackbox/context.ts +257 -0
- package/src/templates.ts +230 -0
- package/test/async-render.test.ts +103 -0
- package/test/blog.test.ts +93 -0
- package/test/fetch-routing.test.ts +76 -0
- package/test/stackbox-context.test.ts +44 -0
- package/tsconfig.json +19 -0
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
|
+
}
|