@stackbox/cms 0.0.3 → 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/AGENTS.md +87 -0
- package/README.md +57 -17
- package/package.json +13 -5
- package/scripts/copy-plugin-assets.mjs +30 -0
- package/src/blocks.ts +58 -0
- package/src/index.ts +8 -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,79 @@
|
|
|
1
|
+
# Random quote plugin — agent playbook
|
|
2
|
+
|
|
3
|
+
## Use when
|
|
4
|
+
|
|
5
|
+
The user asks for any of:
|
|
6
|
+
|
|
7
|
+
- random quote, quote of the day, inspirational quote
|
|
8
|
+
- sidebar quote, footer quote, block that shows a quote
|
|
9
|
+
- embed a rotating or random quotation on a page
|
|
10
|
+
|
|
11
|
+
This plugin is a **block-only** example — it exports no page factory or content objects. Drop the block into any slot.
|
|
12
|
+
|
|
13
|
+
## Do not
|
|
14
|
+
|
|
15
|
+
- Hand-roll quote rotation logic — use `randomQuoteBlock()` from this plugin
|
|
16
|
+
- Build a full quote page unless the user explicitly asks — this plugin ships a **block**, not routes
|
|
17
|
+
|
|
18
|
+
## Exports
|
|
19
|
+
|
|
20
|
+
- `randomQuoteBlock(options?)` — block factory; `{ quotes }` overrides bundled defaults
|
|
21
|
+
- `defaultQuotes` — bundled quote array (imported from `public_assets/quotes.json`)
|
|
22
|
+
- `pickRandomQuote(quotes)` — picks one quote at random
|
|
23
|
+
- `Quote` — type `{ text, author }`
|
|
24
|
+
- `RandomQuoteBlockOptions` — type `{ quotes?: readonly Quote[] }`
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { randomQuoteBlock } from "@stackbox/cms/plugins/random-quote";
|
|
30
|
+
|
|
31
|
+
// Bundled quotes (default):
|
|
32
|
+
slots: { sidebar: [randomQuoteBlock()] }
|
|
33
|
+
|
|
34
|
+
// Custom quotes from the user's site:
|
|
35
|
+
import myQuotes from "../content/quotes.json" with { type: "json" };
|
|
36
|
+
|
|
37
|
+
slots: { sidebar: [randomQuoteBlock({ quotes: myQuotes })] }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Custom JSON must be an array of `{ text, author }` objects — same shape as bundled `public_assets/quotes.json`.
|
|
41
|
+
|
|
42
|
+
Bundled quotes are also served as a public asset at `/_sb/plugins/random-quote/quotes.json` (via `pluginAssetPath("random-quote", "quotes.json")` from `@stackbox/cms/link`).
|
|
43
|
+
|
|
44
|
+
Each request renders a randomly selected quote from the configured list.
|
|
45
|
+
|
|
46
|
+
## Public assets
|
|
47
|
+
|
|
48
|
+
Bundled quotes live in `public_assets/quotes.json`. The build copies this folder to `dist/public/_sb/plugins/random-quote/` automatically — do not edit the copy script.
|
|
49
|
+
|
|
50
|
+
## Files to create or update
|
|
51
|
+
|
|
52
|
+
| File | Action |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| Any page using the block | Import `randomQuoteBlock` and add to a slot array |
|
|
55
|
+
| `content/quotes.json` | Optional — user's own quotes when not using bundled defaults |
|
|
56
|
+
| `templates/site-template.ts` | Ensure the target slot exists (e.g. `sidebar`, `content`) |
|
|
57
|
+
|
|
58
|
+
No `pages/*.ts` or `server.ts` changes required unless adding the block to a new page.
|
|
59
|
+
|
|
60
|
+
## Wiring example
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// pages/home.ts
|
|
64
|
+
import { createPage } from "@stackbox/cms";
|
|
65
|
+
import { randomQuoteBlock } from "@stackbox/cms/plugins/random-quote";
|
|
66
|
+
import myQuotes from "../content/quotes.json" with { type: "json" };
|
|
67
|
+
import { siteTemplate } from "../templates/site-template";
|
|
68
|
+
|
|
69
|
+
export const homePage = createPage(siteTemplate, {
|
|
70
|
+
path: "/",
|
|
71
|
+
title: "Home",
|
|
72
|
+
slots: {
|
|
73
|
+
content: ["<p>Welcome.</p>"],
|
|
74
|
+
sidebar: [randomQuoteBlock({ quotes: myQuotes })],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Use `randomQuoteBlock()` without options to keep bundled defaults. Combine with other blocks or HTML strings in the same slot.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { html } from "@hyperspan/html";
|
|
2
|
+
import { createBlock } from "../../blocks.js";
|
|
3
|
+
import bundledQuotes from "./public_assets/quotes.json" with { type: "json" };
|
|
4
|
+
|
|
5
|
+
export type Quote = {
|
|
6
|
+
text: string;
|
|
7
|
+
author: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RandomQuoteBlockOptions = {
|
|
11
|
+
/** Custom quotes. Omit to use bundled defaults. */
|
|
12
|
+
quotes?: readonly Quote[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const defaultQuotes: readonly Quote[] = bundledQuotes;
|
|
16
|
+
|
|
17
|
+
export function pickRandomQuote(quotes: readonly Quote[]): Quote {
|
|
18
|
+
if (quotes.length === 0) {
|
|
19
|
+
throw new Error("random-quote: quotes must contain at least one quote");
|
|
20
|
+
}
|
|
21
|
+
return quotes[Math.floor(Math.random() * quotes.length)]!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const randomQuoteBlock = createBlock({
|
|
25
|
+
name: "random-quote",
|
|
26
|
+
render(options?: RandomQuoteBlockOptions) {
|
|
27
|
+
const quote = pickRandomQuote(options?.quotes ?? defaultQuotes);
|
|
28
|
+
return html`<blockquote data-block="random-quote" cite="${quote.author}">
|
|
29
|
+
<p>${quote.text}</p>
|
|
30
|
+
<footer>— ${quote.author}</footer>
|
|
31
|
+
</blockquote>`;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"text": "You can beat 40 scholars with one fact, but you can't beat one idiot with 40 facts.",
|
|
4
|
+
"author": "Rumi"
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"text": "You will observe with concern how long a useful truth may be known and exist before it is generally recieved and practices on",
|
|
8
|
+
"author": "Benjamin Franklin"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"text": "Life can only be understood backwards; but it must be lived forwards.",
|
|
12
|
+
"author": "Søren Kierkegaard"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"text": "We are what we repeatedly do. Excellence, then, is not an act, but a habit.",
|
|
16
|
+
"author": "Will Durant"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"text": "Time has a wonderful way of showing us what really matters.",
|
|
20
|
+
"author": "Margaret Peters"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"text": "When a person can't find a deep sense of meaning, they distract themselves with pleasure.",
|
|
24
|
+
"author": "Viktor Frankl"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"text": "To attain knowledge, add things everyday. To attain wisdom, remove things every day.",
|
|
28
|
+
"author": "Lao Tzu"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"text": "At the end of the day, we can endure much more than we think we can.",
|
|
32
|
+
"author": "Frida Kahlo"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"text": "People rarely succeed unless they have fun in what they are doing.",
|
|
36
|
+
"author": "Dale Carnegie"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"text": "If you don't design your own life plan, chances are you'll fall into someone else's plan.",
|
|
40
|
+
"author": "Jim Rohn"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"text": "Not everything that is faced can be changed, but nothing can be changed until it is faced.",
|
|
44
|
+
"author": "James Baldwin"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"text": "The surface of the Earth is the shore of the cosmic ocean.",
|
|
48
|
+
"author": "Carl Sagan"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"text": "I do not feel obliged to believe that that same God who has endowed us with senses, reason, and intellect has intended to forgo their use",
|
|
52
|
+
"author": "Galileo Galilei"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"text": "In science the authority embodied in the opinion of thousands is not worth a spark of reason in one man.",
|
|
56
|
+
"author": "Galileo Galilei"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"text": "Mathematics is the language in which God wrote the universe.",
|
|
60
|
+
"author": "Galileo Galilei"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"text": "Ignorance is preferable to error; and he is less remote from the truth who believes nothing, than he who believes what is wrong.",
|
|
64
|
+
"author": "Thomas Jefferson"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"text": "The essence of Government is power; and power, lodged as it must be in human hands, will ever be liable to abuse.",
|
|
68
|
+
"author": "James Madison"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"text": "A workman who wants to do his work well must first prepare his tools.",
|
|
72
|
+
"author": "Confucius"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"text": "It is only the very wisest and the very stupidest who cannot change.",
|
|
76
|
+
"author": "Confucius"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"text": "Our wretched species is so made that those who walk on the well-trodden path always throw stones at those who are showing a new road.",
|
|
80
|
+
"author": "Voltaire"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"text": "I have always thought the actions of men the best interpreters of their thoughts.",
|
|
84
|
+
"author": "John Locke"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"text": "There are two tragedies in life. One is not to get your heart's desire. The other is to get it.",
|
|
88
|
+
"author": "George Bernard Shaw"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"text": "Marriage is the only adventure open to the cowardly.",
|
|
92
|
+
"author": "Voltaire"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"text": "Even the rich are hungry for love, for being cared for, for being wanted, for having someone to call their own.",
|
|
96
|
+
"author": "Mother Teresa"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"text": "Loneliness and the feeling of being unwanted is the most terrible poverty.",
|
|
100
|
+
"author": "Mother Teresa"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"text": "To know what life is worth you have to risk it once in a while.",
|
|
104
|
+
"author": "Jean-Paul Sartre"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"text": "Conformity is the jailer of freedom and the enemy of growth.",
|
|
108
|
+
"author": "John F. Kennedy"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"text": "Man is not born to solve the problem of the universe, but to find out what he has to do; and to restrain himself within the limits of his comprehension.",
|
|
112
|
+
"author": "Goethe"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"text": "Love does not dominate, it cultivates. And that is more.",
|
|
116
|
+
"author": "Goethe"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"text": "He alone is great and happy who fills his own station of independence, and has neither to command nor to obey.",
|
|
120
|
+
"author": "Goethe"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"text": "If you wish to advance into the infinite, explore the finite in all directions.",
|
|
124
|
+
"author": "Goethe"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"text": "One ought to hold on to one's heart; for if one lets it go, one soon loses control of the head too.",
|
|
128
|
+
"author": "Friedrich Nietzsche"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"text": "A faith is a necessity to man. Woe to him who believes nothing.",
|
|
132
|
+
"author": "Victor Hugo"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"text": "To live a life which is a perpetual falsehood is to suffer unknown tortures.",
|
|
136
|
+
"author": "Victor Hugo"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"text": "A man may be a fine genius, and yet understand nothing of an art which he has not studied.",
|
|
140
|
+
"author": "Victor Hugo"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"text": "How much more grievous are the consequences of anger than the causes of it.",
|
|
144
|
+
"author": "Marcus Aurelius"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"text": "You always own the option of having no opinion. There is never any need to get worked up or to trouble your soul about things you can't control. These things are not asking to be judged by you. Leave them alone.",
|
|
148
|
+
"author": "Marcus Aurelius"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"text": "It never ceases to amaze me: we all love ourselves more than other people, but care more about their opinions than our own.",
|
|
152
|
+
"author": "Marcus Aurelius"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"text": "External things are not the problem. It's your assessment of them. Which you can erase right now.",
|
|
156
|
+
"author": "Marcus Aurelius"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"text": "The man who reads nothing at all is more educated than the man who reads nothing but newspapers.",
|
|
160
|
+
"author": "Thomas Jefferson"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"text": "Nothing gives one person so much advantage over anohter as to remain always cool and unruffled under all circumstances.",
|
|
164
|
+
"author": "Thomas Jefferson"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"text": "Doubt is an uncomfortable condition, but certainty is a ridiculous one.",
|
|
168
|
+
"author": "Voltaire"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"text": "The longer we dwell on our misfortunes, the greater is their power to harm us.",
|
|
172
|
+
"author": "Voltaire"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"text": "Those who can make you believe absurdities, can make you commit atrocities.",
|
|
176
|
+
"author": "Voltaire"
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"text": "It is dangerous to be right in matters on which the established authorities are wrong.",
|
|
180
|
+
"author": "Voltaire"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"text": "One can stand anything except a succession of ordinary days.",
|
|
184
|
+
"author": "Goethe"
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"text": "Good times become good memories and bad times become good lessons in life.",
|
|
188
|
+
"author": "Unknown"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"text": "It's better to have a short life that is full of what you like doing, than a long life spent in a miserable way.",
|
|
192
|
+
"author": "Alan Watts"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"text": "To have faith is to trust yourself to the water. When you swim you don't grab hold of the water, because if you do you will sink and drown. Instead you relax, and float.",
|
|
196
|
+
"author": "Alan Watts"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"text": "I've been searching for years for the ideal place. And I've come to the realization that the only way to find it is to be it.",
|
|
200
|
+
"author": "Alan Watts"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"text": "I would rather be governed by the first 2,000 people in the Boston telephone directory than by the 2,000 people on the faculty of Harvard University.",
|
|
204
|
+
"author": "William F. Buckley Jr."
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"text": "Never confuse education with intelligence, you can have a PhD and still be an idiot.",
|
|
208
|
+
"author": "Richard Feynman"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"text": "The enemy is anybody who's going to get you killed, no matter which side he's on.",
|
|
212
|
+
"author": "Joseph Heller"
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"text": "People often say motivation doesn't last. Neither does bathing—that's why we recommend it daily.",
|
|
216
|
+
"author": "Zig Ziglar"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"text": "Live free ot die; death is not the worst of evils.",
|
|
220
|
+
"author": "General John Stark"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"text": "Equality only exists in theory. Social law can give all men equal rights. Nature will never give them equal faculties.",
|
|
224
|
+
"author": "Napoleon"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"text": "One who does not want to reason exists as a fanatic; one who ignores how to reason exists as a fool; and one who will not dare to reason exists as a slave.",
|
|
228
|
+
"author": "Carlos Drummond de Andrade"
|
|
229
|
+
}
|
|
230
|
+
]
|
package/src/render-page.ts
CHANGED
|
@@ -18,7 +18,7 @@ function validateRequiredSlots(page: SitePage): void {
|
|
|
18
18
|
const items = page.slots[required as keyof typeof page.slots];
|
|
19
19
|
if (!items || items.length === 0 || !slotHasContent(items)) {
|
|
20
20
|
throw new PageValidationError(
|
|
21
|
-
`required slot "${required}" must have at least one
|
|
21
|
+
`required slot "${required}" must have at least one block or non-empty HTML string`,
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/site.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SitePage } from "./pages.js";
|
|
2
2
|
import { isPage } from "./pages.js";
|
|
3
3
|
import { renderPage } from "./render-page.js";
|
|
4
|
+
import { servePluginAsset } from "./link.js";
|
|
4
5
|
import { normalizePathname } from "./routing.js";
|
|
5
6
|
import { createContext } from "./stackbox/context.js";
|
|
6
7
|
|
|
@@ -124,6 +125,12 @@ export function createSite<T extends Record<string, unknown>>(
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
const pathname = normalizePathname(ctx.req.url.pathname);
|
|
128
|
+
|
|
129
|
+
const pluginAsset = servePluginAsset(pathname, method);
|
|
130
|
+
if (pluginAsset) {
|
|
131
|
+
return pluginAsset;
|
|
132
|
+
}
|
|
133
|
+
|
|
127
134
|
const page = pageMap.get(pathname);
|
|
128
135
|
|
|
129
136
|
if (!page) {
|
package/src/slot-content.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import { z as zod } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { isBlock, type Block } from "./blocks.js";
|
|
4
4
|
import type { SlotDefinition, SlotNamesFrom } from "./templates.js";
|
|
5
5
|
|
|
6
|
-
export type DefaultSlotContent =
|
|
6
|
+
export type DefaultSlotContent = Block | string;
|
|
7
7
|
|
|
8
|
-
/** Accepts any
|
|
8
|
+
/** Accepts any block or HTML string (default slot content). */
|
|
9
9
|
export const anySlotContentSchema: z.ZodType<DefaultSlotContent> = zod.union([
|
|
10
10
|
zod.string(),
|
|
11
|
-
zod.custom<
|
|
11
|
+
zod.custom<Block>((value) => isBlock(value)),
|
|
12
12
|
]);
|
|
13
13
|
|
|
14
14
|
/** Accepts only HTML strings. */
|
|
15
15
|
export const stringSlotContentSchema = zod.string();
|
|
16
16
|
|
|
17
|
-
/** Accepts only CMS
|
|
18
|
-
export const
|
|
19
|
-
(value) =>
|
|
17
|
+
/** Accepts only CMS blocks. */
|
|
18
|
+
export const blockSlotContentSchema: z.ZodType<Block> = zod.custom<Block>(
|
|
19
|
+
(value) => isBlock(value),
|
|
20
20
|
);
|
|
21
21
|
|
|
22
22
|
export type SlotContentFromDefinition<D extends SlotDefinition> =
|
|
@@ -53,10 +53,10 @@ export function validateSlotContentItem(
|
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
if (typeof item !== "string" && !
|
|
56
|
+
if (typeof item !== "string" && !isBlock(item)) {
|
|
57
57
|
throw new SlotContentValidationError(
|
|
58
58
|
slotName,
|
|
59
|
-
"expected
|
|
59
|
+
"expected block or HTML string",
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -71,7 +71,7 @@ export class SlotContentValidationError extends Error {
|
|
|
71
71
|
export function slotHasContent(items: unknown[]): boolean {
|
|
72
72
|
return items.some(
|
|
73
73
|
(item) =>
|
|
74
|
-
|
|
74
|
+
isBlock(item) ||
|
|
75
75
|
(typeof item === "string" && item.trim().length > 0),
|
|
76
76
|
);
|
|
77
77
|
}
|
package/src/slot-handle.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { html, type HSHtml } from "@hyperspan/html";
|
|
2
2
|
import type { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { isBlock } from "./blocks.js";
|
|
4
4
|
import type { SitePage, RenderContext } from "./pages.js";
|
|
5
5
|
import {
|
|
6
6
|
type DefaultSlotContent,
|
|
@@ -62,11 +62,11 @@ export function renderSlotContent(
|
|
|
62
62
|
if (typeof item === "string") {
|
|
63
63
|
return html.raw(item);
|
|
64
64
|
}
|
|
65
|
-
if (
|
|
65
|
+
if (isBlock(item)) {
|
|
66
66
|
return item.render(ctx);
|
|
67
67
|
}
|
|
68
68
|
throw new RenderError(
|
|
69
|
-
"invalid slot content; expected
|
|
69
|
+
"invalid slot content; expected block or HTML string",
|
|
70
70
|
);
|
|
71
71
|
}),
|
|
72
72
|
).then((chunks) => html`${chunks}`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { html, renderAsync } from "@hyperspan/html";
|
|
4
|
-
import {
|
|
4
|
+
import { createBlock } from "../src/blocks.js";
|
|
5
5
|
import { createPage } from "../src/pages.js";
|
|
6
6
|
import { renderPage } from "../src/render-page.js";
|
|
7
7
|
import { createSiteConfig } from "../src/site.js";
|
|
@@ -12,33 +12,33 @@ function sleep(ms: number): Promise<void> {
|
|
|
12
12
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
describe("async
|
|
16
|
-
it("await async
|
|
15
|
+
describe("async block rendering", () => {
|
|
16
|
+
it("await async blocks in slot content", async () => {
|
|
17
17
|
const siteConfig = createSiteConfig({ name: "Test" });
|
|
18
18
|
const ctx = { siteConfig };
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const slowBlock = createBlock({
|
|
21
21
|
name: "slow",
|
|
22
22
|
async render() {
|
|
23
23
|
await sleep(25);
|
|
24
|
-
return html`<p>slow
|
|
24
|
+
return html`<p>slow block</p>`;
|
|
25
25
|
},
|
|
26
26
|
})();
|
|
27
27
|
|
|
28
28
|
const htmlOut = await renderAsync(
|
|
29
|
-
await renderSlotContent("content", [
|
|
29
|
+
await renderSlotContent("content", [slowBlock], ctx, undefined),
|
|
30
30
|
);
|
|
31
31
|
|
|
32
|
-
assert.match(htmlOut, /<p>slow
|
|
32
|
+
assert.match(htmlOut, /<p>slow block<\/p>/);
|
|
33
33
|
assert.doesNotMatch(htmlOut, /hs:loading/);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("resolves multiple async
|
|
36
|
+
it("resolves multiple async blocks concurrently", async () => {
|
|
37
37
|
const siteConfig = createSiteConfig({ name: "Test" });
|
|
38
38
|
const ctx = { siteConfig };
|
|
39
39
|
const order: string[] = [];
|
|
40
40
|
|
|
41
|
-
const first =
|
|
41
|
+
const first = createBlock({
|
|
42
42
|
name: "first",
|
|
43
43
|
async render() {
|
|
44
44
|
await sleep(30);
|
|
@@ -47,7 +47,7 @@ describe("async module rendering", () => {
|
|
|
47
47
|
},
|
|
48
48
|
})();
|
|
49
49
|
|
|
50
|
-
const second =
|
|
50
|
+
const second = createBlock({
|
|
51
51
|
name: "second",
|
|
52
52
|
async render() {
|
|
53
53
|
await sleep(10);
|
|
@@ -71,7 +71,7 @@ describe("async module rendering", () => {
|
|
|
71
71
|
assert.deepEqual(order, ["second", "first"]);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("renders async
|
|
74
|
+
it("renders async blocks through the full page pipeline", async () => {
|
|
75
75
|
const siteConfig = createSiteConfig({ name: "Test Site" });
|
|
76
76
|
const template = createTemplate({
|
|
77
77
|
siteConfig,
|
|
@@ -81,7 +81,7 @@ describe("async module rendering", () => {
|
|
|
81
81
|
},
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
const
|
|
84
|
+
const asyncBlock = createBlock({
|
|
85
85
|
name: "page-async",
|
|
86
86
|
async render() {
|
|
87
87
|
await sleep(15);
|
|
@@ -92,7 +92,7 @@ describe("async module rendering", () => {
|
|
|
92
92
|
const page = createPage(template, {
|
|
93
93
|
path: "/async-test",
|
|
94
94
|
title: "Async test",
|
|
95
|
-
slots: { content: [
|
|
95
|
+
slots: { content: [asyncBlock] },
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
const htmlOut = await renderPage(page, siteConfig);
|
package/test/blog.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { mkdtempSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { describe, it } from "node:test";
|
|
7
|
-
import { createBlog } from "../src/
|
|
7
|
+
import { createBlog } from "../src/plugins/blog/index.js";
|
|
8
8
|
|
|
9
9
|
function makeBlogDir(posts: Record<string, string>): string {
|
|
10
10
|
const root = mkdtempSync(join(tmpdir(), "stackbox-blog-"));
|
|
@@ -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
|
+
});
|