@weft-js/plugin-media 0.1.1 → 0.1.4

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/src/build.mjs CHANGED
@@ -1,10 +1,9 @@
1
- // media.build@1 (research 15 §2–4, research 26 §2.4): one source file in, a MediaAsset and its files out.
2
1
  // probe (sharp, EXIF orientation) → width ladder → per-format quality search at the largest width (SSIMULACRA2,
3
2
  // the libjxl tool) → encode every width → dominant colour + 16 px WebP placeholder. SVG: refuse unsafe files
4
3
  // (M012), then SVGO. With a media.encode@1 provider (@weft-js/plugin-media-ffmpeg), src/video.mjs takes video, audio
5
4
  // and animated GIFs; without one, a GIF stays as it is and video is P101.
6
5
  // Cached by content hash under `cacheDir`, so dev restarts and rebuilds don't re-encode. With `encoder: 'server'` the
7
- // build only stores the original: the variants are media.server@1's to encode (research 29 §6).
6
+ // build only stores the original: the variants are media.server@1's to encode.
8
7
  import { execFile, spawnSync } from 'node:child_process'
9
8
  import { createHash } from 'node:crypto'
10
9
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
@@ -15,7 +14,7 @@ import sharp from 'sharp'
15
14
  import { optimize } from 'svgo'
16
15
  import { probe } from '@weft-js/core/media-probe'
17
16
  import { createMediaServer } from './server.mjs'
18
- import { DEFAULTS, FIXED, encoders, hex, isPhoto, MIME } from './shared.mjs'
17
+ import { DEFAULTS, FIXED, encoders, hex, isPhoto, MIME, withPlaceholder } from './shared.mjs'
19
18
  import { unsafeSvg } from './svg.mjs'
20
19
  import { AUDIO, audioFile, gif, VIDEO, video } from './video.mjs'
21
20
 
@@ -40,10 +39,10 @@ export function findScorer(name) {
40
39
  }
41
40
 
42
41
  /** Lowest quality whose SSIMULACRA2 score is ≥ target, within 5 encodes (scores rise with quality). */
43
- async function search(format, base, width, scorer, target, dir) {
42
+ async function search({ format, base, width, scorer, target, dir }) {
44
43
  const ref = join(dir, 'ref.png')
45
44
  if (!existsSync(ref)) writeFileSync(ref, await base().resize({ width }).png().toBuffer())
46
- const score = async q => {
45
+ const score = async (q) => {
47
46
  const encoded = await encoders[format](base().resize({ width }), q).toBuffer()
48
47
  const out = join(dir, `${format}-${q}.png`)
49
48
  writeFileSync(out, await sharp(encoded).png().toBuffer())
@@ -59,11 +58,16 @@ async function search(format, base, width, scorer, target, dir) {
59
58
  return hi
60
59
  }
61
60
 
62
- const short = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 8)
61
+ const short = (bytes) => createHash('sha256').update(bytes).digest('hex').slice(0, 8)
63
62
 
64
63
  /** The media.build@1 provider: `build` takes one source file and gives back its asset and encoded files, cached by content. */
65
64
  export function createMediaBuild(input = {}) {
66
- const o = { ...DEFAULTS, ...input, quality: { ...DEFAULTS.quality, ...input.quality }, video: { ...DEFAULTS.video, ...input.video } }
65
+ const o = {
66
+ ...DEFAULTS,
67
+ ...input,
68
+ quality: { ...DEFAULTS.quality, ...input.quality },
69
+ video: { ...DEFAULTS.video, ...input.video },
70
+ }
67
71
  return {
68
72
  /**
69
73
  * @param {{ file: string, bytes: Uint8Array }} source
@@ -74,27 +78,53 @@ export function createMediaBuild(input = {}) {
74
78
  const timed = VIDEO.test(file) || AUDIO.test(file)
75
79
  if (o.encoder === 'server' && !timed) {
76
80
  try {
77
- return { asset: await createMediaServer(o).at(root, encode).ingest(bytes, { name }), files: [] }
81
+ return {
82
+ asset: await createMediaServer(o).at(root, encode).ingest(bytes, { name }),
83
+ files: [],
84
+ }
78
85
  } catch (e) {
79
86
  if (!e.code) throw e
80
- return { asset: null, files: [], diagnostics: [{ code: e.code, severity: 'error', message: e.message }] }
87
+ return {
88
+ asset: null,
89
+ files: [],
90
+ diagnostics: [{ code: e.code, severity: 'error', message: e.message }],
91
+ }
81
92
  }
82
93
  }
83
94
  const stem = basename(file, extname(file)).replace(/[^\w-]+/g, '-')
84
- const key = createHash('sha256').update(bytes).update(JSON.stringify(o)).update(String(VERSION)).update(audio ? readFileSync(audio) : '').update(JSON.stringify(await versions(encode))).digest('hex').slice(0, 20)
95
+ const key = createHash('sha256')
96
+ .update(bytes)
97
+ .update(JSON.stringify(o))
98
+ .update(String(VERSION))
99
+ .update(audio ? readFileSync(audio) : '')
100
+ .update(JSON.stringify(await versions(encode)))
101
+ .digest('hex')
102
+ .slice(0, 20)
85
103
  const cached = join(cacheDir, `${key}.json`)
86
104
  if (existsSync(cached)) {
87
105
  const hit = JSON.parse(readFileSync(cached, 'utf8'))
88
- return { ...hit, files: hit.files.map(n => ({ name: n, bytes: new Uint8Array(readFileSync(join(cacheDir, key, n))) })) }
106
+ return {
107
+ ...hit,
108
+ files: hit.files.map((n) => ({
109
+ name: n,
110
+ bytes: new Uint8Array(readFileSync(join(cacheDir, key, n))),
111
+ })),
112
+ }
89
113
  }
90
114
 
91
115
  const source = { bytes, name, stem, audio }
92
116
  const scorer = findScorer(o.quality.scorer)
93
- const out = await (/\.svg$/i.test(file) ? vector(name, stem, bytes) : VIDEO.test(file) ? video(source, o, encode, scorer) : AUDIO.test(file) ? audioFile(source, o, encode) : raster(source, o, encode))
94
- if (out.diagnostics?.some(d => d.severity === 'error')) return out
117
+ const out = await (/\.svg$/i.test(file)
118
+ ? vector(name, stem, bytes)
119
+ : VIDEO.test(file)
120
+ ? video(source, o, encode, scorer)
121
+ : AUDIO.test(file)
122
+ ? audioFile(source, o, encode)
123
+ : raster(source, o, encode))
124
+ if (out.diagnostics?.some((d) => d.severity === 'error')) return out
95
125
  mkdirSync(join(cacheDir, key), { recursive: true })
96
126
  for (const f of out.files) writeFileSync(join(cacheDir, key, f.name), f.bytes)
97
- writeFileSync(cached, JSON.stringify({ ...out, files: out.files.map(f => f.name) }))
127
+ writeFileSync(cached, JSON.stringify({ ...out, files: out.files.map((f) => f.name) }))
98
128
  return out
99
129
  },
100
130
  }
@@ -103,7 +133,8 @@ export function createMediaBuild(input = {}) {
103
133
  /** What the encoders are: a new ffmpeg means new bytes. */
104
134
  async function versions(encode) {
105
135
  const found = {}
106
- for (const [key, enc] of Object.entries(encode)) found[key] = await enc.versions().catch(() => null)
136
+ for (const [key, enc] of Object.entries(encode))
137
+ found[key] = await enc.versions().catch(() => null)
107
138
  return found
108
139
  }
109
140
 
@@ -111,12 +142,82 @@ async function versions(encode) {
111
142
  function vector(name, stem, bytes) {
112
143
  const text = new TextDecoder().decode(bytes)
113
144
  const unsafe = unsafeSvg(text)
114
- if (unsafe.length) return { asset: null, files: [], diagnostics: [{ code: 'M012', severity: 'error', message: `${name} has ${unsafe.join(', ')}: an image with active content or external references is not optimised or served. Sanitise the file.` }] }
145
+ if (unsafe.length)
146
+ return {
147
+ asset: null,
148
+ files: [],
149
+ diagnostics: [
150
+ {
151
+ code: 'M012',
152
+ severity: 'error',
153
+ message: `${name} has ${unsafe.join(', ')}: an image with active content or external references is not optimised or served. Sanitise the file.`,
154
+ },
155
+ ],
156
+ }
115
157
  const size = probe(bytes, name)
116
- if (!size) return { asset: null, files: [], diagnostics: [{ code: 'M001', severity: 'error', message: `${name} has no width/height or viewBox, so its size is unknown.` }] }
158
+ if (!size)
159
+ return {
160
+ asset: null,
161
+ files: [],
162
+ diagnostics: [
163
+ {
164
+ code: 'M001',
165
+ severity: 'error',
166
+ message: `${name} has no width/height or viewBox, so its size is unknown.`,
167
+ },
168
+ ],
169
+ }
117
170
  const svg = new TextEncoder().encode(optimize(text, { multipass: true }).data)
118
171
  const fileName = `${stem}-${short(svg)}.svg`
119
- return { asset: { kind: 'vector', src: fileName, width: size.width, height: size.height, type: MIME.svg, sources: [], name, quality: 'original' }, files: [{ name: fileName, bytes: svg }] }
172
+ return {
173
+ asset: {
174
+ kind: 'vector',
175
+ src: fileName,
176
+ width: size.width,
177
+ height: size.height,
178
+ type: MIME.svg,
179
+ sources: [],
180
+ name,
181
+ quality: 'original',
182
+ },
183
+ files: [{ name: fileName, bytes: svg }],
184
+ }
185
+ }
186
+
187
+ async function animatedGif(
188
+ { bytes, name, stem, audio, meta, width, height },
189
+ o,
190
+ encode,
191
+ diagnostics,
192
+ ) {
193
+ const converted = await gif(
194
+ { bytes, name, stem, audio, meta, width, height },
195
+ o,
196
+ encode,
197
+ findScorer(o.quality.scorer),
198
+ )
199
+ if (converted) return converted
200
+ if (bytes.length > o.gifLimit)
201
+ diagnostics.push({
202
+ code: 'M004',
203
+ severity: 'warning',
204
+ message: `${name} is a ${Math.round(bytes.length / 1024)} KB animated GIF, and it ships as is${encode['video/h264'] ? ': nothing beat it by 15%' : '. Add `mediaFfmpeg()` from @weft-js/plugin-media-ffmpeg to turn it into video'}.`,
205
+ })
206
+ const fileName = `${stem}-${short(bytes)}.gif`
207
+ return {
208
+ asset: {
209
+ kind: 'graphic',
210
+ src: fileName,
211
+ width,
212
+ height,
213
+ type: MIME.gif,
214
+ sources: [],
215
+ name,
216
+ quality: 'original',
217
+ },
218
+ files: [{ name: fileName, bytes }],
219
+ diagnostics,
220
+ }
120
221
  }
121
222
 
122
223
  async function raster({ bytes, name, stem, audio }, o, encode) {
@@ -126,19 +227,14 @@ async function raster({ bytes, name, stem, audio }, o, encode) {
126
227
  const height = turned ? (meta.pageHeight ?? meta.width) : (meta.pageHeight ?? meta.height)
127
228
  const diagnostics = []
128
229
 
129
- if (meta.format === 'gif' && (meta.pages ?? 1) > 1) {
130
- const converted = await gif({ bytes, name, stem, audio, meta, width, height }, o, encode, findScorer(o.quality.scorer))
131
- if (converted) return converted
132
- if (bytes.length > o.gifLimit) diagnostics.push({ code: 'M004', severity: 'warning', message: `${name} is a ${Math.round(bytes.length / 1024)} KB animated GIF, and it ships as is${encode['video/h264'] ? ': nothing beat it by 15%' : '. Add `mediaFfmpeg()` from @weft-js/plugin-media-ffmpeg to turn it into video'}.` })
133
- const fileName = `${stem}-${short(bytes)}.gif`
134
- return { asset: { kind: 'graphic', src: fileName, width, height, type: MIME.gif, sources: [], name, quality: 'original' }, files: [{ name: fileName, bytes }], diagnostics }
135
- }
230
+ if (meta.format === 'gif' && (meta.pages ?? 1) > 1)
231
+ return animatedGif({ bytes, name, stem, audio, meta, width, height }, o, encode, diagnostics)
136
232
 
137
233
  const photo = isPhoto(meta)
138
234
  const fallback = photo ? 'jpeg' : 'png'
139
- const formats = [...new Set(o.formats.filter(f => f !== fallback)), fallback]
235
+ const formats = [...new Set(o.formats.filter((f) => f !== fallback)), fallback]
140
236
  const largest = Math.min(width, o.widths.at(-1))
141
- const ladder = [...new Set([...o.widths.filter(w => w < largest), largest])]
237
+ const ladder = [...new Set([...o.widths.filter((w) => w < largest), largest])]
142
238
  const base = () => sharp(bytes).rotate()
143
239
 
144
240
  const scorer = findScorer(o.quality.scorer)
@@ -147,7 +243,9 @@ async function raster({ bytes, name, stem, audio }, o, encode) {
147
243
  try {
148
244
  for (const f of formats) {
149
245
  if (f === 'png') continue
150
- qualities[f] = scorer ? await search(f, base, largest, scorer, o.quality.target, dir) : FIXED[f]
246
+ qualities[f] = scorer
247
+ ? await search({ format: f, base, width: largest, scorer, target: o.quality.target, dir })
248
+ : FIXED[f]
151
249
  }
152
250
  } finally {
153
251
  if (dir) rmSync(dir, { recursive: true, force: true })
@@ -167,16 +265,30 @@ async function raster({ bytes, name, stem, audio }, o, encode) {
167
265
  }
168
266
  const src = sources.at(-1).srcset.split(', ').at(-1).split(' ')[0]
169
267
 
170
- const asset = { kind: photo ? 'photo' : 'graphic', src, width, height, type: MIME[fallback], sources, name, quality: scorer ? 'ssimulacra2' : 'fixed', qualities }
171
- if (o.encoder === 'compatible') { // The fallback ladder is already built: a swap costs no extra files.
268
+ const asset = {
269
+ kind: photo ? 'photo' : 'graphic',
270
+ src,
271
+ width,
272
+ height,
273
+ type: MIME[fallback],
274
+ sources,
275
+ name,
276
+ quality: scorer ? 'ssimulacra2' : 'fixed',
277
+ qualities,
278
+ }
279
+ if (o.encoder === 'compatible') {
280
+ // The fallback ladder is already built: a swap costs no extra files.
172
281
  asset.copy = { src, type: MIME[fallback] }
173
282
  asset.swap = o.swap ?? 'intent'
174
- diagnostics.push({ code: 'M014', severity: 'info', message: `${name}: \`encoder: 'compatible'\` adds 0 KB: the compatible copy is the fallback ladder.` })
283
+ diagnostics.push({
284
+ code: 'M014',
285
+ severity: 'info',
286
+ message: `${name}: \`encoder: 'compatible'\` adds 0 KB: the compatible copy is the fallback ladder.`,
287
+ })
175
288
  }
176
289
  if (o.placeholder) {
177
290
  asset.dominant = hex((await base().stats()).dominant)
178
- const tiny = await base().resize(16, 16, { fit: 'inside' }).webp({ quality: 40 }).toBuffer()
179
- asset.placeholder = `data:image/webp;base64,${tiny.toString('base64')}`
291
+ await withPlaceholder(asset, files, base(), stem, o)
180
292
  }
181
293
  return { asset, files, diagnostics }
182
294
  }
package/src/check.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // M001–M013 in `weft check` (research 15 §7.5; the static rules of research 26 §2.5). Runs as this plugin's `check`
1
+ // M001–M013 in `weft check`. Runs as this plugin's `check`
2
2
  // hook over the project's .tsx/.jsx files, with the JSX view `weft check` passes in `ctx`.
3
3
  import { basename, dirname, extname, join, normalize } from 'node:path'
4
4
  import { probe } from '@weft-js/core/media-probe'
@@ -8,7 +8,211 @@ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)$/i
8
8
  const MEDIA = new Set(['img', 'video', 'Media'])
9
9
  const ROUTE = /(^|\/)src\/routes\//
10
10
 
11
- const rel = (from, spec) => normalize(join(dirname(from), spec)).split('\\').join('/')
11
+ const rel = (from, spec) =>
12
+ normalize(join(dirname(from), spec))
13
+ .split('\\')
14
+ .join('/')
15
+
16
+ function checkSource({ el, src, size, sized, raw, report }) {
17
+ // M016: a string has no dimensions until it loads
18
+ if (!raw && (src?.kind === 'string' || /^[`'"]/.test(src?.expr ?? '')))
19
+ report(
20
+ 'M016',
21
+ el,
22
+ '`<Media src>` is a string, so nothing knows its size before it loads and the page shifts.',
23
+ {
24
+ description:
25
+ 'Import it with `?media`, or store the record `ctx.media.ingest` returns and type the field `z.media()`',
26
+ safe: false,
27
+ },
28
+ )
29
+
30
+ // M001: raw media with no box (X030 covers it inside a Boundary)
31
+ if (raw && !sized && !el.ancestors.includes('Boundary')) {
32
+ report(
33
+ 'M001',
34
+ el,
35
+ `<${el.name}> has no \`width\`/\`height\` or \`aspect-ratio\`: it renders 0 px tall and pushes the page down when it loads.`,
36
+ size
37
+ ? {
38
+ description: `Insert the file's size (${size.width}×${size.height})`,
39
+ safe: true,
40
+ edits: [
41
+ {
42
+ start: el.tagEnd,
43
+ end: el.tagEnd,
44
+ text: ` width={${size.width}} height={${size.height}}`,
45
+ },
46
+ ],
47
+ }
48
+ : undefined,
49
+ )
50
+ }
51
+ }
52
+
53
+ function checkImport({ el, a, src, imp, spec, query, target, size, ctx, file, report }) {
54
+ // M002: through Vite's URL import or a string path to a project file
55
+ if (el.name === 'img' && imp && IMAGE.test(spec) && query !== 'media') {
56
+ report(
57
+ 'M002',
58
+ el,
59
+ `\`${imp.source}\` is the untouched file: no resizing, no AVIF, no placeholder.`,
60
+ {
61
+ description: 'Use ?media and <Media>',
62
+ safe: false,
63
+ edits: [
64
+ { start: imp.start, end: imp.end, text: `import ${imp.local} from '${spec}?media'` },
65
+ ],
66
+ },
67
+ )
68
+ } else if (
69
+ el.name === 'img' &&
70
+ src?.kind === 'string' &&
71
+ !/^(https?:|data:|\/\/)/.test(src.value) &&
72
+ !src.value.startsWith('/') &&
73
+ IMAGE.test(src.value) &&
74
+ ctx.exists(rel(file, src.value))
75
+ ) {
76
+ report(
77
+ 'M002',
78
+ el,
79
+ `\`${src.value}\` is a project file referenced by string: no resizing, no AVIF, no dimensions.`,
80
+ )
81
+ }
82
+ // M003: oversize for its rendered width
83
+ const w = Number(a('width')?.value ?? a('width')?.expr)
84
+ if (el.name === 'img' && size && w > 0 && size.width > w * 2 * 1.25) {
85
+ report(
86
+ 'M003',
87
+ el,
88
+ `${basename(target)} is ${size.width} px wide for a ${w} px slot: ${Math.round((size.width / (w * 2)) * 100) / 100}× what a 2× screen needs.`,
89
+ )
90
+ }
91
+ }
92
+
93
+ function checkMotion({ el, a, src, target, bytes, gifLimit, report }) {
94
+ // M004 and M011: GIFs in raw <img>
95
+ const gif =
96
+ (target && /\.gif$/i.test(target)) || (src?.kind === 'string' && /\.gif$/i.test(src.value))
97
+ if (el.name === 'img' && gif && bytes && bytes.length > gifLimit)
98
+ report('M004', el, `${basename(target)} is ${Math.round(bytes.length / 1024)} KB of GIF.`)
99
+ if (el.name === 'img' && gif)
100
+ report(
101
+ 'M011',
102
+ el,
103
+ 'An animated GIF in a raw <img> loops forever with no pause control (WCAG 2.2.2).',
104
+ )
105
+ // M005, M006, M011: video
106
+ if (el.name === 'video') {
107
+ if (a('autoplay') && !a('muted'))
108
+ report(
109
+ 'M005',
110
+ el,
111
+ '<video autoplay> without `muted`: browsers block it, and sound before a gesture is hostile.',
112
+ {
113
+ description: 'Add muted',
114
+ safe: true,
115
+ edits: [{ start: el.tagEnd, end: el.tagEnd, text: ' muted' }],
116
+ },
117
+ )
118
+ if (!a('poster'))
119
+ report('M006', el, '<video> without `poster` is a blank box until its first frame decodes.')
120
+ if (a('autoplay') && a('loop') && !a('controls'))
121
+ report(
122
+ 'M011',
123
+ el,
124
+ '<video autoplay loop> without `controls` moves forever with no way to pause it (WCAG 2.2.2).',
125
+ )
126
+ }
127
+ }
128
+
129
+ function checkAlt({ el, a, src, imp, target, report }) {
130
+ // M007: alt
131
+ if (el.name !== 'video') {
132
+ const alt = a('alt')
133
+ const stems = [
134
+ imp?.local,
135
+ target && basename(target),
136
+ target && basename(target, extname(target)),
137
+ src?.kind === 'string' && basename(src.value),
138
+ ]
139
+ .filter(Boolean)
140
+ .map((s) => s.toLowerCase())
141
+ if (!alt) report('M007', el, `<${el.name}> has no \`alt\`.`)
142
+ else if (el.name === 'Media' && alt.kind === 'string' && alt.value === '' && !a('decorative'))
143
+ report(
144
+ 'M007',
145
+ el,
146
+ '`alt=""` hides the image from screen readers: say so with `decorative`, or describe it.',
147
+ )
148
+ else if (alt.kind === 'string' && alt.value && stems.includes(alt.value.toLowerCase()))
149
+ report(
150
+ 'M007',
151
+ el,
152
+ `\`alt="${alt.value}"\` is the file name, which tells nobody what the image shows.`,
153
+ )
154
+ }
155
+ }
156
+
157
+ function checkPriority({ el, a, route, i, priorities, report }) {
158
+ // M008, M009, M010: priority (route files: the likely LCP image)
159
+ const lazy = a('loading')?.value === 'lazy'
160
+ const priority = !!a('priority') || a('fetchpriority')?.value === 'high'
161
+ if (priority && lazy)
162
+ report('M008', el, '`priority` media with `loading="lazy"` loads late anyway.', {
163
+ description: 'Remove loading="lazy"',
164
+ safe: true,
165
+ edits: [{ start: a('loading').start - 1, end: a('loading').end, text: '' }],
166
+ })
167
+ else if (route && i === 0 && lazy)
168
+ report(
169
+ 'M008',
170
+ el,
171
+ `The route's first media is \`loading="lazy"\`: it is the likely LCP image, and lazy delays it.`,
172
+ )
173
+ if (route && priority && ++priorities > 2) {
174
+ const p = a('priority') ?? a('fetchpriority')
175
+ report(
176
+ 'M009',
177
+ el,
178
+ `More than 2 \`priority\` media in this route: they compete for the first bytes.`,
179
+ {
180
+ description: 'Remove priority',
181
+ safe: true,
182
+ edits: [{ start: p.start - 1, end: p.end, text: '' }],
183
+ },
184
+ )
185
+ }
186
+ if (el.name === 'Media' && a('priority') && !a('width') && !a('sizes'))
187
+ report(
188
+ 'M010',
189
+ el,
190
+ '`priority` makes this image eager, and eager images can\'t use `sizes="auto"`: the browser assumes 100vw and fetches the largest file.',
191
+ )
192
+ return priorities
193
+ }
194
+
195
+ function checkSecurity({ el, src, target, bytes, svgs, ctx, report }) {
196
+ // M012: unsafe SVG used as an image
197
+ if (target && /\.svg$/i.test(target) && bytes) {
198
+ if (!svgs.has(target)) svgs.set(target, unsafeSvg(new TextDecoder().decode(bytes)))
199
+ if (svgs.get(target).length)
200
+ report('M012', el, `${basename(target)} has ${svgs.get(target).join(', ')}.`)
201
+ }
202
+ // M013: public/ by string
203
+ if (
204
+ src?.kind === 'string' &&
205
+ src.value.startsWith('/') &&
206
+ !src.value.startsWith('//') &&
207
+ ctx.exists(`public${src.value.split('?')[0]}`)
208
+ ) {
209
+ report(
210
+ 'M013',
211
+ el,
212
+ `\`${src.value}\` is served from public/: untracked, unoptimised, with no dimensions.`,
213
+ )
214
+ }
215
+ }
12
216
 
13
217
  /** @param {{ files: string[], read: (f: string) => string, jsx: Function, importOf: Function, exists: (f: string) => boolean, bytes: (f: string) => Uint8Array | undefined }} ctx */
14
218
  export function checkMedia(ctx, { gifLimit = 256 * 1024 } = {}) {
@@ -17,13 +221,14 @@ export function checkMedia(ctx, { gifLimit = 256 * 1024 } = {}) {
17
221
  for (const file of ctx.files) {
18
222
  if (!/\.[jt]sx$/.test(file)) continue
19
223
  const scan = ctx.jsx(file)
20
- const report = (code, el, why, fix) => out.push({ code, file, start: el.start, end: el.tagEnd, why, ...(fix && { fix }) })
21
- const media = scan.elements.filter(e => MEDIA.has(e.name))
224
+ const report = (code, el, why, fix) =>
225
+ out.push({ code, file, start: el.start, end: el.tagEnd, why, ...(fix && { fix }) })
226
+ const media = scan.elements.filter((e) => MEDIA.has(e.name))
22
227
  const route = ROUTE.test(file)
23
228
  let priorities = 0
24
229
 
25
230
  for (const [i, el] of media.entries()) {
26
- const a = n => el.attr(n)
231
+ const a = (n) => el.attr(n)
27
232
  const src = a('src')
28
233
  const imp = src?.kind === 'expr' ? ctx.importOf(scan, src.expr) : undefined
29
234
  const [spec, query = ''] = imp ? imp.source.split('?') : []
@@ -34,63 +239,12 @@ export function checkMedia(ctx, { gifLimit = 256 * 1024 } = {}) {
34
239
  const sized = (a('width') && a('height')) || /aspect-?ratio/i.test(style)
35
240
  const raw = el.name !== 'Media'
36
241
 
37
- // M016: a string has no dimensions until it loads
38
- if (!raw && (src?.kind === 'string' || /^[`'"]/.test(src?.expr ?? '')))
39
- report('M016', el, '`<Media src>` is a string, so nothing knows its size before it loads and the page shifts.', { description: 'Import it with `?media`, or store the record `ctx.media.ingest` returns and type the field `z.media()`', safe: false })
40
-
41
- // M001: raw media with no box (X030 covers it inside a Boundary)
42
- if (raw && !sized && !el.ancestors.includes('Boundary')) {
43
- report('M001', el, `<${el.name}> has no \`width\`/\`height\` or \`aspect-ratio\`: it renders 0 px tall and pushes the page down when it loads.`,
44
- size ? { description: `Insert the file's size (${size.width}×${size.height})`, safe: true, edits: [{ start: el.tagEnd, end: el.tagEnd, text: ` width={${size.width}} height={${size.height}}` }] } : undefined)
45
- }
46
- // M002: through Vite's URL import or a string path to a project file
47
- if (el.name === 'img' && imp && IMAGE.test(spec) && query !== 'media') {
48
- report('M002', el, `\`${imp.source}\` is the untouched file: no resizing, no AVIF, no placeholder.`, { description: 'Use ?media and <Media>', safe: false, edits: [{ start: imp.start, end: imp.end, text: `import ${imp.local} from '${spec}?media'` }] })
49
- } else if (el.name === 'img' && src?.kind === 'string' && !/^(https?:|data:|\/\/)/.test(src.value) && !src.value.startsWith('/') && IMAGE.test(src.value) && ctx.exists(rel(file, src.value))) {
50
- report('M002', el, `\`${src.value}\` is a project file referenced by string: no resizing, no AVIF, no dimensions.`)
51
- }
52
- // M003: oversize for its rendered width
53
- const w = Number(a('width')?.value ?? a('width')?.expr)
54
- if (el.name === 'img' && size && w > 0 && size.width > w * 2 * 1.25) {
55
- report('M003', el, `${basename(target)} is ${size.width} px wide for a ${w} px slot: ${Math.round(size.width / (w * 2) * 100) / 100}× what a 2× screen needs.`)
56
- }
57
- // M004 and M011: GIFs in raw <img>
58
- const gif = (target && /\.gif$/i.test(target)) || (src?.kind === 'string' && /\.gif$/i.test(src.value))
59
- if (el.name === 'img' && gif && bytes && bytes.length > gifLimit) report('M004', el, `${basename(target)} is ${Math.round(bytes.length / 1024)} KB of GIF.`)
60
- if (el.name === 'img' && gif) report('M011', el, 'An animated GIF in a raw <img> loops forever with no pause control (WCAG 2.2.2).')
61
- // M005, M006, M011: video
62
- if (el.name === 'video') {
63
- if (a('autoplay') && !a('muted')) report('M005', el, '<video autoplay> without `muted`: browsers block it, and sound before a gesture is hostile.', { description: 'Add muted', safe: true, edits: [{ start: el.tagEnd, end: el.tagEnd, text: ' muted' }] })
64
- if (!a('poster')) report('M006', el, '<video> without `poster` is a blank box until its first frame decodes.')
65
- if (a('autoplay') && a('loop') && !a('controls')) report('M011', el, '<video autoplay loop> without `controls` moves forever with no way to pause it (WCAG 2.2.2).')
66
- }
67
- // M007: alt
68
- if (el.name !== 'video') {
69
- const alt = a('alt')
70
- const stems = [imp?.local, target && basename(target), target && basename(target, extname(target)), src?.kind === 'string' && basename(src.value)].filter(Boolean).map(s => s.toLowerCase())
71
- if (!alt) report('M007', el, `<${el.name}> has no \`alt\`.`)
72
- else if (el.name === 'Media' && alt.kind === 'string' && alt.value === '' && !a('decorative')) report('M007', el, '`alt=""` hides the image from screen readers: say so with `decorative`, or describe it.')
73
- else if (alt.kind === 'string' && alt.value && stems.includes(alt.value.toLowerCase())) report('M007', el, `\`alt="${alt.value}"\` is the file name, which tells nobody what the image shows.`)
74
- }
75
- // M008, M009, M010: priority (route files: the likely LCP image)
76
- const lazy = a('loading')?.value === 'lazy'
77
- const priority = !!a('priority') || a('fetchpriority')?.value === 'high'
78
- if (priority && lazy) report('M008', el, '`priority` media with `loading="lazy"` loads late anyway.', { description: 'Remove loading="lazy"', safe: true, edits: [{ start: a('loading').start - 1, end: a('loading').end, text: '' }] })
79
- else if (route && i === 0 && lazy) report('M008', el, `The route's first media is \`loading="lazy"\`: it is the likely LCP image, and lazy delays it.`)
80
- if (route && priority && ++priorities > 2) {
81
- const p = a('priority') ?? a('fetchpriority')
82
- report('M009', el, `More than 2 \`priority\` media in this route: they compete for the first bytes.`, { description: 'Remove priority', safe: true, edits: [{ start: p.start - 1, end: p.end, text: '' }] })
83
- }
84
- if (el.name === 'Media' && a('priority') && !a('width') && !a('sizes')) report('M010', el, '`priority` makes this image eager, and eager images can\'t use `sizes="auto"`: the browser assumes 100vw and fetches the largest file.')
85
- // M012: unsafe SVG used as an image
86
- if (target && /\.svg$/i.test(target) && bytes) {
87
- if (!svgs.has(target)) svgs.set(target, unsafeSvg(new TextDecoder().decode(bytes)))
88
- if (svgs.get(target).length) report('M012', el, `${basename(target)} has ${svgs.get(target).join(', ')}.`)
89
- }
90
- // M013: public/ by string
91
- if (src?.kind === 'string' && src.value.startsWith('/') && !src.value.startsWith('//') && ctx.exists(`public${src.value.split('?')[0]}`)) {
92
- report('M013', el, `\`${src.value}\` is served from public/: untracked, unoptimised, with no dimensions.`)
93
- }
242
+ checkSource({ el, src, size, sized, raw, report })
243
+ checkImport({ el, a, src, imp, spec, query, target, size, ctx, file, report })
244
+ checkMotion({ el, a, src, target, bytes, gifLimit, report })
245
+ checkAlt({ el, a, src, imp, target, report })
246
+ priorities = checkPriority({ el, a, route, i, priorities, report })
247
+ checkSecurity({ el, src, target, bytes, svgs, ctx, report })
94
248
  }
95
249
  }
96
250
  return out
package/src/index.d.ts CHANGED
@@ -8,8 +8,11 @@ export interface MediaOptions {
8
8
  widths?: number[]
9
9
  /** `target`: the SSIMULACRA2 score each format is searched to (default 80). `scorer`: the command, or 'none'. */
10
10
  quality?: { target?: number; scorer?: string }
11
- /** Dominant colour and a 16 px WebP placeholder. Default true. */
12
- placeholder?: boolean
11
+ /**
12
+ * Dominant colour and a 16 px WebP placeholder. `true` or 'inline' (the default) inlines it as a `data:` URL; 'file'
13
+ * writes it as its own file, for a CSP whose `img-src` has no `data:`; `false` leaves both out.
14
+ */
15
+ placeholder?: boolean | 'inline' | 'file'
13
16
  /** Bytes over which a GIF is M004. Default 256 KB. */
14
17
  gifLimit?: number
15
18
  /** 'server': `?media` imports are encoded on first request by the media route, not at build. 'compatible': the page shows the efficient file, and a compatible copy swaps in so saving gives a file that opens anywhere. Default 'off'. */