-
-
Notifications
You must be signed in to change notification settings - Fork 6.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(html)!: support more asset sources (#11138)
- Loading branch information
Showing
7 changed files
with
369 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}` | ||
} |
Oops, something went wrong.