@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.
@@ -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
- const serverStateScript = Object.keys(serverState).length > 0
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
 
@@ -61,7 +61,8 @@ const counterSpec = {
61
61
  }
62
62
 
63
63
  const serverDataSpec = {
64
- route: '/products',
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: '/scoped-state',
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}`)
@@ -324,7 +324,9 @@ export function validateSpec(spec) {
324
324
 
325
325
  // meta
326
326
  if (spec.meta) {
327
- if (typeof spec.meta !== 'object') {
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