@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/README.md +482 -3
- package/package.json +13 -6
- package/src/build.mjs +145 -33
- package/src/check.mjs +216 -62
- package/src/index.d.ts +5 -2
- package/src/index.mjs +131 -33
- package/src/server/ingest.mjs +118 -0
- package/src/server/serve.mjs +60 -0
- package/src/server.mjs +13 -91
- package/src/shared.mjs +31 -5
- package/src/upload/gif.mjs +46 -0
- package/src/upload/ingest.mjs +45 -0
- package/src/upload/record.mjs +10 -0
- package/src/upload/rung.mjs +37 -0
- package/src/upload/serve.mjs +41 -0
- package/src/upload/video.mjs +55 -0
- package/src/upload.mjs +79 -89
- package/src/video/audio.mjs +52 -0
- package/src/video/common.mjs +28 -0
- package/src/video.mjs +227 -95
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
|
|
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 = {
|
|
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 {
|
|
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 {
|
|
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')
|
|
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 {
|
|
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)
|
|
94
|
-
|
|
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))
|
|
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)
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
171
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
21
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
/**
|
|
12
|
-
|
|
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'. */
|