@invisibleloop/pulse 0.7.0 → 0.7.2

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.
@@ -4,6 +4,20 @@ Before finishing any spec, verify every point below. Fix anything that fails.
4
4
 
5
5
  ### Critical
6
6
 
7
+ - **`meta` must be a plain object — never a function.** Individual fields (`title`, `description`, `styles`) can be `async (ctx) => value` functions, but `meta` itself is always `{}`.
8
+
9
+ ```js
10
+ // WRONG — meta is not a function factory
11
+ meta: async (ctx) => ({ title: '...', styles: [...] })
12
+
13
+ // CORRECT — meta is a plain object; individual fields are functions
14
+ meta: {
15
+ title: async (ctx) => (await post(ctx)).frontmatter.title,
16
+ description: async (ctx) => (await post(ctx)).frontmatter.description,
17
+ styles: ['/pulse-ui.css', '/app.css'],
18
+ }
19
+ ```
20
+
7
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
8
22
 
9
23
  ### Components first
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.7.0
@@ -52,8 +52,10 @@
52
52
  }
53
53
  }
54
54
 
55
- // After client-side navigation — sidebar DOM is replaced, so re-query and re-centre
55
+ // After client-side navigation — #pulse-root is replaced, so the original
56
+ // navSidebar reference is detached. Re-query for the fresh sidebar element.
56
57
  document.addEventListener('pulse:navigate', function () {
58
+ navSidebar = document.querySelector('.docs-sidebar')
57
59
  var newActive = navSidebar && navSidebar.querySelector('.nav-link.active')
58
60
  centerNavLink(newActive)
59
61
  })
@@ -2729,6 +2729,8 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2729
2729
  .ui-prose ul { list-style-type: disc; }
2730
2730
  .ui-prose ol { list-style-type: decimal; }
2731
2731
  .ui-prose ul ul { list-style-type: circle; }
2732
+ .ui-prose ul.contains-task-list { list-style: none; padding-left: 0; }
2733
+ .ui-prose li.task-list-item input[type="checkbox"] { margin-right: .4em; }
2732
2734
  .ui-prose li { margin-bottom: .375em; }
2733
2735
  .ui-prose li > ul,
2734
2736
  .ui-prose li > ol { margin-top: .375em; margin-bottom: .375em; }
@@ -14,7 +14,7 @@ let _manifest = {}
14
14
  export function initLayoutManifest(m) { _manifest = m }
15
15
  const asset = href => _manifest[href] || href
16
16
 
17
- const { version } = pkg
17
+ const version = typeof pkg !== 'undefined' ? (pkg?.version ?? '') : ''
18
18
 
19
19
  function esc(s) {
20
20
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
@@ -1,6 +1,7 @@
1
1
  import { renderComponentPage, demo } from '../../lib/component-page.js'
2
2
  import { prevNext } from '../../lib/nav.js'
3
3
  import { table } from '../../lib/layout.js'
4
+ import { modal } from '../../../../src/ui/index.js'
4
5
  import {
5
6
  button, feature,
6
7
  iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowDown,
@@ -17,8 +18,12 @@ import {
17
18
  iconPhone, iconGamepad,
18
19
  iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight,
19
20
  iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconBug,
20
- iconMapPin,
21
- iconSun, iconMoon,
21
+ iconMapPin, iconSun, iconMoon,
22
+ iconShoppingCart, iconShoppingBag, iconCreditCard, iconPackage, iconGift,
23
+ iconWallet, iconTruck, iconReceipt, iconStore, iconPercent, iconTicket, iconBanknote,
24
+ iconUtensils, iconCoffee, iconPizza, iconApple, iconCarrot,
25
+ iconWine, iconCakeSlice, iconFish, iconCherry, iconEgg,
26
+ iconCookie, iconIceCream, iconCroissant, iconSalad, iconWheat,
22
27
  } from '../../../../src/ui/index.js'
23
28
 
24
29
  const { prev, next } = prevNext('/components/icons')
@@ -92,11 +97,11 @@ const ICONS = [
92
97
  { name: 'iconTag', fn: iconTag },
93
98
  ]},
94
99
  { group: 'Media & Rating', icons: [
95
- { name: 'iconPlay', fn: iconPlay },
96
- { name: 'iconPause', fn: iconPause },
100
+ { name: 'iconPlay', fn: iconPlay },
101
+ { name: 'iconPause', fn: iconPause },
97
102
  { name: 'iconVolume', fn: iconVolume },
98
- { name: 'iconStar', fn: iconStar },
99
- { name: 'iconHeart', fn: iconHeart },
103
+ { name: 'iconStar', fn: iconStar },
104
+ { name: 'iconHeart', fn: iconHeart },
100
105
  ]},
101
106
  { group: 'Devices', icons: [
102
107
  { name: 'iconPhone', fn: iconPhone },
@@ -122,22 +127,117 @@ const ICONS = [
122
127
  { name: 'iconSun', fn: iconSun },
123
128
  { name: 'iconMoon', fn: iconMoon },
124
129
  ]},
130
+ { group: 'Ecommerce', icons: [
131
+ { name: 'iconShoppingCart', fn: iconShoppingCart },
132
+ { name: 'iconShoppingBag', fn: iconShoppingBag },
133
+ { name: 'iconCreditCard', fn: iconCreditCard },
134
+ { name: 'iconPackage', fn: iconPackage },
135
+ { name: 'iconGift', fn: iconGift },
136
+ { name: 'iconWallet', fn: iconWallet },
137
+ { name: 'iconTruck', fn: iconTruck },
138
+ { name: 'iconReceipt', fn: iconReceipt },
139
+ { name: 'iconStore', fn: iconStore },
140
+ { name: 'iconPercent', fn: iconPercent },
141
+ { name: 'iconTicket', fn: iconTicket },
142
+ { name: 'iconBanknote', fn: iconBanknote },
143
+ ]},
144
+ { group: 'Food & Drink', icons: [
145
+ { name: 'iconUtensils', fn: iconUtensils },
146
+ { name: 'iconCoffee', fn: iconCoffee },
147
+ { name: 'iconPizza', fn: iconPizza },
148
+ { name: 'iconApple', fn: iconApple },
149
+ { name: 'iconCarrot', fn: iconCarrot },
150
+ { name: 'iconWine', fn: iconWine },
151
+ { name: 'iconCakeSlice', fn: iconCakeSlice },
152
+ { name: 'iconFish', fn: iconFish },
153
+ { name: 'iconCherry', fn: iconCherry },
154
+ { name: 'iconEgg', fn: iconEgg },
155
+ { name: 'iconCookie', fn: iconCookie },
156
+ { name: 'iconIceCream', fn: iconIceCream },
157
+ { name: 'iconCroissant', fn: iconCroissant },
158
+ { name: 'iconSalad', fn: iconSalad },
159
+ { name: 'iconWheat', fn: iconWheat },
160
+ ]},
125
161
  ]
126
162
 
163
+ // Safe ID from icon name: iconArrowLeft → arrow-left
164
+ const safeId = name => name.replace(/^icon/, '').replace(/([A-Z])/g, m => '-' + m.toLowerCase()).replace(/^-/, '')
165
+
166
+ const PREVIEW_SIZES = [12, 16, 20, 24, 32, 48]
167
+ const PREVIEW_COLORS = [
168
+ { label: 'Default', style: '' },
169
+ { label: 'Accent', style: 'color:var(--accent)' },
170
+ { label: 'Green', style: 'color:var(--color-green,#4ade80)' },
171
+ { label: 'Yellow', style: 'color:var(--color-yellow,#facc15)' },
172
+ { label: 'Red', style: 'color:var(--color-red,#f87171)' },
173
+ { label: 'Muted', style: 'color:var(--muted)' },
174
+ ]
175
+
176
+ function iconPreviewModal({ name, fn }) {
177
+ const id = `icon-modal-${safeId(name)}`
178
+
179
+ const sizesHtml = PREVIEW_SIZES.map(size => `
180
+ <div style="display:flex;flex-direction:column;align-items:center;gap:4px">
181
+ ${fn({ size })}
182
+ <span style="font-size:0.6rem;color:var(--muted);font-family:var(--mono,monospace)">${size}</span>
183
+ </div>
184
+ `).join('')
185
+
186
+ const colorsHtml = PREVIEW_COLORS.map(({ label, style }) => `
187
+ <div style="display:flex;flex-direction:column;align-items:center;gap:4px;${style}">
188
+ ${fn({ size: 28 })}
189
+ <span style="font-size:0.6rem;color:var(--muted);font-family:var(--mono,monospace)">${label}</span>
190
+ </div>
191
+ `).join('')
192
+
193
+ const bgHtml =
194
+ ['circle', 'square'].map(shape =>
195
+ ['accent', 'success', 'warning', 'error', 'muted'].map(bgColor =>
196
+ fn({ size: 20, bg: shape, bgColor })
197
+ ).join('')
198
+ ).join('<br style="display:block;margin:.5rem 0">')
199
+
200
+ return modal({
201
+ id,
202
+ title: name,
203
+ size: 'md',
204
+ content: `
205
+ <div style="display:flex;flex-direction:column;gap:1.5rem">
206
+ <div>
207
+ <p style="font-size:.75rem;color:var(--muted);margin:0 0 .75rem">Sizes</p>
208
+ <div style="display:flex;align-items:flex-end;gap:1rem;flex-wrap:wrap">${sizesHtml}</div>
209
+ </div>
210
+ <div>
211
+ <p style="font-size:.75rem;color:var(--muted);margin:0 0 .75rem">Colours</p>
212
+ <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">${colorsHtml}</div>
213
+ </div>
214
+ <div>
215
+ <p style="font-size:.75rem;color:var(--muted);margin:0 0 .75rem">Backgrounds</p>
216
+ <div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">${bgHtml}</div>
217
+ </div>
218
+ </div>
219
+ `,
220
+ })
221
+ }
222
+
127
223
  function iconGrid_() {
128
224
  return ICONS.map(({ group, icons }) => `
129
225
  <h3 class="doc-h3" style="margin-top:2rem">${group}</h3>
130
226
  <div class="icon-grid">
131
227
  ${icons.map(({ name, fn }) => `
132
- <div class="icon-grid-item">
228
+ <button type="button" class="icon-grid-item" style="cursor:pointer;font:inherit;color:inherit" data-dialog-open="icon-modal-${safeId(name)}" aria-label="Preview ${name}">
133
229
  <div class="icon-grid-preview">${fn({ size: 20 })}</div>
134
230
  <span class="icon-grid-name">${name}</span>
135
- </div>
231
+ </button>
136
232
  `).join('')}
137
233
  </div>
138
234
  `).join('')
139
235
  }
140
236
 
237
+ function iconModals_() {
238
+ return ICONS.flatMap(({ icons }) => icons).map(iconPreviewModal).join('')
239
+ }
240
+
141
241
  export default {
142
242
  route: '/components/icons',
143
243
  meta: {
@@ -150,7 +250,7 @@ export default {
150
250
  prev,
151
251
  next,
152
252
  name: 'icons',
153
- description: '55 curated icons. All are pure functions returning an SVG string — no external library, no DOM dependency, tree-shakeable. Style: 24×24 viewBox, <code>stroke="currentColor"</code>, compatible with any colour token.',
253
+ description: '100 curated icons. All are pure functions returning an SVG string — no external library, no DOM dependency, tree-shakeable. Style: 24×24 viewBox, <code>stroke="currentColor"</code>, compatible with any colour token.',
154
254
  content: `
155
255
 
156
256
  <h2 class="doc-h2" id="usage">Usage</h2>
@@ -266,10 +366,12 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
266
366
  )}
267
367
 
268
368
  <h2 class="doc-h2" id="all-icons">All icons</h2>
269
- <p>Click any icon name to copy the import.</p>
369
+ <p>Click any icon to preview sizes, colours, and backgrounds.</p>
270
370
 
271
371
  ${iconGrid_()}
272
372
 
373
+ ${iconModals_()}
374
+
273
375
  ${table(
274
376
  ['Prop', 'Type', 'Default', 'Description'],
275
377
  [
@@ -279,6 +381,8 @@ feature({ icon: iconCode({ size: 22, bg: 'square', bgColor: 'muted' }), titl
279
381
  ['<code>bgColor</code>', 'string', "'accent'", "'accent' · 'success' · 'warning' · 'error' · 'muted'"],
280
382
  ]
281
383
  )}
384
+
385
+ <p style="margin-top:2rem;font-size:.8rem;color:var(--muted)">SVG paths derived from <a href="https://lucide.dev" target="_blank" rel="noopener noreferrer">Lucide</a> (ISC License, Copyright © 2022 Lucide Contributors) and <a href="https://feathericons.com" target="_blank" rel="noopener noreferrer">Feather Icons</a> (MIT License, Copyright © 2013–2017 Cole Bemis). Phosphor hand-pointer icons MIT License, Copyright © 2020 Phosphor Icons.</p>
282
386
  `,
283
387
  }),
284
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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.7.0
1
+ 0.7.2
package/scripts/build.js CHANGED
@@ -476,7 +476,9 @@ const result = await esbuild.build({
476
476
  const manifest = {}
477
477
 
478
478
  for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
479
- const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.join(ROOT, outFile))
479
+ // outFile is relative to process.cwd(), not ROOT — use path.resolve to get the
480
+ // true absolute path before computing the URL-relative bundle path.
481
+ const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.resolve(outFile))
480
482
 
481
483
  // Shared runtime chunk — esbuild generates this via splitting, no entryPoint
482
484
  if (!meta.entryPoint && path.basename(outFile).startsWith('runtime-')) {
package/src/cli/dev.js CHANGED
@@ -187,11 +187,14 @@ const { updateSpecs } = createServer(specs, {
187
187
 
188
188
  const serveFile = (filePath) => {
189
189
  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
190
- // Strip node:* imports — they don't resolve in the browser. Server-only imports
191
- // are only used inside spec.server functions which the client runtime never calls.
190
+ // Strip server-only imports that can't resolve in the browser:
191
+ // - node:* built-ins (fs, path, etc.)
192
+ // - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
193
+ // These are only used inside spec.server functions or module-level server init;
194
+ // the client runtime never calls those code paths.
192
195
  let content = fs.readFileSync(filePath, 'utf-8')
193
196
  content = content.replace(
194
- /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm,
197
+ /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
195
198
  ''
196
199
  )
197
200
  res.writeHead(200, {
@@ -258,9 +258,48 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
258
258
  }
259
259
 
260
260
  // ---------------------------------------------------------------------------
261
- // Page HTML wrapper
261
+ // Page HTML wrapper — shared script builders
262
262
  // ---------------------------------------------------------------------------
263
263
 
264
+ /**
265
+ * Build the inline script that exposes window.__PULSE_NONCE__ and (optionally)
266
+ * window.__PULSE_STORE__ to the client runtime.
267
+ *
268
+ * Only emitted for hydrated pages. Purely server-rendered pages have no JS to
269
+ * read these globals, and omitting the inline script keeps cached HTML nonce-free
270
+ * so it can be served with a nonce-free CSP without a CSP violation.
271
+ */
272
+ export function buildStoreScript(spec, storeState, nonce) {
273
+ if (!spec.hydrate) return ''
274
+ if (storeState && Object.keys(storeState).length > 0) {
275
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
276
+ }
277
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
278
+ }
279
+
280
+ /**
281
+ * Build the hydration <script> tag that mounts the spec client-side.
282
+ *
283
+ * Bundle paths (/dist/) → a single external <script type="module" src="..."> tag.
284
+ * Dev source paths → an inline module block with explicit imports (nonce required).
285
+ */
286
+ export function buildHydrateScript(spec, storeDef, nonce) {
287
+ if (!spec.hydrate) return ''
288
+ if (isBundle(spec.hydrate)) {
289
+ return `<script type="module" src="${esc(spec.hydrate)}"></script>`
290
+ }
291
+ const storeImport = storeDef?.hydrate ? `\n import store from '${esc(storeDef.hydrate)}'` : ''
292
+ const storeArg = storeDef?.hydrate ? ', { ssr: true, store }' : ', { ssr: true }'
293
+ return `<script type="module" nonce="${nonce}">
294
+ import spec from '${esc(spec.hydrate)}'
295
+ import { mount } from '/@pulse/runtime/index.js'
296
+ import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
297
+ const root = document.getElementById('pulse-root')
298
+ mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
299
+ initNavigation(root, mount)
300
+ </script>`
301
+ }
302
+
264
303
  /**
265
304
  * Wrap rendered content in a full HTML document.
266
305
  *
@@ -320,38 +359,8 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
320
359
  ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
321
360
  : ''
322
361
 
323
- // Serialise store state so the client store singleton can be initialised.
324
- // Also exposes window.__updatePulseStore__ so navigate.js can refresh the
325
- // singleton with fresh server data on client-side navigations.
326
- // window.__PULSE_NONCE__ lets the toast runtime inject a nonce'd <style> tag
327
- // to satisfy the style-src CSP directive.
328
- const storeStateScript = storeState && Object.keys(storeState).length > 0
329
- ? `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
330
- : `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
331
-
332
- // Hydration bootstrap — makes the server-rendered HTML interactive.
333
- // When hydrate points to a self-executing bundle (/dist/…) a single <script>
334
- // tag is enough; the bundle imports spec + runtime and calls mount() itself.
335
- // In dev mode (source file path) we emit the explicit inline import block.
336
- const storeImport = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
337
- ? `\n import store from '${esc(storeDef.hydrate)}'`
338
- : ''
339
- const storeArg = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
340
- ? ', { ssr: true, store }'
341
- : ', { ssr: true }'
342
-
343
- const hydrateScript = spec.hydrate
344
- ? isBundle(spec.hydrate)
345
- ? `<script type="module" src="${esc(spec.hydrate)}"></script>`
346
- : `<script type="module" nonce="${nonce}">
347
- import spec from '${esc(spec.hydrate)}'
348
- import { mount } from '/@pulse/runtime/index.js'
349
- import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
350
- const root = document.getElementById('pulse-root')
351
- mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
352
- initNavigation(root, mount)
353
- </script>`
354
- : ''
362
+ const storeStateScript = buildStoreScript(spec, storeState, nonce)
363
+ const hydrateScript = buildHydrateScript(spec, storeDef, nonce)
355
364
 
356
365
  // Server-Timing header value (caller is responsible for setting the header)
357
366
  const serverTimingValue = timing.total !== undefined
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
21
21
 
22
22
  const _require = createRequire(import.meta.url)
23
23
  export const version = _require('../../package.json').version
24
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -92,7 +92,8 @@ function buildCsp(nonce, ext = {}) {
92
92
 
93
93
  /**
94
94
  * Build the nonce-free CSP for cached responses.
95
- * Safe because cached pages have no inline scripts.
95
+ * Safe because isCacheablePage() only returns true when spec.server, spec.store,
96
+ * and spec.hydrate are all absent — guaranteeing no inline nonce scripts in the HTML.
96
97
  * @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
97
98
  */
98
99
  function buildCachedCsp(ext = {}) {
@@ -304,6 +305,26 @@ function pageCacheTtl(spec, dev, defaultCache) {
304
305
  return cfg?.maxAge || 0
305
306
  }
306
307
 
308
+ /** Build the in-process page cache key scoped to route + request parameters. */
309
+ function buildCacheKey(spec, ctx) {
310
+ return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
311
+ }
312
+
313
+ /**
314
+ * True when a page's rendered HTML is safe to cache in-process.
315
+ *
316
+ * A page is NOT cacheable if it has any of:
317
+ * - spec.server — embeds a nonce'd __PULSE_SERVER__ inline script
318
+ * - spec.store — same
319
+ * - spec.hydrate — embeds a nonce'd __PULSE_NONCE__ inline script (for toasts)
320
+ *
321
+ * In all three cases the cached HTML would contain a nonce that doesn't match
322
+ * the nonce-free CSP used for cached responses, causing a CSP violation.
323
+ */
324
+ function isCacheablePage(spec) {
325
+ return !spec.server && !spec.store?.length && !spec.hydrate
326
+ }
327
+
307
328
  // ---------------------------------------------------------------------------
308
329
  // Dev import map — lets browser resolve @invisibleloop/pulse/* bare specifiers
309
330
  // ---------------------------------------------------------------------------
@@ -349,8 +370,7 @@ async function cachedRenderToString(spec, ctx, dev) {
349
370
  return renderToString(spec, ctx)
350
371
  }
351
372
 
352
- // Build a cache key scoped to this route + request parameters
353
- const key = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
373
+ const key = buildCacheKey(spec, ctx)
354
374
 
355
375
  const cached = serverDataCache.get(key)
356
376
  if (cached) return cached
@@ -840,9 +860,8 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
840
860
  * Checks the in-process page cache before rendering; stores result after.
841
861
  */
842
862
  async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
843
- const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
844
- // Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
845
- const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
863
+ const cacheKey = buildCacheKey(spec, ctx)
864
+ const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
846
865
 
847
866
  let html, serverTimingValue, fromCache = false
848
867
 
@@ -891,9 +910,8 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
891
910
  */
892
911
  async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
893
912
  // Serve from in-process page cache when available — skip streaming overhead.
894
- // Pages with spec.server or spec.store embed a nonce'd __PULSE_SERVER__ script so are never cached.
895
- const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
896
- const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
913
+ const cacheKey = buildCacheKey(spec, ctx)
914
+ const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
897
915
  const cached = ttl > 0 ? pageHtmlCache.get(cacheKey) : null
898
916
 
899
917
  if (cached) {
@@ -972,43 +990,19 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
972
990
  <body${bodyAttr}>
973
991
  <div id="pulse-root">`
974
992
 
975
- const storeImport = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
976
- ? `\n import store from '${escHtml(store.hydrate)}'`
977
- : ''
978
- const storeArg = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
979
- ? ', { ssr: true, store }'
980
- : ', { ssr: true }'
981
-
982
- const hydrateScript = spec.hydrate
983
- ? spec.hydrate.startsWith('/dist/')
984
- ? `\n <script type="module" src="${escHtml(spec.hydrate)}"></script>`
985
- : `\n <script type="module" nonce="${nonce}">
986
- import spec from '${escHtml(spec.hydrate)}'
987
- import { mount } from '/@pulse/runtime/index.js'
988
- import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
989
- const root = document.getElementById('pulse-root')
990
- mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
991
- initNavigation(root, mount)
992
- </script>`
993
- : ''
994
-
995
- const scriptTags = (meta.scripts || [])
993
+ const scriptTags = (meta.scripts || [])
996
994
  .map(src => ` <script src="${escHtml(src)}" defer></script>`)
997
995
  .join('\n')
996
+ const storeScript = buildStoreScript(spec, ctx.store || null, nonce)
997
+ const hydrateScript = buildHydrateScript(spec, store, nonce)
998
998
 
999
999
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
1000
1000
 
1001
- // Emit window.__PULSE_STORE__ so the client store singleton can be initialised.
1002
- // Also exposes __updatePulseStore__ for navigate.js to refresh store on navigation.
1003
- // window.__PULSE_NONCE__ lets the toast runtime inject a nonce'd <style> tag to
1004
- // satisfy the style-src CSP directive (which disallows uninonce'd inline styles).
1005
- const storeScript = ctx.store && Object.keys(ctx.store).length > 0
1006
- ? `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
1007
- : `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
1008
-
1009
1001
  const docClose = `
1010
1002
  </div>
1011
- ${scriptTags}${storeScript}${hydrateScript}
1003
+ ${scriptTags}
1004
+ ${storeScript}
1005
+ ${hydrateScript}
1012
1006
  ${resolvedExtraBody}
1013
1007
  </body>
1014
1008
  </html>`
package/src/ui/icons.js CHANGED
@@ -1,7 +1,20 @@
1
1
  /**
2
2
  * Pulse UI — Icon set
3
3
  *
4
- * ~55 curated icons. All are pure functions returning an SVG string.
4
+ * SVG paths are derived from Lucide (https://lucide.dev) and Feather Icons.
5
+ *
6
+ * Lucide is licensed under the ISC License:
7
+ * Copyright (c) 2022 Lucide Contributors
8
+ * Permission to use, copy, modify, and/or distribute this software for any
9
+ * purpose with or without fee is hereby granted, provided that the above
10
+ * copyright notice and this permission notice appear in all copies.
11
+ *
12
+ * Portions derived from Feather Icons, licensed under the MIT License:
13
+ * Copyright (c) 2013-2017 Cole Bemis
14
+ *
15
+ * Phosphor hand-pointer icons (MIT): Copyright (c) 2020 Phosphor Icons
16
+ *
17
+ * Icon functions are pure functions returning an SVG string.
5
18
  * Style: 24×24 viewBox, stroke="currentColor", 2px stroke-width (Lucide-compatible).
6
19
  *
7
20
  * Usage:
@@ -159,3 +172,36 @@ export const iconLoader = (o) => s('<line x1="12" y1="2" x2="12" y2="6"/
159
172
  export const iconGrid = (o) => s('<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>', opts(o))
160
173
  export const iconBug = (o) => s('<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/>', opts(o))
161
174
  export const iconMapPin = (o) => s('<path d="M20 10c0 6-8 13-8 13s-8-7-8-13a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>', opts(o))
175
+
176
+ // ─── Ecommerce ───────────────────────────────────────────────────────────────
177
+
178
+ export const iconShoppingCart = (o) => s('<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>', opts(o))
179
+ export const iconShoppingBag = (o) => s('<path d="M16 10a4 4 0 0 1-8 0"/><path d="M3.103 6.034h17.794"/><path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z"/>', opts(o))
180
+ export const iconCreditCard = (o) => s('<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>', opts(o))
181
+ export const iconPackage = (o) => s('<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m7.5 4.27 9 5.15"/><polyline points="3.29 7 12 12 20.71 7"/>', opts(o))
182
+ export const iconGift = (o) => s('<path d="M12 7v14"/><path d="M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8"/><path d="M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5"/><rect x="3" y="7" width="18" height="4" rx="1"/>', opts(o))
183
+ export const iconWallet = (o) => s('<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>', opts(o))
184
+ export const iconTruck = (o) => s('<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>', opts(o))
185
+ export const iconReceipt = (o) => s('<path d="M12 17V7"/><path d="M16 8h-6a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H8"/><path d="M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z"/>', opts(o))
186
+ export const iconStore = (o) => s('<path d="M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5"/><path d="M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244"/><path d="M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05"/>', opts(o))
187
+ export const iconPercent = (o) => s('<line x1="19" x2="5" y1="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>', opts(o))
188
+ export const iconTicket = (o) => s('<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>', opts(o))
189
+ export const iconBanknote = (o) => s('<rect width="20" height="12" x="2" y="6" rx="2"/><circle cx="12" cy="12" r="2"/><path d="M6 12h.01M18 12h.01"/>', opts(o))
190
+
191
+ // ─── Food & Drink ─────────────────────────────────────────────────────────────
192
+
193
+ export const iconUtensils = (o) => s('<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>', opts(o))
194
+ export const iconCoffee = (o) => s('<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>', opts(o))
195
+ export const iconPizza = (o) => s('<path d="m12 14-1 1"/><path d="m13.75 18.25-1.25 1.42"/><path d="M17.775 5.654a15.68 15.68 0 0 0-12.121 12.12"/><path d="M18.8 9.3a1 1 0 0 0 2.1 7.7"/><path d="M21.964 20.732a1 1 0 0 1-1.232 1.232l-18-5a1 1 0 0 1-.695-1.232A19.68 19.68 0 0 1 15.732 2.037a1 1 0 0 1 1.232.695z"/>', opts(o))
196
+ export const iconApple = (o) => s('<path d="M12 6.528V3a1 1 0 0 1 1-1h0"/><path d="M18.237 21A15 15 0 0 0 22 11a6 6 0 0 0-10-4.472A6 6 0 0 0 2 11a15.1 15.1 0 0 0 3.763 10 3 3 0 0 0 3.648.648 5.5 5.5 0 0 1 5.178 0A3 3 0 0 0 18.237 21"/>', opts(o))
197
+ export const iconCarrot = (o) => s('<path d="M2.27 21.7s9.87-3.5 12.73-6.36a4.5 4.5 0 0 0-6.36-6.37C5.77 11.84 2.27 21.7 2.27 21.7zM8.64 14l-2.05-2.04M15.34 15l-2.46-2.46"/><path d="M22 9s-1.33-2-3.5-2C16.86 7 15 9 15 9s1.33 2 3.5 2S22 9 22 9z"/><path d="M15 2s-2 1.33-2 3.5S15 9 15 9s2-1.84 2-3.5C17 3.33 15 2 15 2z"/>', opts(o))
198
+ export const iconWine = (o) => s('<path d="M8 22h8"/><path d="M7 10h10"/><path d="M12 15v7"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/>', opts(o))
199
+ export const iconCakeSlice = (o) => s('<path d="M16 13H3"/><path d="M16 17H3"/><path d="m7.2 7.9-3.388 2.5A2 2 0 0 0 3 12.01V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-8.654c0-2-2.44-6.026-6.44-8.026a1 1 0 0 0-1.082.057L10.4 5.6"/><circle cx="9" cy="7" r="2"/>', opts(o))
200
+ export const iconFish = (o) => s('<path d="M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6Z"/><path d="M18 12v.5"/><path d="M16 17.93a9.77 9.77 0 0 1 0-11.86"/><path d="M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33"/><path d="M10.46 7.26C10.2 5.88 9.17 4.24 8 3h5.8a2 2 0 0 1 1.98 1.67l.23 1.4"/><path d="m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98"/>', opts(o))
201
+ export const iconCherry = (o) => s('<path d="M2 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3-2.5-2-5 .24-5 3Z"/><path d="M12 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3-2.5-2-5 .24-5 3Z"/><path d="M7 14c3.22-2.91 4.29-8.75 5-12 1.66 2.38 4.94 9 5 12"/><path d="M22 9c-4.29 0-7.14-2.33-10-7 5.71 0 10 4.67 10 7Z"/>', opts(o))
202
+ export const iconEgg = (o) => s('<path d="M12 2C8 2 4 8 4 14a8 8 0 0 0 16 0c0-6-4-12-8-12"/>', opts(o))
203
+ export const iconCookie = (o) => s('<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/><path d="M12 12v.01"/><path d="M11 17v.01"/><path d="M7 14v.01"/>', opts(o))
204
+ export const iconIceCream = (o) => s('<path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11"/><path d="M17 7A5 5 0 0 0 7 7"/><path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4"/>', opts(o))
205
+ export const iconCroissant = (o) => s('<path d="M10.2 18H4.774a1.5 1.5 0 0 1-1.352-.97 11 11 0 0 1 .132-6.487"/><path d="M18 10.2V4.774a1.5 1.5 0 0 0-.97-1.352 11 11 0 0 0-6.486.132"/><path d="M18 5a4 3 0 0 1 4 3 2 2 0 0 1-2 2 10 10 0 0 0-5.139 1.42"/><path d="M5 18a3 4 0 0 0 3 4 2 2 0 0 0 2-2 10 10 0 0 1 1.42-5.14"/><path d="M8.709 2.554a10 10 0 0 0-6.155 6.155 1.5 1.5 0 0 0 .676 1.626l9.807 5.42a2 2 0 0 0 2.718-2.718l-5.42-9.807a1.5 1.5 0 0 0-1.626-.676"/>', opts(o))
206
+ export const iconSalad = (o) => s('<path d="M7 21h10"/><path d="M12 21a9 9 0 0 0 9-9H3a9 9 0 0 0 9 9Z"/><path d="M11.38 12a2.4 2.4 0 0 1-.4-4.77 2.4 2.4 0 0 1 3.2-2.77 2.4 2.4 0 0 1 3.47-.63 2.4 2.4 0 0 1 3.37 3.37 2.4 2.4 0 0 1-1.1 3.7 2.51 2.51 0 0 1 .03 1.1"/><path d="m13 12 4-4"/><path d="M10.9 7.25A3.99 3.99 0 0 0 4 10c0 .73.2 1.41.54 2"/>', opts(o))
207
+ export const iconWheat = (o) => s('<path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z"/><path d="M11.47 17.47 13 19l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L5 19l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z"/><path d="M15.47 13.47 17 15l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L9 15l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z"/><path d="M19.47 9.47 21 11l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L13 11l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z"/>', opts(o))
package/src/ui/index.js CHANGED
@@ -86,4 +86,9 @@ export {
86
86
  iconHandPointUp, iconHandPointDown, iconHandPointLeft, iconHandPointRight,
87
87
  iconGlobe, iconShield, iconZap, iconTrendingUp, iconTrendingDown, iconLoader, iconGrid, iconBug, iconMapPin,
88
88
  iconSun, iconMoon,
89
+ iconShoppingCart, iconShoppingBag, iconCreditCard, iconPackage, iconGift,
90
+ iconWallet, iconTruck, iconReceipt, iconStore, iconPercent, iconTicket, iconBanknote,
91
+ iconUtensils, iconCoffee, iconPizza, iconApple, iconCarrot,
92
+ iconWine, iconCakeSlice, iconFish, iconCherry, iconEgg,
93
+ iconCookie, iconIceCream, iconCroissant, iconSalad, iconWheat,
89
94
  } from './icons.js'