@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 +33 -2
- package/docs/public/.pulse-ui-version +1 -1
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +14 -0
- package/src/cli/dev.js +8 -8
- package/src/cli/start.js +3 -0
- package/src/server/index.js +10 -8
- package/src/server/server.test.js +51 -18
- package/src/ui/ui.test.js +40 -0
- package/src/ui/uiimage.js +19 -14
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) =>
|
|
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.
|
|
1
|
+
0.2.5
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
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
|
-
|
|
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
|
})
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
1396
|
-
|
|
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([
|
|
433
|
-
const { status, headers } = await request(port, 'GET', '/
|
|
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([
|
|
441
|
-
const { body } = await request(port, 'GET', '/
|
|
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',
|
|
445
|
-
assert(meta.title === '
|
|
446
|
-
assert(meta.hydrate === '/examples/
|
|
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([
|
|
453
|
-
const { body } = await request(port, 'GET', '/
|
|
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('
|
|
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([
|
|
464
|
-
const { body } = await request(port, 'GET', '/
|
|
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,
|
|
468
|
-
assert(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
|
|
28
|
-
pill
|
|
29
|
-
width
|
|
30
|
-
height
|
|
31
|
-
maxWidth
|
|
32
|
-
|
|
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
|
|
44
|
-
const heightAttr
|
|
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="
|
|
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="
|
|
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>`
|