Skip to content

Commit 8a7af50

Browse files
authored
feat(html)!: support more asset sources (#11138)
1 parent 826c81a commit 8a7af50

File tree

7 files changed

+369
-142
lines changed

7 files changed

+369
-142
lines changed

docs/guide/features.md

+18-4
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,31 @@ Any HTML files in your project root can be directly accessed by its respective d
170170
- `<root>/about.html` -> `http://localhost:5173/about.html`
171171
- `<root>/blog/index.html` -> `http://localhost:5173/blog/index.html`
172172

173-
HTML elements such as `<script type="module">` and `<link href>` tags are processed by default, which enables using Vite features in the linked files. General asset elements, such as `<img src>`, `<video src>`, and `<source src>`, are also rebased to ensure they are optimized and linked to the right path.
174-
175-
```html
173+
Files referenced by HTML elements such as `<script type="module">` and `<link href>` are processed and bundled as part of the app. General asset elements can also reference assets to be optimized by default, including:
174+
175+
- `<audio src>`
176+
- `<embed src>`
177+
- `<img src>` and `<img srcset>`
178+
- `<image src>`
179+
- `<input src>`
180+
- `<link href>` and `<link imagesrcet>`
181+
- `<object data>`
182+
- `<source src>` and `<source srcset>`
183+
- `<track src>`
184+
- `<use href>` and `<use xlink:href>`
185+
- `<video src>` and `<video poster>`
186+
- `<meta content>`
187+
- Only if `name` attribute matches `msapplication-tileimage`, `msapplication-square70x70logo`, `msapplication-square150x150logo`, `msapplication-wide310x150logo`, `msapplication-square310x310logo`, `msapplication-config`, or `twitter:image`
188+
- Or only if `property` attribute matches `og:image`, `og:image:url`, `og:image:secure_url`, `og:audio`, `og:audio:secure_url`, `og:video`, or `og:video:secure_url`
189+
190+
```html {4-5,8-9}
176191
<!doctype html>
177192
<html>
178193
<head>
179194
<link rel="icon" href="/favicon.ico" />
180195
<link rel="stylesheet" href="/src/styles.css" />
181196
</head>
182197
<body>
183-
<div id="app"></div>
184198
<img src="/src/images/logo.svg" alt="logo" />
185199
<script type="module" src="/src/main.js"></script>
186200
</body>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { type DefaultTreeAdapterMap, parseFragment } from 'parse5'
3+
import { getNodeAssetAttributes } from '../assetSource'
4+
5+
describe('getNodeAssetAttributes', () => {
6+
const getNode = (html: string) => {
7+
const ast = parseFragment(html, { sourceCodeLocationInfo: true })
8+
return ast.childNodes[0] as DefaultTreeAdapterMap['element']
9+
}
10+
11+
test('handles img src', () => {
12+
const node = getNode('<img src="foo.jpg">')
13+
const attrs = getNodeAssetAttributes(node)
14+
expect(attrs).toHaveLength(1)
15+
expect(attrs[0]).toHaveProperty('type', 'src')
16+
expect(attrs[0]).toHaveProperty('key', 'src')
17+
expect(attrs[0]).toHaveProperty('value', 'foo.jpg')
18+
expect(attrs[0].attributes).toEqual({ src: 'foo.jpg' })
19+
expect(attrs[0].location).toHaveProperty('startOffset', 5)
20+
expect(attrs[0].location).toHaveProperty('endOffset', 18)
21+
})
22+
23+
test('handles source srcset', () => {
24+
const node = getNode('<source srcset="foo.jpg 1x, bar.jpg 2x">')
25+
const attrs = getNodeAssetAttributes(node)
26+
expect(attrs).toHaveLength(1)
27+
expect(attrs[0]).toHaveProperty('type', 'srcset')
28+
expect(attrs[0]).toHaveProperty('key', 'srcset')
29+
expect(attrs[0]).toHaveProperty('value', 'foo.jpg 1x, bar.jpg 2x')
30+
expect(attrs[0].attributes).toEqual({ srcset: 'foo.jpg 1x, bar.jpg 2x' })
31+
})
32+
33+
test('handles video src and poster', () => {
34+
const node = getNode('<video src="video.mp4" poster="poster.jpg">')
35+
const attrs = getNodeAssetAttributes(node)
36+
expect(attrs).toHaveLength(2)
37+
expect(attrs[0]).toHaveProperty('type', 'src')
38+
expect(attrs[0]).toHaveProperty('key', 'src')
39+
expect(attrs[0]).toHaveProperty('value', 'video.mp4')
40+
expect(attrs[0].attributes).toEqual({
41+
src: 'video.mp4',
42+
poster: 'poster.jpg',
43+
})
44+
expect(attrs[1]).toHaveProperty('type', 'src')
45+
expect(attrs[1]).toHaveProperty('key', 'poster')
46+
expect(attrs[1]).toHaveProperty('value', 'poster.jpg')
47+
})
48+
49+
test('handles link with allowed rel', () => {
50+
const node = getNode('<link rel="stylesheet" href="style.css">')
51+
const attrs = getNodeAssetAttributes(node)
52+
expect(attrs).toHaveLength(1)
53+
expect(attrs[0]).toHaveProperty('type', 'src')
54+
expect(attrs[0]).toHaveProperty('key', 'href')
55+
expect(attrs[0]).toHaveProperty('value', 'style.css')
56+
expect(attrs[0].attributes).toEqual({
57+
rel: 'stylesheet',
58+
href: 'style.css',
59+
})
60+
})
61+
62+
test('handles meta with allowed name', () => {
63+
const node = getNode('<meta name="twitter:image" content="image.jpg">')
64+
const attrs = getNodeAssetAttributes(node)
65+
expect(attrs).toHaveLength(1)
66+
expect(attrs[0]).toHaveProperty('type', 'src')
67+
expect(attrs[0]).toHaveProperty('key', 'content')
68+
expect(attrs[0]).toHaveProperty('value', 'image.jpg')
69+
})
70+
71+
test('handles meta with allowed property', () => {
72+
const node = getNode('<meta property="og:image" content="image.jpg">')
73+
const attrs = getNodeAssetAttributes(node)
74+
expect(attrs).toHaveLength(1)
75+
expect(attrs[0]).toHaveProperty('type', 'src')
76+
expect(attrs[0]).toHaveProperty('key', 'content')
77+
expect(attrs[0]).toHaveProperty('value', 'image.jpg')
78+
})
79+
80+
test('does not handle meta with unknown name', () => {
81+
const node = getNode('<meta name="unknown" content="image.jpg">')
82+
const attrs = getNodeAssetAttributes(node)
83+
expect(attrs).toHaveLength(0)
84+
})
85+
86+
test('does not handle meta with unknown property', () => {
87+
const node = getNode('<meta property="unknown" content="image.jpg">')
88+
const attrs = getNodeAssetAttributes(node)
89+
expect(attrs).toHaveLength(0)
90+
})
91+
92+
test('does not handle meta with no known properties', () => {
93+
const node = getNode('<meta foo="bar" content="image.jpg">')
94+
const attrs = getNodeAssetAttributes(node)
95+
expect(attrs).toHaveLength(0)
96+
})
97+
})

packages/vite/src/node/assetSource.ts

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { DefaultTreeAdapterMap, Token } from 'parse5'
2+
3+
interface HtmlAssetSource {
4+
srcAttributes?: string[]
5+
srcsetAttributes?: string[]
6+
/**
7+
* Called before handling an attribute to determine if it should be processed.
8+
*/
9+
filter?: (data: HtmlAssetSourceFilterData) => boolean
10+
}
11+
12+
interface HtmlAssetSourceFilterData {
13+
key: string
14+
value: string
15+
attributes: Record<string, string>
16+
}
17+
18+
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name
19+
// https://wiki.whatwg.org/wiki/MetaExtensions
20+
const ALLOWED_META_NAME = [
21+
'msapplication-tileimage',
22+
'msapplication-square70x70logo',
23+
'msapplication-square150x150logo',
24+
'msapplication-wide310x150logo',
25+
'msapplication-square310x310logo',
26+
'msapplication-config',
27+
'twitter:image',
28+
]
29+
30+
// https://ogp.me
31+
const ALLOWED_META_PROPERTY = [
32+
'og:image',
33+
'og:image:url',
34+
'og:image:secure_url',
35+
'og:audio',
36+
'og:audio:secure_url',
37+
'og:video',
38+
'og:video:secure_url',
39+
]
40+
41+
const DEFAULT_HTML_ASSET_SOURCES: Record<string, HtmlAssetSource> = {
42+
audio: {
43+
srcAttributes: ['src'],
44+
},
45+
embed: {
46+
srcAttributes: ['src'],
47+
},
48+
img: {
49+
srcAttributes: ['src'],
50+
srcsetAttributes: ['srcset'],
51+
},
52+
image: {
53+
srcAttributes: ['href', 'xlink:href'],
54+
},
55+
input: {
56+
srcAttributes: ['src'],
57+
},
58+
link: {
59+
srcAttributes: ['href'],
60+
srcsetAttributes: ['imagesrcset'],
61+
},
62+
object: {
63+
srcAttributes: ['data'],
64+
},
65+
source: {
66+
srcAttributes: ['src'],
67+
srcsetAttributes: ['srcset'],
68+
},
69+
track: {
70+
srcAttributes: ['src'],
71+
},
72+
use: {
73+
srcAttributes: ['href', 'xlink:href'],
74+
},
75+
video: {
76+
srcAttributes: ['src', 'poster'],
77+
},
78+
meta: {
79+
srcAttributes: ['content'],
80+
filter({ attributes }) {
81+
if (
82+
attributes.name &&
83+
ALLOWED_META_NAME.includes(attributes.name.trim().toLowerCase())
84+
) {
85+
return true
86+
}
87+
88+
if (
89+
attributes.property &&
90+
ALLOWED_META_PROPERTY.includes(attributes.property.trim().toLowerCase())
91+
) {
92+
return true
93+
}
94+
95+
return false
96+
},
97+
},
98+
}
99+
100+
interface HtmlAssetAttribute {
101+
type: 'src' | 'srcset' | 'remove'
102+
key: string
103+
value: string
104+
attributes: Record<string, string>
105+
location: Token.Location
106+
}
107+
108+
/**
109+
* Given a HTML node, find all attributes that references an asset to be processed
110+
*/
111+
export function getNodeAssetAttributes(
112+
node: DefaultTreeAdapterMap['element'],
113+
): HtmlAssetAttribute[] {
114+
const matched = DEFAULT_HTML_ASSET_SOURCES[node.nodeName]
115+
if (!matched) return []
116+
117+
const attributes: Record<string, string> = {}
118+
for (const attr of node.attrs) {
119+
attributes[getAttrKey(attr)] = attr.value
120+
}
121+
122+
// If the node has a `vite-ignore` attribute, remove the attribute and early out
123+
// to skip processing any attributes
124+
if ('vite-ignore' in attributes) {
125+
return [
126+
{
127+
type: 'remove',
128+
key: 'vite-ignore',
129+
value: '',
130+
attributes,
131+
location: node.sourceCodeLocation!.attrs!['vite-ignore'],
132+
},
133+
]
134+
}
135+
136+
const actions: HtmlAssetAttribute[] = []
137+
function handleAttributeKey(key: string, type: 'src' | 'srcset') {
138+
const value = attributes[key]
139+
if (!value) return
140+
if (matched.filter && !matched.filter({ key, value, attributes })) return
141+
const location = node.sourceCodeLocation!.attrs![key]
142+
actions.push({ type, key, value, attributes, location })
143+
}
144+
matched.srcAttributes?.forEach((key) => handleAttributeKey(key, 'src'))
145+
matched.srcsetAttributes?.forEach((key) => handleAttributeKey(key, 'srcset'))
146+
return actions
147+
}
148+
149+
function getAttrKey(attr: Token.Attribute): string {
150+
return attr.prefix === undefined ? attr.name : `${attr.prefix}:${attr.name}`
151+
}

0 commit comments

Comments
 (0)