Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 10 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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