feat(html)!: support more asset sources (#11138)

This commit is contained in:
Bjorn Lu 2024-10-31 15:57:16 +08:00 committed by GitHub
parent 826c81a40b
commit 8a7af50b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 368 additions and 141 deletions

View File

@ -170,9 +170,24 @@ Any HTML files in your project root can be directly accessed by its respective d
- `<root>/about.html` -> `http://localhost:5173/about.html`
- `<root>/blog/index.html` -> `http://localhost:5173/blog/index.html`
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.
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:
```html
- `<audio src>`
- `<embed src>`
- `<img src>` and `<img srcset>`
- `<image src>`
- `<input src>`
- `<link href>` and `<link imagesrcet>`
- `<object data>`
- `<source src>` and `<source srcset>`
- `<track src>`
- `<use href>` and `<use xlink:href>`
- `<video src>` and `<video poster>`
- `<meta content>`
- Only if `name` attribute matches `msapplication-tileimage`, `msapplication-square70x70logo`, `msapplication-square150x150logo`, `msapplication-wide310x150logo`, `msapplication-square310x310logo`, `msapplication-config`, or `twitter:image`
- 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`
```html {4-5,8-9}
<!doctype html>
<html>
<head>
@ -180,7 +195,6 @@ HTML elements such as `<script type="module">` and `<link href>` tags are proces
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="app"></div>
<img src="/src/images/logo.svg" alt="logo" />
<script type="module" src="/src/main.js"></script>
</body>

View File

@ -0,0 +1,97 @@
import { describe, expect, test } from 'vitest'
import { type DefaultTreeAdapterMap, parseFragment } from 'parse5'
import { getNodeAssetAttributes } from '../assetSource'
describe('getNodeAssetAttributes', () => {
const getNode = (html: string) => {
const ast = parseFragment(html, { sourceCodeLocationInfo: true })
return ast.childNodes[0] as DefaultTreeAdapterMap['element']
}
test('handles img src', () => {
const node = getNode('<img src="foo.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(1)
expect(attrs[0]).toHaveProperty('type', 'src')
expect(attrs[0]).toHaveProperty('key', 'src')
expect(attrs[0]).toHaveProperty('value', 'foo.jpg')
expect(attrs[0].attributes).toEqual({ src: 'foo.jpg' })
expect(attrs[0].location).toHaveProperty('startOffset', 5)
expect(attrs[0].location).toHaveProperty('endOffset', 18)
})
test('handles source srcset', () => {
const node = getNode('<source srcset="foo.jpg 1x, bar.jpg 2x">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(1)
expect(attrs[0]).toHaveProperty('type', 'srcset')
expect(attrs[0]).toHaveProperty('key', 'srcset')
expect(attrs[0]).toHaveProperty('value', 'foo.jpg 1x, bar.jpg 2x')
expect(attrs[0].attributes).toEqual({ srcset: 'foo.jpg 1x, bar.jpg 2x' })
})
test('handles video src and poster', () => {
const node = getNode('<video src="video.mp4" poster="poster.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(2)
expect(attrs[0]).toHaveProperty('type', 'src')
expect(attrs[0]).toHaveProperty('key', 'src')
expect(attrs[0]).toHaveProperty('value', 'video.mp4')
expect(attrs[0].attributes).toEqual({
src: 'video.mp4',
poster: 'poster.jpg',
})
expect(attrs[1]).toHaveProperty('type', 'src')
expect(attrs[1]).toHaveProperty('key', 'poster')
expect(attrs[1]).toHaveProperty('value', 'poster.jpg')
})
test('handles link with allowed rel', () => {
const node = getNode('<link rel="stylesheet" href="style.css">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(1)
expect(attrs[0]).toHaveProperty('type', 'src')
expect(attrs[0]).toHaveProperty('key', 'href')
expect(attrs[0]).toHaveProperty('value', 'style.css')
expect(attrs[0].attributes).toEqual({
rel: 'stylesheet',
href: 'style.css',
})
})
test('handles meta with allowed name', () => {
const node = getNode('<meta name="twitter:image" content="image.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(1)
expect(attrs[0]).toHaveProperty('type', 'src')
expect(attrs[0]).toHaveProperty('key', 'content')
expect(attrs[0]).toHaveProperty('value', 'image.jpg')
})
test('handles meta with allowed property', () => {
const node = getNode('<meta property="og:image" content="image.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(1)
expect(attrs[0]).toHaveProperty('type', 'src')
expect(attrs[0]).toHaveProperty('key', 'content')
expect(attrs[0]).toHaveProperty('value', 'image.jpg')
})
test('does not handle meta with unknown name', () => {
const node = getNode('<meta name="unknown" content="image.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(0)
})
test('does not handle meta with unknown property', () => {
const node = getNode('<meta property="unknown" content="image.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(0)
})
test('does not handle meta with no known properties', () => {
const node = getNode('<meta foo="bar" content="image.jpg">')
const attrs = getNodeAssetAttributes(node)
expect(attrs).toHaveLength(0)
})
})

View File

@ -0,0 +1,151 @@
import type { DefaultTreeAdapterMap, Token } from 'parse5'
interface HtmlAssetSource {
srcAttributes?: string[]
srcsetAttributes?: string[]
/**
* Called before handling an attribute to determine if it should be processed.
*/
filter?: (data: HtmlAssetSourceFilterData) => boolean
}
interface HtmlAssetSourceFilterData {
key: string
value: string
attributes: Record<string, string>
}
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name
// https://wiki.whatwg.org/wiki/MetaExtensions
const ALLOWED_META_NAME = [
'msapplication-tileimage',
'msapplication-square70x70logo',
'msapplication-square150x150logo',
'msapplication-wide310x150logo',
'msapplication-square310x310logo',
'msapplication-config',
'twitter:image',
]
// https://ogp.me
const ALLOWED_META_PROPERTY = [
'og:image',
'og:image:url',
'og:image:secure_url',
'og:audio',
'og:audio:secure_url',
'og:video',
'og:video:secure_url',
]
const DEFAULT_HTML_ASSET_SOURCES: Record<string, HtmlAssetSource> = {
audio: {
srcAttributes: ['src'],
},
embed: {
srcAttributes: ['src'],
},
img: {
srcAttributes: ['src'],
srcsetAttributes: ['srcset'],
},
image: {
srcAttributes: ['href', 'xlink:href'],
},
input: {
srcAttributes: ['src'],
},
link: {
srcAttributes: ['href'],
srcsetAttributes: ['imagesrcset'],
},
object: {
srcAttributes: ['data'],
},
source: {
srcAttributes: ['src'],
srcsetAttributes: ['srcset'],
},
track: {
srcAttributes: ['src'],
},
use: {
srcAttributes: ['href', 'xlink:href'],
},
video: {
srcAttributes: ['src', 'poster'],
},
meta: {
srcAttributes: ['content'],
filter({ attributes }) {
if (
attributes.name &&
ALLOWED_META_NAME.includes(attributes.name.trim().toLowerCase())
) {
return true
}
if (
attributes.property &&
ALLOWED_META_PROPERTY.includes(attributes.property.trim().toLowerCase())
) {
return true
}
return false
},
},
}
interface HtmlAssetAttribute {
type: 'src' | 'srcset' | 'remove'
key: string
value: string
attributes: Record<string, string>
location: Token.Location
}
/**
* Given a HTML node, find all attributes that references an asset to be processed
*/
export function getNodeAssetAttributes(
node: DefaultTreeAdapterMap['element'],
): HtmlAssetAttribute[] {
const matched = DEFAULT_HTML_ASSET_SOURCES[node.nodeName]
if (!matched) return []
const attributes: Record<string, string> = {}
for (const attr of node.attrs) {
attributes[getAttrKey(attr)] = attr.value
}
// If the node has a `vite-ignore` attribute, remove the attribute and early out
// to skip processing any attributes
if ('vite-ignore' in attributes) {
return [
{
type: 'remove',
key: 'vite-ignore',
value: '',
attributes,
location: node.sourceCodeLocation!.attrs!['vite-ignore'],
},
]
}
const actions: HtmlAssetAttribute[] = []
function handleAttributeKey(key: string, type: 'src' | 'srcset') {
const value = attributes[key]
if (!value) return
if (matched.filter && !matched.filter({ key, value, attributes })) return
const location = node.sourceCodeLocation!.attrs![key]
actions.push({ type, key, value, attributes, location })
}
matched.srcAttributes?.forEach((key) => handleAttributeKey(key, 'src'))
matched.srcsetAttributes?.forEach((key) => handleAttributeKey(key, 'srcset'))
return actions
}
function getAttrKey(attr: Token.Attribute): string {
return attr.prefix === undefined ? attr.name : `${attr.prefix}:${attr.name}`
}

View File

@ -33,6 +33,7 @@ import { resolveEnvPrefix } from '../env'
import type { Logger } from '../logger'
import { cleanUrl } from '../../shared/utils'
import { usePerEnvironmentState } from '../environment'
import { getNodeAssetAttributes } from '../assetSource'
import {
assetUrlRE,
getPublicAssetFilename,
@ -140,16 +141,6 @@ export function addToHTMLProxyTransformResult(
htmlProxyResult.set(hash, code)
}
// this extends the config in @vue/compiler-sfc with <link href>
export const assetAttrsConfig: Record<string, string[]> = {
link: ['href'],
video: ['src', 'poster'],
source: ['src', 'srcset'],
img: ['src', 'srcset'],
image: ['xlink:href', 'href'],
use: ['xlink:href', 'href'],
}
// Some `<link rel>` elements should not be inlined in build. Excluding:
// - `shortcut` : only valid for IE <9, use `icon`
// - `mask-icon` : deprecated since Safari 12 (for pinned tabs)
@ -525,96 +516,80 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
// For asset references in index.html, also generate an import
// statement for each - this will be handled by the asset plugin
const assetAttrs = assetAttrsConfig[node.nodeName]
if (assetAttrs) {
const nodeAttrs: Record<string, string> = {}
for (const attr of node.attrs) {
nodeAttrs[getAttrKey(attr)] = attr.value
}
const shouldIgnore =
node.nodeName === 'link' && 'vite-ignore' in nodeAttrs
if (shouldIgnore) {
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
} else {
for (const attrKey in nodeAttrs) {
const attrValue = nodeAttrs[attrKey]
if (attrValue && assetAttrs.includes(attrKey)) {
if (attrKey === 'srcset') {
assetUrlsPromises.push(
(async () => {
const processedEncodedUrl = await processSrcSet(
attrValue,
async ({ url }) => {
const decodedUrl = decodeURI(url)
if (!isExcludedUrl(decodedUrl)) {
const result = await processAssetUrl(url)
return result !== decodedUrl
? encodeURIPath(result)
: url
}
return url
},
)
if (processedEncodedUrl !== attrValue) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
processedEncodedUrl,
)
}
})(),
const assetAttributes = getNodeAssetAttributes(node)
for (const attr of assetAttributes) {
if (attr.type === 'remove') {
s.remove(attr.location.startOffset, attr.location.endOffset)
continue
} else if (attr.type === 'srcset') {
assetUrlsPromises.push(
(async () => {
const processedEncodedUrl = await processSrcSet(
attr.value,
async ({ url }) => {
const decodedUrl = decodeURI(url)
if (!isExcludedUrl(decodedUrl)) {
const result = await processAssetUrl(url)
return result !== decodedUrl
? encodeURIPath(result)
: url
}
return url
},
)
if (processedEncodedUrl !== attr.value) {
overwriteAttrValue(s, attr.location, processedEncodedUrl)
}
})(),
)
} else if (attr.type === 'src') {
const url = decodeURI(attr.value)
if (checkPublicFile(url, config)) {
overwriteAttrValue(
s,
attr.location,
partialEncodeURIPath(toOutputPublicFilePath(url)),
)
} else if (!isExcludedUrl(url)) {
if (
node.nodeName === 'link' &&
isCSSRequest(url) &&
// should not be converted if following attributes are present (#6748)
!('media' in attr.attributes || 'disabled' in attr.attributes)
) {
// CSS references, convert to import
const importExpression = `\nimport ${JSON.stringify(url)}`
styleUrls.push({
url,
start: nodeStartWithLeadingWhitespace(node),
end: node.sourceCodeLocation!.endOffset,
})
js += importExpression
} else {
// If the node is a link, check if it can be inlined. If not, set `shouldInline`
// to `false` to force no inline. If `undefined`, it leaves to the default heuristics.
const isNoInlineLink =
node.nodeName === 'link' &&
attr.attributes.rel &&
parseRelAttr(attr.attributes.rel).some((v) =>
noInlineLinkRels.has(v),
)
} else {
const url = decodeURI(attrValue)
if (checkPublicFile(url, config)) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(toOutputPublicFilePath(url)),
const shouldInline = isNoInlineLink ? false : undefined
assetUrlsPromises.push(
(async () => {
const processedUrl = await processAssetUrl(
url,
shouldInline,
)
} else if (!isExcludedUrl(url)) {
if (
node.nodeName === 'link' &&
isCSSRequest(url) &&
// should not be converted if following attributes are present (#6748)
!('media' in nodeAttrs || 'disabled' in nodeAttrs)
) {
// CSS references, convert to import
const importExpression = `\nimport ${JSON.stringify(url)}`
styleUrls.push({
url,
start: nodeStartWithLeadingWhitespace(node),
end: node.sourceCodeLocation!.endOffset,
})
js += importExpression
} else {
// If the node is a link, check if it can be inlined. If not, set `shouldInline`
// to `false` to force no inline. If `undefined`, it leaves to the default heuristics.
const isNoInlineLink =
node.nodeName === 'link' &&
nodeAttrs.rel &&
parseRelAttr(nodeAttrs.rel).some((v) =>
noInlineLinkRels.has(v),
)
const shouldInline = isNoInlineLink ? false : undefined
assetUrlsPromises.push(
(async () => {
const processedUrl = await processAssetUrl(
url,
shouldInline,
)
if (processedUrl !== url) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(processedUrl),
)
}
})(),
if (processedUrl !== url) {
overwriteAttrValue(
s,
attr.location,
partialEncodeURIPath(processedUrl),
)
}
}
}
})(),
)
}
}
}
@ -1565,14 +1540,3 @@ function serializeAttrs(attrs: HtmlTagDescriptor['attrs']): string {
function incrementIndent(indent: string = '') {
return `${indent}${indent[0] === '\t' ? '\t' : ' '}`
}
export function getAttrKey(attr: Token.Attribute): string {
return attr.prefix === undefined ? attr.name : `${attr.prefix}:${attr.name}`
}
function getAttrSourceCodeLocation(
node: DefaultTreeAdapterMap['element'],
attrKey: string,
) {
return node.sourceCodeLocation!.attrs![attrKey]
}

View File

@ -9,10 +9,8 @@ import type { IndexHtmlTransformHook } from '../../plugins/html'
import {
addToHTMLProxyCache,
applyHtmlTransforms,
assetAttrsConfig,
extractImportExpressionFromClassicScript,
findNeedTransformStyleAttribute,
getAttrKey,
getScriptInfo,
htmlEnvHook,
htmlProxyResult,
@ -45,6 +43,7 @@ import { checkPublicFile } from '../../publicDir'
import { isCSSRequest } from '../../plugins/css'
import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap'
import { cleanUrl, unwrapId, wrapId } from '../../../shared/utils'
import { getNodeAssetAttributes } from '../../assetSource'
interface AssetNode {
start: number
@ -277,7 +276,7 @@ const devHtmlHook: IndexHtmlTransformHook = async (
} else if (src) {
const processedUrl = processNodeUrl(
src.value,
getAttrKey(src) === 'srcset',
/* useSrcSetReplacer */ false,
config,
htmlPath,
originalUrl,
@ -332,35 +331,20 @@ const devHtmlHook: IndexHtmlTransformHook = async (
}
// elements with [href/src] attrs
const assetAttrs = assetAttrsConfig[node.nodeName]
if (assetAttrs) {
const nodeAttrs: Record<string, string> = {}
for (const attr of node.attrs) {
nodeAttrs[getAttrKey(attr)] = attr.value
}
const shouldIgnore =
node.nodeName === 'link' && 'vite-ignore' in nodeAttrs
if (shouldIgnore) {
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
const assetAttributes = getNodeAssetAttributes(node)
for (const attr of assetAttributes) {
if (attr.type === 'remove') {
s.remove(attr.location.startOffset, attr.location.endOffset)
} else {
for (const attrKey in nodeAttrs) {
const attrValue = nodeAttrs[attrKey]
if (attrValue && assetAttrs.includes(attrKey)) {
const processedUrl = processNodeUrl(
attrValue,
attrKey === 'srcset',
config,
htmlPath,
originalUrl,
)
if (processedUrl !== attrValue) {
overwriteAttrValue(
s,
node.sourceCodeLocation!.attrs![attrKey],
processedUrl,
)
}
}
const processedUrl = processNodeUrl(
attr.value,
attr.type === 'srcset',
config,
htmlPath,
originalUrl,
)
if (processedUrl !== attr.value) {
overwriteAttrValue(s, attr.location, processedUrl)
}
}
}

View File

@ -347,6 +347,18 @@ describe('image', () => {
})
})
describe('meta', () => {
test('og image', async () => {
const meta = await page.$('.meta-og-image')
const content = await meta.getAttribute('content')
expect(content).toMatch(
isBuild
? /\/foo\/bar\/assets\/asset-\w{8}\.png/
: /\/foo\/bar\/nested\/asset.png/,
)
})
})
describe('svg fragments', () => {
// 404 is checked already, so here we just ensure the urls end with #fragment
test('img url', async () => {

View File

@ -4,6 +4,11 @@
<meta charset="UTF-8" />
<link class="ico" rel="icon" type="image/svg+xml" href="favicon.ico" />
<link rel="manifest" href="manifest.json" />
<meta
class="meta-og-image"
property="og:image"
content="./nested/asset.png"
/>
</head>
<link class="data-href" rel="icon" href="data:," />