@stackbox/cms 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +8 -0
- package/AGENTS.md +87 -0
- package/README.md +58 -19
- package/package.json +13 -5
- package/scripts/copy-plugin-assets.mjs +30 -0
- package/src/blocks.ts +58 -0
- package/src/index.ts +9 -8
- package/src/link.ts +158 -0
- package/src/pages.ts +1 -1
- package/src/plugins/blog/AGENTS.md +131 -0
- package/src/plugins/random-quote/AGENTS.md +79 -0
- package/src/plugins/random-quote/index.ts +33 -0
- package/src/plugins/random-quote/public_assets/quotes.json +230 -0
- package/src/render-page.ts +1 -1
- package/src/site.ts +7 -0
- package/src/slot-content.ts +10 -10
- package/src/slot-handle.ts +3 -3
- package/test/async-render.test.ts +13 -13
- package/test/blog.test.ts +1 -1
- package/test/plugin-assets.test.ts +80 -0
- package/test/random-quote.test.ts +55 -0
- package/tsconfig.json +1 -0
- package/src/modules.ts +0 -58
- /package/src/{modules/blog/module.ts → plugins/blog/index.ts} +0 -0
- /package/src/{modules → plugins}/blog/posts.ts +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import {
|
|
6
|
+
getPluginPublicRootDir,
|
|
7
|
+
parsePluginAssetRequest,
|
|
8
|
+
pluginAssetPath,
|
|
9
|
+
servePluginAsset,
|
|
10
|
+
} from "../src/link.js";
|
|
11
|
+
import { createSite, createSiteConfig } from "../src/site.js";
|
|
12
|
+
import { createPage } from "../src/pages.js";
|
|
13
|
+
import { createTemplate } from "../src/templates.js";
|
|
14
|
+
import { html } from "@hyperspan/html";
|
|
15
|
+
|
|
16
|
+
describe("pluginAssetPath", () => {
|
|
17
|
+
it("builds paths under /_sb/plugins", () => {
|
|
18
|
+
assert.strictEqual(
|
|
19
|
+
pluginAssetPath("random-quote", "photo.jpg"),
|
|
20
|
+
"/_sb/plugins/random-quote/photo.jpg",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects unsafe relative segments", () => {
|
|
25
|
+
assert.throws(() => pluginAssetPath("random-quote", "../secret.txt"));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("plugin public assets", () => {
|
|
30
|
+
it("copies random-quote quotes.json to dist/public/_sb", () => {
|
|
31
|
+
const file = join(
|
|
32
|
+
getPluginPublicRootDir(),
|
|
33
|
+
"random-quote",
|
|
34
|
+
"quotes.json",
|
|
35
|
+
);
|
|
36
|
+
assert.ok(existsSync(file));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parsePluginAssetRequest rejects traversal", () => {
|
|
40
|
+
assert.strictEqual(
|
|
41
|
+
parsePluginAssetRequest("/_sb/plugins/random-quote/../quotes.json"),
|
|
42
|
+
null,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("servePluginAsset returns JSON for bundled quotes", async () => {
|
|
47
|
+
const res = servePluginAsset(
|
|
48
|
+
"/_sb/plugins/random-quote/quotes.json",
|
|
49
|
+
"GET",
|
|
50
|
+
);
|
|
51
|
+
assert.ok(res);
|
|
52
|
+
assert.strictEqual(res!.status, 200);
|
|
53
|
+
assert.match(res!.headers.get("content-type") ?? "", /json/);
|
|
54
|
+
const body = await res!.text();
|
|
55
|
+
assert.match(body, /Rumi/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("site.fetch serves plugin assets before page routes", async () => {
|
|
59
|
+
const siteConfig = createSiteConfig({ name: "Asset Test" });
|
|
60
|
+
const template = createTemplate({
|
|
61
|
+
siteConfig,
|
|
62
|
+
slots: [{ name: "content", options: { required: true, primary: true } }],
|
|
63
|
+
render({ slots }: { slots: { content: { render: () => unknown } } }) {
|
|
64
|
+
return html`<body>${slots.content.render()}</body>`;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const homePage = createPage(template, {
|
|
68
|
+
path: "/",
|
|
69
|
+
title: "Home",
|
|
70
|
+
slots: { content: ["<p>home</p>"] },
|
|
71
|
+
});
|
|
72
|
+
const site = createSite(siteConfig, { pages: [homePage] });
|
|
73
|
+
|
|
74
|
+
const res = await site.fetch(
|
|
75
|
+
new Request("https://example.com/_sb/plugins/random-quote/quotes.json"),
|
|
76
|
+
);
|
|
77
|
+
assert.strictEqual(res.status, 200);
|
|
78
|
+
assert.match(await res.text(), /Rumi/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { renderAsync } from "@hyperspan/html";
|
|
4
|
+
import { createSiteConfig } from "../src/site.js";
|
|
5
|
+
import {
|
|
6
|
+
defaultQuotes,
|
|
7
|
+
pickRandomQuote,
|
|
8
|
+
randomQuoteBlock,
|
|
9
|
+
type Quote,
|
|
10
|
+
} from "../src/plugins/random-quote/index.js";
|
|
11
|
+
|
|
12
|
+
describe("random-quote plugin", () => {
|
|
13
|
+
it("ships bundled default quotes", () => {
|
|
14
|
+
assert.ok(defaultQuotes.length >= 50);
|
|
15
|
+
assert.strictEqual(defaultQuotes[0]!.author, "Rumi");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("pickRandomQuote returns a quote from the given list", () => {
|
|
19
|
+
const custom: Quote[] = [
|
|
20
|
+
{ text: "Custom quote.", author: "Test Author" },
|
|
21
|
+
];
|
|
22
|
+
const quote = pickRandomQuote(custom);
|
|
23
|
+
assert.strictEqual(quote.author, "Test Author");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("randomQuoteBlock uses bundled quotes by default", async () => {
|
|
27
|
+
const siteConfig = createSiteConfig({ name: "Test" });
|
|
28
|
+
const originalRandom = Math.random;
|
|
29
|
+
Math.random = () => 0;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const block = randomQuoteBlock();
|
|
33
|
+
const htmlOut = await renderAsync(block.render({ siteConfig }));
|
|
34
|
+
|
|
35
|
+
assert.match(htmlOut, /data-block="random-quote"/);
|
|
36
|
+
assert.match(htmlOut, /<blockquote/);
|
|
37
|
+
assert.match(htmlOut, /beat 40 scholars/);
|
|
38
|
+
assert.match(htmlOut, /— Rumi/);
|
|
39
|
+
} finally {
|
|
40
|
+
Math.random = originalRandom;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("randomQuoteBlock accepts custom quotes", async () => {
|
|
45
|
+
const siteConfig = createSiteConfig({ name: "Test" });
|
|
46
|
+
const custom: Quote[] = [
|
|
47
|
+
{ text: "Ship your own quotes.", author: "Site Owner" },
|
|
48
|
+
];
|
|
49
|
+
const block = randomQuoteBlock({ quotes: custom });
|
|
50
|
+
const htmlOut = await renderAsync(block.render({ siteConfig }));
|
|
51
|
+
|
|
52
|
+
assert.match(htmlOut, /Ship your own quotes/);
|
|
53
|
+
assert.match(htmlOut, /— Site Owner/);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/tsconfig.json
CHANGED
package/src/modules.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|