@invisibleloop/pulse 0.5.1 → 0.6.1
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.md +36 -0
- package/docs/.claude/pulse-checklist.md +7 -5
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/pulse-ui.css +27 -0
- package/docs/server.js +1 -0
- package/docs/src/lib/highlight.js +2 -299
- package/docs/src/lib/nav.js +1 -0
- package/docs/src/pages/components/prose.js +2 -2
- package/docs/src/pages/markdown.js +155 -0
- package/docs/src/pages/server-data.js +18 -0
- package/docs/src/pages/spec.js +1 -1
- package/package.json +5 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +27 -0
- package/scripts/build.js +53 -2
- package/scripts/strip-server.test.js +74 -2
- package/src/agent/checklist.md +20 -0
- package/src/agent/guide-server.md +62 -0
- package/src/agent/guide-spec.md +5 -3
- package/src/agent/guide.md +105 -2
- package/src/cli/dev.js +4 -0
- package/src/cli/scaffold.js +0 -2
- package/src/cli/start.js +1 -0
- package/src/md/highlight.js +369 -0
- package/src/md/index.js +337 -0
- package/src/runtime/ssr.js +4 -3
- package/src/runtime/ssr.test.js +5 -2
- package/src/spec/schema.js +3 -1
- package/types/md.d.ts +66 -0
package/src/runtime/ssr.js
CHANGED
|
@@ -146,7 +146,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
|
|
|
146
146
|
? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
|
|
147
147
|
: {}
|
|
148
148
|
const mergedAllState = mergeStoreKeys(spec, allServerState, ctx.store)
|
|
149
|
-
if (Object.keys(mergedAllState).length > 0) {
|
|
149
|
+
if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
|
|
150
150
|
controller.enqueue(encode(
|
|
151
151
|
`<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
|
|
152
152
|
))
|
|
@@ -314,8 +314,9 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
|
|
|
314
314
|
: ''
|
|
315
315
|
|
|
316
316
|
// Serialise server state into the page so the client runtime can read it
|
|
317
|
-
// without making a second request
|
|
318
|
-
|
|
317
|
+
// without making a second request. Only needed when the page is hydrated —
|
|
318
|
+
// purely server-rendered pages have no JS to read it.
|
|
319
|
+
const serverStateScript = spec.hydrate && Object.keys(serverState).length > 0
|
|
319
320
|
? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
|
|
320
321
|
: ''
|
|
321
322
|
|
package/src/runtime/ssr.test.js
CHANGED
|
@@ -61,7 +61,8 @@ const counterSpec = {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const serverDataSpec = {
|
|
64
|
-
route:
|
|
64
|
+
route: '/products',
|
|
65
|
+
hydrate: '/src/pages/products.js',
|
|
65
66
|
state: {},
|
|
66
67
|
server: {
|
|
67
68
|
products: async (ctx) => [
|
|
@@ -314,7 +315,8 @@ await test('stream.scope: shell does not wait for deferred-only fetchers', async
|
|
|
314
315
|
|
|
315
316
|
await test('stream.scope: __PULSE_SERVER__ includes state from all scoped fetchers', async () => {
|
|
316
317
|
const spec = {
|
|
317
|
-
route:
|
|
318
|
+
route: '/scoped-state',
|
|
319
|
+
hydrate: '/src/pages/scoped-state.js',
|
|
318
320
|
stream: {
|
|
319
321
|
shell: ['header'],
|
|
320
322
|
deferred: ['feed'],
|
|
@@ -366,6 +368,7 @@ await test('sets og:title meta tag', async () => {
|
|
|
366
368
|
await test('serialises server state into page', async () => {
|
|
367
369
|
const { html } = wrapDocument({
|
|
368
370
|
content: '',
|
|
371
|
+
spec: { hydrate: '/src/pages/products.js' },
|
|
369
372
|
serverState: { products: [{ id: 1 }] }
|
|
370
373
|
})
|
|
371
374
|
assert(html.includes('__PULSE_SERVER__'), `Expected server state script: ${html}`)
|
package/src/spec/schema.js
CHANGED
|
@@ -324,7 +324,9 @@ export function validateSpec(spec) {
|
|
|
324
324
|
|
|
325
325
|
// meta
|
|
326
326
|
if (spec.meta) {
|
|
327
|
-
if (typeof spec.meta
|
|
327
|
+
if (typeof spec.meta === 'function') {
|
|
328
|
+
errors.push('spec.meta must be a plain object, not a function. Individual fields (title, description, styles) can be async (ctx) => value functions, but meta itself must be {}')
|
|
329
|
+
} else if (typeof spec.meta !== 'object') {
|
|
328
330
|
errors.push('spec.meta must be a plain object')
|
|
329
331
|
} else if (spec.meta.schema !== undefined && typeof spec.meta.schema !== 'object') {
|
|
330
332
|
errors.push('spec.meta.schema must be a plain object (JSON-LD)')
|
package/types/md.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Markdown parser types
|
|
3
|
+
* @invisibleloop/pulse/md
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Parsed frontmatter key/value pairs from the --- block */
|
|
7
|
+
export type Frontmatter = Record<string, string>
|
|
8
|
+
|
|
9
|
+
/** Result of parsing a markdown string */
|
|
10
|
+
export interface MdResult {
|
|
11
|
+
/** HTML string ready to pass to prose({ content }) */
|
|
12
|
+
html: string
|
|
13
|
+
/** Key/value pairs parsed from the --- frontmatter block */
|
|
14
|
+
frontmatter: Frontmatter
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Request context passed to server fetchers */
|
|
18
|
+
export interface MdContext {
|
|
19
|
+
params?: Record<string, string>
|
|
20
|
+
[key: string]: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Async server fetcher returned by md() */
|
|
24
|
+
export type MdFetcher = (ctx?: MdContext) => Promise<MdResult>
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a server fetcher that reads and parses a markdown file.
|
|
28
|
+
*
|
|
29
|
+
* Path patterns support :param placeholders resolved from ctx.params:
|
|
30
|
+
* md('content/blog/:slug.md')
|
|
31
|
+
*
|
|
32
|
+
* The result is cached per request on ctx._mdCache so meta and server
|
|
33
|
+
* can both call the same fetcher without reading the file twice.
|
|
34
|
+
*
|
|
35
|
+
* Missing files throw an error with status 404 — add onViewError on
|
|
36
|
+
* dynamic routes to handle this gracefully.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* import { md } from '@invisibleloop/pulse/md'
|
|
40
|
+
* import { prose } from '@invisibleloop/pulse/ui'
|
|
41
|
+
*
|
|
42
|
+
* const post = md('content/blog/:slug.md')
|
|
43
|
+
*
|
|
44
|
+
* export default {
|
|
45
|
+
* route: '/blog/:slug',
|
|
46
|
+
* meta: {
|
|
47
|
+
* title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
48
|
+
* description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
49
|
+
* },
|
|
50
|
+
* server: { post },
|
|
51
|
+
* view: (state, { post }) => prose({ content: post.html }),
|
|
52
|
+
* onViewError: () => '<p>Post not found.</p>',
|
|
53
|
+
* }
|
|
54
|
+
*/
|
|
55
|
+
export function md(pathPattern: string | URL): MdFetcher
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a markdown string into HTML and frontmatter.
|
|
59
|
+
* Use this when markdown comes from a database or API rather than a file.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* import { parseMd } from '@invisibleloop/pulse/md'
|
|
63
|
+
*
|
|
64
|
+
* const { html, frontmatter } = parseMd(record.body)
|
|
65
|
+
*/
|
|
66
|
+
export function parseMd(source: string): MdResult
|