@stackbox/cms 0.0.4 → 0.1.0

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 (122) hide show
  1. package/README.md +119 -7
  2. package/dist/AGENTS.md +215 -0
  3. package/dist/blocks.d.ts +11 -0
  4. package/dist/blocks.d.ts.map +1 -0
  5. package/dist/blocks.js +27 -0
  6. package/dist/blocks.js.map +1 -0
  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 +16 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +15 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/link.d.ts +6 -0
  24. package/dist/link.d.ts.map +1 -0
  25. package/dist/link.js +121 -0
  26. package/dist/link.js.map +1 -0
  27. package/dist/modules.d.ts +18 -0
  28. package/dist/modules.d.ts.map +1 -0
  29. package/dist/modules.js +25 -0
  30. package/dist/modules.js.map +1 -0
  31. package/dist/pages.d.ts +24 -0
  32. package/dist/pages.d.ts.map +1 -0
  33. package/dist/pages.js +87 -0
  34. package/dist/pages.js.map +1 -0
  35. package/dist/plugin.d.ts +22 -0
  36. package/dist/plugin.d.ts.map +1 -0
  37. package/dist/plugin.js +155 -0
  38. package/dist/plugin.js.map +1 -0
  39. package/dist/plugins/blog/index.d.ts +21 -0
  40. package/dist/plugins/blog/index.d.ts.map +1 -0
  41. package/dist/plugins/blog/index.js +68 -0
  42. package/dist/plugins/blog/index.js.map +1 -0
  43. package/dist/plugins/blog/posts.d.ts +15 -0
  44. package/dist/plugins/blog/posts.d.ts.map +1 -0
  45. package/dist/plugins/blog/posts.js +46 -0
  46. package/dist/plugins/blog/posts.js.map +1 -0
  47. package/dist/plugins/random-quote/assets/quotes.json +230 -0
  48. package/dist/plugins/random-quote/index.d.ts +14 -0
  49. package/dist/plugins/random-quote/index.d.ts.map +1 -0
  50. package/dist/plugins/random-quote/index.js +35 -0
  51. package/dist/plugins/random-quote/index.js.map +1 -0
  52. package/dist/plugins/sitemap/index.d.ts +14 -0
  53. package/dist/plugins/sitemap/index.d.ts.map +1 -0
  54. package/dist/plugins/sitemap/index.js +119 -0
  55. package/dist/plugins/sitemap/index.js.map +1 -0
  56. package/dist/render-head.d.ts +7 -0
  57. package/dist/render-head.d.ts.map +1 -0
  58. package/dist/render-head.js +27 -0
  59. package/dist/render-head.js.map +1 -0
  60. package/dist/render-page.d.ts +4 -0
  61. package/dist/render-page.d.ts.map +1 -0
  62. package/dist/render-page.js +34 -0
  63. package/dist/render-page.js.map +1 -0
  64. package/dist/routing.d.ts +3 -0
  65. package/dist/routing.d.ts.map +1 -0
  66. package/dist/routing.js +11 -0
  67. package/dist/routing.js.map +1 -0
  68. package/dist/site.d.ts +14 -0
  69. package/dist/site.d.ts.map +1 -0
  70. package/dist/site.js +233 -0
  71. package/dist/site.js.map +1 -0
  72. package/dist/slot-content.d.ts +14 -0
  73. package/dist/slot-content.d.ts.map +1 -0
  74. package/dist/slot-content.js +34 -0
  75. package/dist/slot-content.js.map +1 -0
  76. package/dist/slot-handle.d.ts +14 -0
  77. package/dist/slot-handle.d.ts.map +1 -0
  78. package/dist/slot-handle.js +83 -0
  79. package/dist/slot-handle.js.map +1 -0
  80. package/dist/stackbox/context.d.ts +3 -0
  81. package/dist/stackbox/context.d.ts.map +1 -0
  82. package/dist/stackbox/context.js +165 -0
  83. package/dist/stackbox/context.js.map +1 -0
  84. package/dist/templates.d.ts +13 -0
  85. package/dist/templates.d.ts.map +1 -0
  86. package/dist/templates.js +106 -0
  87. package/dist/templates.js.map +1 -0
  88. package/dist/types.d.ts +277 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/types.js +2 -0
  91. package/dist/types.js.map +1 -0
  92. package/package.json +18 -3
  93. package/src/plugins/blog/AGENTS.md +5 -1
  94. package/src/plugins/blog/index.ts +22 -5
  95. package/src/plugins/random-quote/AGENTS.md +30 -7
  96. package/src/plugins/random-quote/index.ts +17 -1
  97. package/src/plugins/random-quote/public_assets/widget.css +5 -0
  98. package/src/plugins/sitemap/AGENTS.md +82 -0
  99. package/src/plugins/sitemap/index.ts +156 -0
  100. package/.claude/settings.local.json +0 -8
  101. package/AGENTS.md +0 -87
  102. package/scripts/copy-plugin-assets.mjs +0 -30
  103. package/src/blocks.ts +0 -58
  104. package/src/index.ts +0 -67
  105. package/src/link.ts +0 -158
  106. package/src/pages.ts +0 -198
  107. package/src/render-head.ts +0 -32
  108. package/src/render-page.ts +0 -51
  109. package/src/routing.ts +0 -14
  110. package/src/site.ts +0 -160
  111. package/src/slot-content.ts +0 -77
  112. package/src/slot-handle.ts +0 -131
  113. package/src/stackbox/context.ts +0 -257
  114. package/src/templates.ts +0 -230
  115. package/test/async-render.test.ts +0 -103
  116. package/test/blog.test.ts +0 -93
  117. package/test/fetch-routing.test.ts +0 -76
  118. package/test/plugin-assets.test.ts +0 -80
  119. package/test/random-quote.test.ts +0 -55
  120. package/test/stackbox-context.test.ts +0 -44
  121. package/tsconfig.json +0 -20
  122. /package/src/plugins/random-quote/{public_assets → assets}/quotes.json +0 -0
@@ -0,0 +1,156 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ createPlugin,
5
+ } from "../../plugin.js";
6
+ import { normalizePathname } from "../../routing.js";
7
+ import type { Stackbox as SB } from "../../types.js";
8
+
9
+ export type SitemapOptions = {
10
+ /** Site origin. Defaults to `siteConfig.config.url`. */
11
+ baseUrl?: string;
12
+ /** Extra site-relative or absolute URLs. */
13
+ extraUrls?: readonly string[];
14
+ exclude?: readonly string[] | ((path: string) => boolean);
15
+ };
16
+
17
+ function resolveBaseUrl(site: SB.Site, options: SitemapOptions): string {
18
+ const raw = options.baseUrl ?? site.siteConfig.config.url;
19
+ if (typeof raw !== "string" || raw.trim().length === 0) {
20
+ throw new Error("sb-sitemap: baseUrl or siteConfig.url is required");
21
+ }
22
+ const url = raw.trim().replace(/\/+$/, "");
23
+ try {
24
+ new URL(url);
25
+ } catch {
26
+ throw new Error(`sb-sitemap: invalid base URL "${raw}"`);
27
+ }
28
+ return url;
29
+ }
30
+
31
+ function isExcluded(path: string, options: SitemapOptions): boolean {
32
+ const normalized = normalizePathname(path);
33
+ if (!options.exclude) {
34
+ return false;
35
+ }
36
+ if (typeof options.exclude === "function") {
37
+ return options.exclude(normalized);
38
+ }
39
+ return options.exclude.some(
40
+ (entry) => normalizePathname(entry) === normalized,
41
+ );
42
+ }
43
+
44
+ function isNoindexPage(page: { meta?: { robots?: string } }): boolean {
45
+ const robots = page.meta?.robots?.toLowerCase() ?? "";
46
+ return robots.includes("noindex");
47
+ }
48
+
49
+ export function collectSitemapUrls(
50
+ site: SB.Site,
51
+ options: SitemapOptions = {},
52
+ ): string[] {
53
+ const base = resolveBaseUrl(site, options);
54
+ const locs = new Set<string>();
55
+
56
+ for (const page of site.pages) {
57
+ if (isNoindexPage(page)) {
58
+ continue;
59
+ }
60
+ if (isExcluded(page.path, options)) {
61
+ continue;
62
+ }
63
+ locs.add(`${base}${normalizePathname(page.path)}`);
64
+ }
65
+
66
+ for (const extra of options.extraUrls ?? []) {
67
+ if (extra.startsWith("http://") || extra.startsWith("https://")) {
68
+ if (!isExcluded(extra, options)) {
69
+ locs.add(extra);
70
+ }
71
+ continue;
72
+ }
73
+ const path = normalizePathname(extra);
74
+ if (!isExcluded(path, options)) {
75
+ locs.add(`${base}${path}`);
76
+ }
77
+ }
78
+
79
+ return [...locs].sort();
80
+ }
81
+
82
+ function escapeXml(text: string): string {
83
+ return text
84
+ .replace(/&/g, "&amp;")
85
+ .replace(/</g, "&lt;")
86
+ .replace(/>/g, "&gt;")
87
+ .replace(/"/g, "&quot;")
88
+ .replace(/'/g, "&apos;");
89
+ }
90
+
91
+ export function renderSitemapXml(
92
+ site: SB.Site,
93
+ options: SitemapOptions = {},
94
+ ): string {
95
+ const urls = collectSitemapUrls(site, options);
96
+ const body = urls
97
+ .map((loc) => ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`)
98
+ .join("\n");
99
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
100
+ }
101
+
102
+ function sitemapResponse(
103
+ request: globalThis.Request,
104
+ xml: string,
105
+ ): globalThis.Response {
106
+ const headers = { "content-type": "application/xml; charset=utf-8" };
107
+ if (request.method === "HEAD") {
108
+ const bytes = new TextEncoder().encode(xml);
109
+ return new globalThis.Response(null, {
110
+ status: 200,
111
+ headers: { ...headers, "content-length": String(bytes.byteLength) },
112
+ });
113
+ }
114
+ return new globalThis.Response(xml, { status: 200, headers });
115
+ }
116
+
117
+ function createSitemapRoutes(
118
+ ctx: SB.PluginRouteContext,
119
+ options: SitemapOptions,
120
+ ) {
121
+ return [
122
+ {
123
+ path: "/sitemap.xml",
124
+ fetch(request: globalThis.Request) {
125
+ const xml = renderSitemapXml(ctx.site, options);
126
+ return sitemapResponse(request, xml);
127
+ },
128
+ },
129
+ ] as const;
130
+ }
131
+
132
+ export function createSitemap(options: SitemapOptions = {}): SB.Plugin {
133
+ return createPlugin({
134
+ name: "sb-sitemap",
135
+ description:
136
+ "Generate sitemap.xml from site pages for search engines and static deploys.",
137
+ version: "1.0.0",
138
+ keywords: [
139
+ "sitemap",
140
+ "sitemap.xml",
141
+ "seo",
142
+ "search engines",
143
+ "xml sitemap",
144
+ ],
145
+ root: import.meta.dirname,
146
+ routes(ctx) {
147
+ return createSitemapRoutes(ctx, options);
148
+ },
149
+ build(ctx) {
150
+ const xml = renderSitemapXml(ctx.site, options);
151
+ writeFileSync(join(ctx.publicDir, "sitemap.xml"), xml, "utf8");
152
+ },
153
+ });
154
+ }
155
+
156
+ export default createSitemap();
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node -e \"import\\('@hyperspan/html'\\).then\\(m => console.log\\('html export:', typeof m.html\\)\\)\")",
5
- "Bash(npm run *)"
6
- ]
7
- }
8
- }
package/AGENTS.md DELETED
@@ -1,87 +0,0 @@
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`).
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,58 +0,0 @@
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 DELETED
@@ -1,67 +0,0 @@
1
- export { html } from "@hyperspan/html";
2
- export {
3
- createBlock,
4
- isBlock,
5
- type Block,
6
- type BlockFactory,
7
- type BlockOptionsOf,
8
- type BlockRenderResult,
9
- } from "./blocks.js";
10
- export {
11
- createPage,
12
- isPage,
13
- PageValidationError,
14
- toPageRenderView,
15
- validateSlotContentItem,
16
- type Page,
17
- type PageMeta,
18
- type PageRenderView,
19
- type RenderContext,
20
- type SitePage,
21
- } from "./pages.js";
22
- export { renderStandardHead } from "./render-head.js";
23
- export { renderPage, RenderError } from "./render-page.js";
24
- export { matchPagePath, normalizePathname } from "./routing.js";
25
- export {
26
- createSite,
27
- createSiteConfig,
28
- isSite,
29
- isSiteConfig,
30
- SiteError,
31
- type Site,
32
- type SiteConfig,
33
- } from "./site.js";
34
- export { createContext, Stackbox } from "./stackbox/context.js";
35
- export {
36
- anySlotContentSchema,
37
- blockSlotContentSchema,
38
- slotHasContent,
39
- SlotContentValidationError,
40
- stringSlotContentSchema,
41
- type DefaultSlotContent,
42
- type PageSlotsInput,
43
- type SlotContentFromDefinition,
44
- } from "./slot-content.js";
45
- export {
46
- buildPageSlots,
47
- buildStubSlots,
48
- renderSlotContent,
49
- slotSentinel,
50
- SLOT_SENTINEL_PREFIX,
51
- type Slot,
52
- type SlotRenderValue,
53
- type TemplateSlotsFrom,
54
- } from "./slot-handle.js";
55
- export {
56
- createTemplate,
57
- isTemplate,
58
- TemplateBuildError,
59
- type RequiredSlotNamesFrom,
60
- type SlotDefinition,
61
- type SlotMeta,
62
- type SlotNamesFrom,
63
- type SlotOptions,
64
- type TemplateDescriptor,
65
- type TemplateRenderContext,
66
- type TemplateRenderFn,
67
- } from "./templates.js";
package/src/link.ts DELETED
@@ -1,158 +0,0 @@
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
- }