Skip to content

Commit

Permalink
feat(html)!: support more asset sources (#11138)
Browse files Browse the repository at this point in the history
  • Loading branch information
bluwy authored Oct 31, 2024
1 parent 826c81a commit 8a7af50
Show file tree
Hide file tree
Showing 7 changed files with 369 additions and 142 deletions.
22 changes: 18 additions & 4 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,31 @@ 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.

```html
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:

- `<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>
<link rel="icon" href="/favicon.ico" />
<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>
Expand Down
97 changes: 97 additions & 0 deletions packages/vite/src/node/__tests__/assetSource.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
151 changes: 151 additions & 0 deletions packages/vite/src/node/assetSource.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
Loading

0 comments on commit 8a7af50

Please sign in to comment.