@invisibleloop/pulse 0.2.6 → 0.2.8

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 CHANGED
@@ -228,12 +228,15 @@ All specs are validated at startup — bad specs throw before the server accepts
228
228
 
229
229
  Pass `resolveBrand: async (host) => brandConfig` to `createServer`. The result is cached per host for 60 seconds and attached to `ctx.brand`. It is available in `guard`, `server` fetchers, and any `meta` field that is a function.
230
230
 
231
- Any `meta` field can be a function `(ctx) => value` — called per request, not at startup:
231
+ Any `meta` field can be a function `(ctx) => value` — called per request, not at startup. Meta functions can also be **async**, so you can fetch data for `title`, `description`, `ogImage` etc.:
232
232
 
233
233
  ```js
234
234
  export default {
235
235
  meta: {
236
- title: (ctx) => `${ctx.brand.name} — Home`,
236
+ title: async (ctx) => {
237
+ const product = await fetchProduct(ctx.params.id)
238
+ return product ? `${product.name} — Store` : 'Store'
239
+ },
237
240
  styles: (ctx) => ['/pulse-ui.css', `/themes/${ctx.brand.slug}.css`],
238
241
  },
239
242
  server: {
@@ -246,6 +249,34 @@ export default {
246
249
  }
247
250
  ```
248
251
 
252
+ If `meta` and `server` both need the same API data, use a **request-scoped cache** on `ctx` to avoid fetching twice. `ctx` is created fresh per request, so `ctx._cache` never leaks between requests:
253
+
254
+ ```js
255
+ // shared-fetchers.js
256
+ export async function getProduct(ctx) {
257
+ ctx._cache ??= {}
258
+ ctx._cache.product ??= await db.products.find(ctx.params.id)
259
+ return ctx._cache.product
260
+ }
261
+ ```
262
+
263
+ ```js
264
+ // spec
265
+ import { getProduct } from './shared-fetchers.js'
266
+
267
+ export default {
268
+ meta: {
269
+ title: async (ctx) => {
270
+ const p = await getProduct(ctx) // fetches once, cached on ctx
271
+ return p ? `${p.name} — Store` : 'Store'
272
+ },
273
+ },
274
+ server: {
275
+ product: (ctx) => getProduct(ctx), // hits the same cache, no second fetch
276
+ },
277
+ }
278
+ ```
279
+
249
280
  Keep brand differences in CSS custom properties — one `/pulse-ui.css` for components, one small `/themes/slug.css` per brand that overrides `:root` variables only.
250
281
 
251
282
  ## Custom Fonts
@@ -1 +1 @@
1
- 0.2.2
1
+ 0.2.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.2.6
1
+ 0.2.8
package/scripts/build.js CHANGED
@@ -468,6 +468,20 @@ async function purgeCssStep(pages, manifest, root, outDir) {
468
468
  }
469
469
  }
470
470
 
471
+ // Scan pulse UI package source — class names generated by UI components
472
+ // (breadcrumbs, avatar, container, etc.) only appear in the package source,
473
+ // not in the project's own pages, so they'd otherwise be purged.
474
+ const scanUiDirs = [
475
+ path.join(root, 'src', 'ui'), // pulse2 own source
476
+ path.join(root, 'node_modules', '@invisibleloop', 'pulse', 'src', 'ui'), // installed package
477
+ ]
478
+ for (const dir of scanUiDirs) {
479
+ if (!fs.existsSync(dir)) continue
480
+ for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.js'))) {
481
+ try { jsContents.push(fs.readFileSync(path.join(dir, f), 'utf8')) } catch {}
482
+ }
483
+ }
484
+
471
485
  if (cssFiles.size === 0) {
472
486
  console.log(' No CSS files referenced — skipping.\n')
473
487
  return
package/src/cli/dev.js CHANGED
@@ -25,16 +25,15 @@ const ROOT = rootArg !== -1
25
25
  ? path.resolve(args[rootArg + 1])
26
26
  : process.cwd()
27
27
 
28
+ let _config = {}
29
+ const configPath = path.join(ROOT, 'pulse.config.js')
30
+ if (fs.existsSync(configPath)) {
31
+ try { _config = (await import(configPath)).default ?? {} } catch { /* ignore */ }
32
+ }
33
+
28
34
  async function resolvePort() {
29
35
  if (portArg !== -1) return parseInt(args[portArg + 1], 10)
30
- const configPath = path.join(ROOT, 'pulse.config.js')
31
- if (fs.existsSync(configPath)) {
32
- try {
33
- const mod = await import(configPath)
34
- if (mod.default?.port) return mod.default.port
35
- } catch { /* fall through */ }
36
- }
37
- return 3000
36
+ return _config.port || 3000
38
37
  }
39
38
 
40
39
  const PORT = await resolvePort()
@@ -148,6 +147,7 @@ const { updateSpecs } = createServer(specs, {
148
147
  manifest: {}, // never use a build manifest in dev — always serve source files
149
148
  extraBody: reloadScript,
150
149
  dev: true,
150
+ ...(_config.csp ? { csp: _config.csp } : {}),
151
151
 
152
152
  onRequest(req, res) {
153
153
  const url = req.url.split('?')[0]
package/src/cli/start.js CHANGED
@@ -42,6 +42,7 @@ if (!fs.existsSync(manifestPath)) {
42
42
 
43
43
  let port = portArg !== -1 ? parseInt(args[portArg + 1], 10) : null
44
44
  let defaultCache = null
45
+ let csp = null
45
46
 
46
47
  const configPath = path.join(ROOT, 'pulse.config.js')
47
48
  if (fs.existsSync(configPath)) {
@@ -49,6 +50,7 @@ if (fs.existsSync(configPath)) {
49
50
  const mod = await import(configPath)
50
51
  port = port || mod.default?.port || null
51
52
  defaultCache = mod.default?.defaultCache ?? null
53
+ csp = mod.default?.csp ?? null
52
54
  } catch { /* ignore */ }
53
55
  }
54
56
 
@@ -71,4 +73,5 @@ createServer(specs, {
71
73
  stream: true,
72
74
  staticDir: PUBLIC_DIR,
73
75
  defaultCache,
76
+ ...(csp ? { csp } : {}),
74
77
  })
@@ -708,7 +708,7 @@ export function createServer(specs, options = {}) {
708
708
  */
709
709
  async function handleNavResponse(spec, ctx, res, dev = false) {
710
710
  const { html, serverState } = await cachedRenderToString(spec, ctx, dev)
711
- const meta = resolveMeta(spec.meta, ctx)
711
+ const meta = await resolveMeta(spec.meta, ctx)
712
712
 
713
713
  const payload = JSON.stringify({
714
714
  html,
@@ -735,7 +735,7 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
735
735
  * showing shell content without waiting for slower deferred fetchers.
736
736
  */
737
737
  async function handleNavStreamResponse(spec, ctx, req, res) {
738
- const meta = resolveMeta(spec.meta, ctx)
738
+ const meta = await resolveMeta(spec.meta, ctx)
739
739
 
740
740
  res.writeHead(200, {
741
741
  'Content-Type': 'application/x-ndjson',
@@ -791,7 +791,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
791
791
  ? (canonicalRaw(ctx, serverState) || canonicalBase)
792
792
  : (canonicalRaw || canonicalBase)
793
793
  const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
794
- const resolvedSpec = { ...spec, meta: resolveMeta(spec.meta, ctx) }
794
+ const resolvedSpec = { ...spec, meta: await resolveMeta(spec.meta, ctx) }
795
795
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
796
796
  const wrapped = wrapDocument({ content, spec: resolvedSpec, serverState, storeState: ctx.store || null, storeDef: store || null, timing, extraBody: resolvedExtraBody, extraHead: (dev ? devImportMap(nonce) + '\n ' : '') + canonicalTag, nonce, runtimeBundle, faviconHref: faviconPath || '' })
797
797
  html = wrapped.html
@@ -847,7 +847,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
847
847
  const t0 = performance.now()
848
848
 
849
849
  // Write the document opening immediately so the browser starts parsing
850
- const meta = resolveMeta(spec.meta, ctx)
850
+ const meta = await resolveMeta(spec.meta, ctx)
851
851
  const title = meta.title || 'Pulse'
852
852
  // Resolve canonical — supports a string or a function receiving (ctx).
853
853
  // Note: server fetcher results are not yet available when the <head> is written in streaming mode.
@@ -1389,12 +1389,14 @@ function serveStatic(req, res, staticDir, dev = false) {
1389
1389
  * @param {Object} ctx
1390
1390
  * @returns {Object}
1391
1391
  */
1392
- function resolveMeta(meta, ctx) {
1392
+ async function resolveMeta(meta, ctx) {
1393
1393
  if (!meta) return {}
1394
1394
  const resolved = {}
1395
- for (const [key, val] of Object.entries(meta)) {
1396
- resolved[key] = typeof val === 'function' ? val(ctx) : val
1397
- }
1395
+ await Promise.all(
1396
+ Object.entries(meta).map(async ([key, val]) => {
1397
+ resolved[key] = typeof val === 'function' ? await val(ctx) : val
1398
+ })
1399
+ )
1398
1400
  return resolved
1399
1401
  }
1400
1402
 
@@ -225,6 +225,32 @@ await test('sets title from spec.meta', async () => {
225
225
  })
226
226
  })
227
227
 
228
+ await test('resolves async meta.title function (string mode)', async () => {
229
+ const spec = {
230
+ route: '/async-meta',
231
+ state: {},
232
+ view: () => '<p>content</p>',
233
+ meta: { title: async () => 'Async Title' },
234
+ }
235
+ await withServer([spec], { stream: false }, async (port) => {
236
+ const { body } = await get(port, '/async-meta')
237
+ assert(body.includes('<title>Async Title</title>'), `Expected async title: ${body}`)
238
+ })
239
+ })
240
+
241
+ await test('resolves async meta.title function (streaming mode)', async () => {
242
+ const spec = {
243
+ route: '/async-meta-stream',
244
+ state: {},
245
+ view: () => '<p>content</p>',
246
+ meta: { title: async () => 'Stream Async Title' },
247
+ }
248
+ await withServer([spec], { stream: true }, async (port) => {
249
+ const { body } = await get(port, '/async-meta-stream')
250
+ assert(body.includes('<title>Stream Async Title</title>'), `Expected async title in stream: ${body}`)
251
+ })
252
+ })
253
+
228
254
  await test('injects JSON-LD script when meta.schema is set (string mode)', async () => {
229
255
  const spec = {
230
256
  route: '/article',
@@ -428,49 +454,56 @@ const deferredNavSpec = {
428
454
 
429
455
  console.log('\nClient-side navigation — streaming (stream: true)\n')
430
456
 
431
- await test('X-Pulse-Navigate with stream:true returns application/x-ndjson', async () => {
432
- await withServer([streamNavSpec], {}, async (port) => {
433
- const { status, headers } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
457
+ await test('X-Pulse-Navigate with stream:true returns application/x-ndjson for deferred specs', async () => {
458
+ await withServer([deferredNavSpec], { stream: true }, async (port) => {
459
+ const { status, headers } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
434
460
  assert(status === 200, `Expected 200, got ${status}`)
435
461
  assert(headers['content-type']?.includes('application/x-ndjson'), `Expected NDJSON: ${headers['content-type']}`)
436
462
  })
437
463
  })
438
464
 
465
+ await test('X-Pulse-Navigate with stream:true returns application/json for non-deferred specs', async () => {
466
+ await withServer([streamNavSpec], { stream: true }, async (port) => {
467
+ const { status, headers } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
468
+ assert(status === 200, `Expected 200, got ${status}`)
469
+ assert(headers['content-type']?.includes('application/json'), `Expected JSON: ${headers['content-type']}`)
470
+ })
471
+ })
472
+
439
473
  await test('streaming nav: first NDJSON line is a meta message with title and hydrate', async () => {
440
- await withServer([streamNavSpec], {}, async (port) => {
441
- const { body } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
474
+ await withServer([deferredNavSpec], { stream: true }, async (port) => {
475
+ const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
442
476
  const lines = body.trim().split('\n').filter(Boolean)
443
477
  const meta = JSON.parse(lines[0])
444
- assert(meta.type === 'meta', `First line should be meta, got ${meta.type}`)
445
- assert(meta.title === 'Stream Nav', `Expected title 'Stream Nav', got ${meta.title}`)
446
- assert(meta.hydrate === '/examples/stream-nav.js', `Expected hydrate, got ${meta.hydrate}`)
447
- assert(meta.styles[0] === '/app.css', `Expected styles, got ${JSON.stringify(meta.styles)}`)
478
+ assert(meta.type === 'meta', `First line should be meta, got ${meta.type}`)
479
+ assert(meta.title === 'Deferred Nav', `Expected title 'Deferred Nav', got ${meta.title}`)
480
+ assert(meta.hydrate === '/examples/deferred-nav.js', `Expected hydrate, got ${meta.hydrate}`)
448
481
  })
449
482
  })
450
483
 
451
484
  await test('streaming nav: html message contains rendered server data', async () => {
452
- await withServer([streamNavSpec], {}, async (port) => {
453
- const { body } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
485
+ await withServer([deferredNavSpec], { stream: true }, async (port) => {
486
+ const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
454
487
  const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
455
488
  const htmlMsg = messages.find(m => m.type === 'html')
456
489
  assert(htmlMsg, 'should have html message')
457
- assert(htmlMsg.html.includes('streamed hello'), `Expected server data in html: ${htmlMsg.html}`)
490
+ assert(htmlMsg.html.includes('shell-data'), `Expected shell server data in html: ${htmlMsg.html}`)
458
491
  assert(!htmlMsg.html.includes('<!DOCTYPE'), 'html should not be a full document')
459
492
  })
460
493
  })
461
494
 
462
495
  await test('streaming nav: done message contains serverState', async () => {
463
- await withServer([streamNavSpec], {}, async (port) => {
464
- const { body } = await request(port, 'GET', '/stream-nav', { 'X-Pulse-Navigate': 'true' })
496
+ await withServer([deferredNavSpec], { stream: true }, async (port) => {
497
+ const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
465
498
  const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
466
499
  const done = messages.find(m => m.type === 'done')
467
- assert(done, 'should have done message')
468
- assert(done.serverState?.msg === 'streamed hello', `Expected serverState.msg, got ${JSON.stringify(done.serverState)}`)
500
+ assert(done, 'should have done message')
501
+ assert(done.serverState?.fast === 'shell-data', `Expected serverState.fast, got ${JSON.stringify(done.serverState)}`)
469
502
  })
470
503
  })
471
504
 
472
505
  await test('streaming nav: deferred spec sends shell then deferred then done', async () => {
473
- await withServer([deferredNavSpec], {}, async (port) => {
506
+ await withServer([deferredNavSpec], { stream: true }, async (port) => {
474
507
  const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
475
508
  const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
476
509
  const types = messages.map(m => m.type)
@@ -491,7 +524,7 @@ await test('streaming nav: deferred spec sends shell then deferred then done', a
491
524
  })
492
525
 
493
526
  await test('streaming nav: html includes <pulse-deferred> placeholder for deferred segments', async () => {
494
- await withServer([deferredNavSpec], {}, async (port) => {
527
+ await withServer([deferredNavSpec], { stream: true }, async (port) => {
495
528
  const { body } = await request(port, 'GET', '/deferred-nav', { 'X-Pulse-Navigate': 'true' })
496
529
  const messages = body.trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
497
530
  const html = messages.find(m => m.type === 'html')
package/src/ui/ui.test.js CHANGED
@@ -40,6 +40,7 @@ import { radio, radioGroup } from './radio.js'
40
40
  import { segmented } from './segmented.js'
41
41
  import { fileUpload } from './fileupload.js'
42
42
  import { modal, modalTrigger } from './modal.js'
43
+ import { uiImage } from './uiimage.js'
43
44
 
44
45
  // ─── button ─────────────────────────────────────────────────────────────────
45
46
 
@@ -1244,4 +1245,43 @@ test('modalTrigger: escapes target id to prevent XSS', () => {
1244
1245
  assert.doesNotMatch(html, /<script>/)
1245
1246
  })
1246
1247
 
1248
+ // ─── uiImage ────────────────────────────────────────────────────────────────
1249
+
1250
+ test('uiImage: renders img with src and alt', () => {
1251
+ const html = uiImage({ src: '/photo.jpg', alt: 'A photo' })
1252
+ assert.match(html, /src="\/photo\.jpg"/)
1253
+ assert.match(html, /alt="A photo"/)
1254
+ })
1255
+
1256
+ test('uiImage: defaults to loading="lazy"', () => {
1257
+ const html = uiImage({ src: '/photo.jpg', alt: '' })
1258
+ assert.match(html, /loading="lazy"/)
1259
+ })
1260
+
1261
+ test('uiImage: accepts loading="eager"', () => {
1262
+ const html = uiImage({ src: '/photo.jpg', alt: '', loading: 'eager' })
1263
+ assert.match(html, /loading="eager"/)
1264
+ })
1265
+
1266
+ test('uiImage: omits fetchpriority when not set', () => {
1267
+ const html = uiImage({ src: '/photo.jpg', alt: '' })
1268
+ assert.doesNotMatch(html, /fetchpriority/)
1269
+ })
1270
+
1271
+ test('uiImage: renders fetchpriority="high" when set', () => {
1272
+ const html = uiImage({ src: '/photo.jpg', alt: '', fetchpriority: 'high' })
1273
+ assert.match(html, /fetchpriority="high"/)
1274
+ })
1275
+
1276
+ test('uiImage: wraps in aspect-ratio crop when ratio given', () => {
1277
+ const html = uiImage({ src: '/photo.jpg', alt: '', ratio: '16/9' })
1278
+ assert.match(html, /aspect-ratio:16\/9/)
1279
+ assert.match(html, /ui-image-img--cover/)
1280
+ })
1281
+
1282
+ test('uiImage: escapes src to prevent XSS', () => {
1283
+ const html = uiImage({ src: '"><script>alert(1)</script>', alt: '' })
1284
+ assert.doesNotMatch(html, /<script>/)
1285
+ })
1286
+
1247
1287
  console.log('✓ All UI component tests passed')
package/src/ui/uiimage.js CHANGED
@@ -15,21 +15,25 @@
15
15
  * @param {number|string} opts.height - img height attribute
16
16
  * @param {number|string} opts.maxWidth - CSS max-width on the figure (px value or CSS string). Use this to constrain portrait/narrow images inside wide columns.
17
17
  * @param {string} opts.class
18
+ * @param {string} opts.loading - 'lazy' (default) or 'eager' — use 'eager' for above-the-fold / LCP images
19
+ * @param {string} opts.fetchpriority - 'high' | 'low' | 'auto' — set 'high' on the LCP image
18
20
  */
19
21
 
20
22
  import { escHtml as e } from '../html.js'
21
23
 
22
24
  export function uiImage({
23
- src = '',
24
- alt = '',
25
- caption = '',
26
- ratio = '',
27
- rounded = false,
28
- pill = false,
29
- width = '',
30
- height = '',
31
- maxWidth = '',
32
- class: cls = '',
25
+ src = '',
26
+ alt = '',
27
+ caption = '',
28
+ ratio = '',
29
+ rounded = false,
30
+ pill = false,
31
+ width = '',
32
+ height = '',
33
+ maxWidth = '',
34
+ loading = 'lazy',
35
+ fetchpriority = '',
36
+ class: cls = '',
33
37
  } = {}) {
34
38
  // The outer figure uses display:contents so the inner crop div
35
39
  // becomes a direct flex/block child of whatever container holds it.
@@ -40,8 +44,9 @@ export function uiImage({
40
44
  ? ` style="max-width:${e(typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth)};margin-left:auto;margin-right:auto"`
41
45
  : ''
42
46
 
43
- const widthAttr = width ? ` width="${e(String(width))}"` : ''
44
- const heightAttr = height ? ` height="${e(String(height))}"` : ''
47
+ const widthAttr = width ? ` width="${e(String(width))}"` : ''
48
+ const heightAttr = height ? ` height="${e(String(height))}"` : ''
49
+ const fetchpriorityAttr = fetchpriority ? ` fetchpriority="${e(fetchpriority)}"` : ''
45
50
 
46
51
  const captionHtml = caption
47
52
  ? `<figcaption class="ui-image-caption">${e(caption)}</figcaption>`
@@ -50,7 +55,7 @@ export function uiImage({
50
55
  if (ratio) {
51
56
  return `<figure class="${e(figClasses)}"${maxWidthStyle}>
52
57
  <div class="ui-image-crop" style="aspect-ratio:${e(ratio)}">
53
- <img src="${e(src)}" alt="${e(alt)}" class="ui-image-img--cover"${widthAttr}${heightAttr} loading="lazy" decoding="async">
58
+ <img src="${e(src)}" alt="${e(alt)}" class="ui-image-img--cover"${widthAttr}${heightAttr} loading="${e(loading)}" decoding="async"${fetchpriorityAttr}>
54
59
  </div>
55
60
  ${captionHtml}
56
61
  </figure>`
@@ -58,7 +63,7 @@ export function uiImage({
58
63
 
59
64
  return `<figure class="${e(figClasses)}"${maxWidthStyle}>
60
65
  <div class="ui-image-wrap">
61
- <img src="${e(src)}" alt="${e(alt)}" class="ui-image-img"${widthAttr}${heightAttr} loading="lazy" decoding="async">
66
+ <img src="${e(src)}" alt="${e(alt)}" class="ui-image-img"${widthAttr}${heightAttr} loading="${e(loading)}" decoding="async"${fetchpriorityAttr}>
62
67
  </div>
63
68
  ${captionHtml}
64
69
  </figure>`