From f9461e4a14c0b6a197040a5210dd70b9f2770f15 Mon Sep 17 00:00:00 2001 From: bluwy Date: Thu, 1 Dec 2022 15:56:42 +0800 Subject: [PATCH 1/7] feat(html): support more asset sources --- packages/vite/src/node/assetSource.ts | 231 ++++++++++++++++++ packages/vite/src/node/plugins/html.ts | 86 +++---- .../src/node/server/middlewares/indexHtml.ts | 28 +-- playground/assets/__tests__/assets.spec.ts | 10 + playground/assets/index.html | 5 + 5 files changed, 288 insertions(+), 72 deletions(-) create mode 100644 packages/vite/src/node/assetSource.ts diff --git a/packages/vite/src/node/assetSource.ts b/packages/vite/src/node/assetSource.ts new file mode 100644 index 00000000000000..daeffeb2bdea7d --- /dev/null +++ b/packages/vite/src/node/assetSource.ts @@ -0,0 +1,231 @@ +import type { DefaultTreeAdapterMap, Token } from 'parse5' + +interface HtmlAssetSource { + tag: string + attributes: string[] + type: 'src' | 'srcset' + filter?: (data: HtmlAssetSourceFilterData) => boolean +} + +interface HtmlAssetSourceFilterData { + attribute: string + value: string + attributes: Record +} + +interface AssetAttribute { + attribute: Token.Attribute + type: 'src' | 'srcset' + location: Token.Location +} + +export function getNodeAssetAttributes( + node: DefaultTreeAdapterMap['element'] +): AssetAttribute[] { + const assetAttrs: AssetAttribute[] = [] + + for (const assetSource of DEFAULT_HTML_ASSET_SOURCES) { + if (assetSource.tag !== node.nodeName) continue + + const attributes = node.attrs.reduce((acc, attr) => { + acc[getAttrKey(attr)] = attr.value + return acc + }, {} as Record) + + const attrNames = Object.keys(attributes) + + for (let i = 0; i < node.attrs.length; i++) { + const attr = node.attrs[i] + + if ( + attr.value && + assetSource.attributes.includes(attrNames[i]) && + (!assetSource.filter || + assetSource.filter({ + attribute: attr.name, + value: attr.value, + attributes + })) + ) { + assetAttrs.push({ + attribute: attr, + type: assetSource.type, + location: node.sourceCodeLocation!.attrs![attrNames[i]] + }) + } + } + } + + return assetAttrs +} + +function getAttrKey(attr: Token.Attribute): string { + return attr.prefix === undefined ? attr.name : `${attr.prefix}:${attr.name}` +} + +const ALLOWED_REL = [ + 'stylesheet', + 'icon', + 'shortcut icon', + 'mask-icon', + 'apple-touch-icon', + 'apple-touch-icon-precomposed', + 'apple-touch-startup-image', + 'manifest', + 'prefetch', + 'preload' +] + +const ALLOWED_ITEMPROP = [ + 'image', + 'logo', + 'screenshot', + 'thumbnailurl', + 'contenturl', + 'downloadurl', + 'duringmedia', + 'embedurl', + 'installurl', + 'layoutimage' +] + +const ALLOWED_META_NAME = [ + 'msapplication-tileimage', + 'msapplication-square70x70logo', + 'msapplication-square150x150logo', + 'msapplication-wide310x150logo', + 'msapplication-square310x310logo', + 'msapplication-config', + 'twitter:image' +] + +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', + 'vk:image' +] + +const DEFAULT_HTML_ASSET_SOURCES: HtmlAssetSource[] = [ + { + tag: 'audio', + type: 'src', + attributes: ['src'] + }, + { + tag: 'embed', + type: 'src', + attributes: ['src'] + }, + { + tag: 'img', + type: 'src', + attributes: ['src'] + }, + { + tag: 'img', + type: 'srcset', + attributes: ['srcset'] + }, + { + tag: 'input', + type: 'src', + attributes: ['src'] + }, + { + tag: 'object', + type: 'src', + attributes: ['data'] + }, + { + tag: 'source', + type: 'src', + attributes: ['src'] + }, + { + tag: 'source', + type: 'srcset', + attributes: ['srcset'] + }, + { + tag: 'track', + type: 'src', + attributes: ['src'] + }, + { + tag: 'video', + type: 'src', + attributes: ['poster', 'src'] + }, + { + tag: 'image', + type: 'src', + attributes: ['href', 'xlink:href'] + }, + { + tag: 'use', + type: 'src', + attributes: ['href', 'xlink:href'] + }, + { + tag: 'link', + type: 'src', + attributes: ['href'], + filter({ attributes }) { + if (attributes.rel && ALLOWED_REL.includes(attributes.rel)) { + return true + } + + if ( + attributes.itemprop && + ALLOWED_ITEMPROP.includes(attributes.itemprop) + ) { + return true + } + + return false + } + }, + { + tag: 'link', + type: 'srcset', + attributes: ['imagesrcset'], + filter({ attributes }) { + if (attributes.rel && ALLOWED_REL.includes(attributes.rel)) { + return true + } + + return false + } + }, + { + tag: 'meta', + type: 'src', + attributes: ['content'], + filter({ attributes }) { + if (attributes.name && ALLOWED_META_NAME.includes(attributes.name)) { + return true + } + + if ( + attributes.property && + ALLOWED_META_PROPERTY.includes(attributes.property) + ) { + return true + } + + if ( + attributes.itemprop && + ALLOWED_ITEMPROP.includes(attributes.itemprop) + ) { + return true + } + + return false + } + } +] diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index c9094ba6093d6e..fdf0ef0b3c8533 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -23,6 +23,7 @@ import { } from '../utils' import type { ResolvedConfig } from '../config' import { toOutputFilePathInHtml } from '../build' +import { getNodeAssetAttributes } from '../assetSource' import { assetUrlRE, checkPublicFile, @@ -120,16 +121,6 @@ export function addToHTMLProxyTransformResult( htmlProxyResult.set(hash, code) } -// this extends the config in @vue/compiler-sfc with -export const assetAttrsConfig: Record = { - link: ['href'], - video: ['src', 'poster'], - source: ['src', 'srcset'], - img: ['src', 'srcset'], - image: ['xlink:href', 'href'], - use: ['xlink:href', 'href'] -} - export const isAsyncScriptMap = new WeakMap< ResolvedConfig, Map @@ -416,48 +407,37 @@ 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) { - for (const p of node.attrs) { - const attrKey = getAttrKey(p) - if (p.value && assetAttrs.includes(attrKey)) { - const attrSourceCodeLocation = - node.sourceCodeLocation!.attrs![attrKey] - // assetsUrl may be encodeURI - const url = decodeURI(p.value) - if (!isExcludedUrl(url)) { - if ( - node.nodeName === 'link' && - isCSSRequest(url) && - // should not be converted if following attributes are present (#6748) - !node.attrs.some( - (p) => - p.prefix === undefined && - (p.name === 'media' || p.name === 'disabled') - ) - ) { - // CSS references, convert to import - const importExpression = `\nimport ${JSON.stringify(url)}` - styleUrls.push({ - url, - start: node.sourceCodeLocation!.startOffset, - end: node.sourceCodeLocation!.endOffset - }) - js += importExpression - } else { - assetUrls.push({ - attr: p, - sourceCodeLocation: attrSourceCodeLocation - }) - } - } else if (checkPublicFile(url, config)) { - overwriteAttrValue( - s, - attrSourceCodeLocation, - toOutputPublicFilePath(url) - ) - } + const assetAttrs = getNodeAssetAttributes(node) + for (const attr of assetAttrs) { + // assetsUrl may be encodeURI + const url = decodeURI(attr.attribute.value) + if (!isExcludedUrl(url)) { + if ( + node.nodeName === 'link' && + isCSSRequest(url) && + // should not be converted if following attributes are present (#6748) + !node.attrs.some( + (p) => + p.prefix === undefined && + (p.name === 'media' || p.name === 'disabled') + ) + ) { + // CSS references, convert to import + const importExpression = `\nimport ${JSON.stringify(url)}` + styleUrls.push({ + url, + start: node.sourceCodeLocation!.startOffset, + end: node.sourceCodeLocation!.endOffset + }) + js += importExpression + } else { + assetUrls.push({ + attr: attr.attribute, + sourceCodeLocation: attr.location + }) } + } else if (checkPublicFile(url, config)) { + overwriteAttrValue(s, attr.location, toOutputPublicFilePath(url)) } } // @@ -1180,7 +1160,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}` -} diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 13e4468c3fb291..deb89598ed672d 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -8,8 +8,6 @@ import type { IndexHtmlTransformHook } from '../../plugins/html' import { addToHTMLProxyCache, applyHtmlTransforms, - assetAttrsConfig, - getAttrKey, getScriptInfo, nodeIsElement, overwriteAttrValue, @@ -32,6 +30,7 @@ import { wrapId } from '../../utils' import type { ModuleGraph } from '../moduleGraph' +import { getNodeAssetAttributes } from '../../assetSource' interface AssetNode { start: number @@ -227,21 +226,16 @@ const devHtmlHook: IndexHtmlTransformHook = async ( } // elements with [href/src] attrs - const assetAttrs = assetAttrsConfig[node.nodeName] - if (assetAttrs) { - for (const p of node.attrs) { - const attrKey = getAttrKey(p) - if (p.value && assetAttrs.includes(attrKey)) { - processNodeUrl( - p, - node.sourceCodeLocation!.attrs![attrKey], - s, - config, - htmlPath, - originalUrl - ) - } - } + const assetAttrs = getNodeAssetAttributes(node) + for (const attr of assetAttrs) { + processNodeUrl( + attr.attribute, + attr.location, + s, + config, + htmlPath, + originalUrl + ) } }) diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 5ba9437c4e0eab..dee00922e84eff 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -216,6 +216,16 @@ 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\/assets\/asset-\w{8}\.png/ : /\/foo\/nested\/asset.png/ + ) + }) +}) + describe('svg fragments', () => { // 404 is checked already, so here we just ensure the urls end with #fragment test('img url', async () => { diff --git a/playground/assets/index.html b/playground/assets/index.html index f897d61a355ed0..56184e581bf300 100644 --- a/playground/assets/index.html +++ b/playground/assets/index.html @@ -3,6 +3,11 @@ + From 25cf3c2c9e2bfe1680b7467aa70abe5eb0bc7747 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Mon, 5 Dec 2022 17:19:07 +0800 Subject: [PATCH 2/7] fix: typo --- packages/vite/src/node/assetSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/src/node/assetSource.ts b/packages/vite/src/node/assetSource.ts index daeffeb2bdea7d..7245ce28ffc721 100644 --- a/packages/vite/src/node/assetSource.ts +++ b/packages/vite/src/node/assetSource.ts @@ -42,7 +42,7 @@ export function getNodeAssetAttributes( assetSource.attributes.includes(attrNames[i]) && (!assetSource.filter || assetSource.filter({ - attribute: attr.name, + attribute: attrNames[i], value: attr.value, attributes })) From dbab105531a8ce612d8c948a6731cc9ed334e4a4 Mon Sep 17 00:00:00 2001 From: bluwy Date: Tue, 29 Oct 2024 20:38:02 +0800 Subject: [PATCH 3/7] wip: step 1 --- packages/vite/src/node/assetSource.ts | 147 ++++++++---------- packages/vite/src/node/plugins/html.ts | 52 +++---- .../src/node/server/middlewares/indexHtml.ts | 46 ++++-- 3 files changed, 112 insertions(+), 133 deletions(-) diff --git a/packages/vite/src/node/assetSource.ts b/packages/vite/src/node/assetSource.ts index 76378f2a473904..fa5e0ac2974be6 100644 --- a/packages/vite/src/node/assetSource.ts +++ b/packages/vite/src/node/assetSource.ts @@ -1,9 +1,14 @@ import type { DefaultTreeAdapterMap, Token } from 'parse5' +// Asset list is derived from https://github.com/webpack-contrib/html-loader +// MIT license: https://github.com/webpack-contrib/html-loader/blob/master/LICENSE + interface HtmlAssetSource { - tag: string - attributes: string[] - type: 'src' | 'srcset' + srcAttributes?: string[] + srcsetAttributes?: string[] + /** + * Called before handling an attribute to determine if it should be processed. + */ filter?: (data: HtmlAssetSourceFilterData) => boolean } @@ -27,10 +32,13 @@ export function getNodeAssetAttributes( for (const assetSource of DEFAULT_HTML_ASSET_SOURCES) { if (assetSource.tag !== node.nodeName) continue - const attributes = node.attrs.reduce((acc, attr) => { - acc[getAttrKey(attr)] = attr.value - return acc - }, {} as Record) + const attributes = node.attrs.reduce( + (acc, attr) => { + acc[getAttrKey(attr)] = attr.value + return acc + }, + {} as Record, + ) const attrNames = Object.keys(attributes) @@ -110,79 +118,54 @@ const ALLOWED_META_PROPERTY = [ 'vk:image', ] -const DEFAULT_HTML_ASSET_SOURCES: HtmlAssetSource[] = [ - { - tag: 'audio', - type: 'src', - attributes: ['src'], - }, - { - tag: 'embed', - type: 'src', - attributes: ['src'], - }, - { - tag: 'img', - type: 'src', - attributes: ['src'], +export const DEFAULT_HTML_ASSET_SOURCES: Record = { + audio: { + srcAttributes: ['src'], }, - { - tag: 'img', - type: 'srcset', - attributes: ['srcset'], + embed: { + srcAttributes: ['src'], }, - { - tag: 'input', - type: 'src', - attributes: ['src'], + img: { + srcAttributes: ['src'], + srcsetAttributes: ['srcset'], }, - { - tag: 'object', - type: 'src', - attributes: ['data'], + input: { + srcAttributes: ['src'], }, - { - tag: 'source', - type: 'src', - attributes: ['src'], + object: { + srcAttributes: ['data'], }, - { - tag: 'source', - type: 'srcset', - attributes: ['srcset'], + source: { + srcAttributes: ['src'], + srcsetAttributes: ['srcset'], }, - { - tag: 'track', - type: 'src', - attributes: ['src'], + track: { + srcAttributes: ['src'], }, - { - tag: 'video', - type: 'src', - attributes: ['poster', 'src'], + video: { + srcAttributes: ['src', 'poster'], }, - { - tag: 'image', - type: 'src', - attributes: ['href', 'xlink:href'], + image: { + srcAttributes: ['href', 'xlink:href'], }, - { - tag: 'use', - type: 'src', - attributes: ['href', 'xlink:href'], + use: { + srcAttributes: ['href', 'xlink:href'], }, - { - tag: 'link', - type: 'src', - attributes: ['href'], - filter({ attributes }) { - if (attributes.rel && ALLOWED_REL.includes(attributes.rel)) { + link: { + srcAttributes: ['href'], + srcsetAttributes: ['imagesrcset'], + filter({ attribute, attributes }) { + if ( + attributes.rel && + ALLOWED_REL.includes(attributes.rel.trim().toLowerCase()) + ) { return true } if ( + attribute === 'href' && attributes.itemprop && - ALLOWED_ITEMPROP.includes(attributes.itemprop) + ALLOWED_ITEMPROP.includes(attributes.itemprop.trim().toLowerCase()) ) { return true } @@ -190,37 +173,29 @@ const DEFAULT_HTML_ASSET_SOURCES: HtmlAssetSource[] = [ return false }, }, - { - tag: 'link', - type: 'srcset', - attributes: ['imagesrcset'], - filter({ attributes }) { - if (attributes.rel && ALLOWED_REL.includes(attributes.rel)) { - return true - } - - return false - }, - }, - { - tag: 'meta', - type: 'src', - attributes: ['content'], - filter({ attributes }) { - if (attributes.name && ALLOWED_META_NAME.includes(attributes.name)) { + meta: { + srcAttributes: ['content'], + filter({ attribute, attributes }) { + if ( + attribute === 'content' && + attributes.name && + ALLOWED_META_NAME.includes(attributes.name.trim().toLowerCase()) + ) { return true } if ( + attribute === 'content' && attributes.property && - ALLOWED_META_PROPERTY.includes(attributes.property) + ALLOWED_META_PROPERTY.includes(attributes.property.trim().toLowerCase()) ) { return true } if ( + attribute === 'content' && attributes.itemprop && - ALLOWED_ITEMPROP.includes(attributes.itemprop) + ALLOWED_ITEMPROP.includes(attributes.itemprop.trim().toLowerCase()) ) { return true } @@ -228,4 +203,4 @@ const DEFAULT_HTML_ASSET_SOURCES: HtmlAssetSource[] = [ return false }, }, -] +} diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index cf6d3c3d96334e..088185a31cb7cf 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -33,6 +33,7 @@ import { resolveEnvPrefix } from '../env' import type { Logger } from '../logger' import { cleanUrl } from '../../shared/utils' import { usePerEnvironmentState } from '../environment' +import { DEFAULT_HTML_ASSET_SOURCES } 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 -export const assetAttrsConfig: Record = { - link: ['href'], - video: ['src', 'poster'], - source: ['src', 'srcset'], - img: ['src', 'srcset'], - image: ['xlink:href', 'href'], - use: ['xlink:href', 'href'], -} - // Some `` 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) @@ -504,16 +495,24 @@ 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] + const assetAttrs = DEFAULT_HTML_ASSET_SOURCES[node.nodeName] if (assetAttrs) { - for (const p of node.attrs) { - const attrKey = getAttrKey(p) - if (p.value && assetAttrs.includes(attrKey)) { - if (attrKey === 'srcset') { + const nodeAttrs: Record = {} + for (const attr of node.attrs) { + nodeAttrs[getAttrKey(attr)] = attr.value + } + if ('vite-ignore' in nodeAttrs) { + // TODO: to be merged + // removeViteIgnoreAttr(s, node.sourceCodeLocation!) + } else { + for (const attrKey in nodeAttrs) { + const attrValue = nodeAttrs[attrKey] + if (!attrValue) continue + if (assetAttrs.srcsetAttributes?.includes(attrKey)) { assetUrlsPromises.push( (async () => { const processedEncodedUrl = await processSrcSet( - p.value, + attrValue, async ({ url }) => { const decodedUrl = decodeURI(url) if (!isExcludedUrl(decodedUrl)) { @@ -525,7 +524,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { return url }, ) - if (processedEncodedUrl !== p.value) { + if (processedEncodedUrl !== attrValue) { overwriteAttrValue( s, getAttrSourceCodeLocation(node, attrKey), @@ -534,8 +533,8 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { } })(), ) - } else { - const url = decodeURI(p.value) + } else if (assetAttrs.srcAttributes?.includes(attrKey)) { + const url = decodeURI(attrValue) if (checkPublicFile(url, config)) { overwriteAttrValue( s, @@ -547,11 +546,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { node.nodeName === 'link' && isCSSRequest(url) && // should not be converted if following attributes are present (#6748) - !node.attrs.some( - (p) => - p.prefix === undefined && - (p.name === 'media' || p.name === 'disabled'), - ) + !('media' in nodeAttrs || 'disabled' in nodeAttrs) ) { // CSS references, convert to import const importExpression = `\nimport ${JSON.stringify(url)}` @@ -566,12 +561,9 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { // to `false` to force no inline. If `undefined`, it leaves to the default heuristics. const isNoInlineLink = node.nodeName === 'link' && - node.attrs.some( - (p) => - p.name === 'rel' && - parseRelAttr(p.value).some((v) => - noInlineLinkRels.has(v), - ), + nodeAttrs.rel && + parseRelAttr(nodeAttrs.rel).some((v) => + noInlineLinkRels.has(v), ) const shouldInline = isNoInlineLink ? false : undefined assetUrlsPromises.push( diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index e55cf8505138cc..25ef038bf4bafa 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -8,7 +8,6 @@ import type { IndexHtmlTransformHook } from '../../plugins/html' import { addToHTMLProxyCache, applyHtmlTransforms, - assetAttrsConfig, extractImportExpressionFromClassicScript, findNeedTransformStyleAttribute, getAttrKey, @@ -44,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 { DEFAULT_HTML_ASSET_SOURCES } from '../../assetSource' interface AssetNode { start: number @@ -330,24 +330,36 @@ const devHtmlHook: IndexHtmlTransformHook = async ( } // elements with [href/src] attrs - const assetAttrs = assetAttrsConfig[node.nodeName] + const assetAttrs = DEFAULT_HTML_ASSET_SOURCES[node.nodeName] if (assetAttrs) { - for (const p of node.attrs) { - const attrKey = getAttrKey(p) - if (p.value && assetAttrs.includes(attrKey)) { - const processedUrl = processNodeUrl( - p.value, - isSrcSet(p), - config, - htmlPath, - originalUrl, - ) - if (processedUrl !== p.value) { - overwriteAttrValue( - s, - node.sourceCodeLocation!.attrs![attrKey], - processedUrl, + const nodeAttrs: Record = {} + for (const attr of node.attrs) { + nodeAttrs[getAttrKey(attr)] = attr.value + } + if ('vite-ignore' in nodeAttrs) { + // TODO: to be merged + // removeViteIgnoreAttr(s, node.sourceCodeLocation!) + } else { + for (const attrKey in nodeAttrs) { + const attrValue = nodeAttrs[attrKey] + if (!attrValue) continue + const isSrcSet = assetAttrs.srcsetAttributes?.includes(attrKey) + const isSrc = assetAttrs.srcAttributes?.includes(attrKey) + if (isSrcSet || isSrc) { + const processedUrl = processNodeUrl( + attrValue, + !!isSrcSet, + config, + htmlPath, + originalUrl, ) + if (processedUrl !== attrValue) { + overwriteAttrValue( + s, + node.sourceCodeLocation!.attrs![attrKey], + processedUrl, + ) + } } } } From f5711cd4f8c433a52bb311cf2f861da34c5feb83 Mon Sep 17 00:00:00 2001 From: bluwy Date: Tue, 29 Oct 2024 20:56:50 +0800 Subject: [PATCH 4/7] chore: done --- packages/vite/src/node/assetSource.ts | 95 ++++++----- packages/vite/src/node/plugins/html.ts | 156 ++++++++---------- .../src/node/server/middlewares/indexHtml.ts | 46 ++---- 3 files changed, 139 insertions(+), 158 deletions(-) diff --git a/packages/vite/src/node/assetSource.ts b/packages/vite/src/node/assetSource.ts index fa5e0ac2974be6..f93208b118b791 100644 --- a/packages/vite/src/node/assetSource.ts +++ b/packages/vite/src/node/assetSource.ts @@ -18,53 +18,70 @@ interface HtmlAssetSourceFilterData { attributes: Record } -interface AssetAttribute { - attribute: Token.Attribute - type: 'src' | 'srcset' +interface HtmlAssetAction { + type: 'src' | 'srcset' | 'remove' + attribute: string + value: string + attributes: Record location: Token.Location } -export function getNodeAssetAttributes( +export function getNodeAssetActions( node: DefaultTreeAdapterMap['element'], -): AssetAttribute[] { - const assetAttrs: AssetAttribute[] = [] +): HtmlAssetAction[] { + const matched = DEFAULT_HTML_ASSET_SOURCES[node.nodeName] + if (!matched) return [] - for (const assetSource of DEFAULT_HTML_ASSET_SOURCES) { - if (assetSource.tag !== node.nodeName) continue + const attributes: Record = {} + for (const attr of node.attrs) { + attributes[getAttrKey(attr)] = attr.value + } - const attributes = node.attrs.reduce( - (acc, attr) => { - acc[getAttrKey(attr)] = attr.value - return acc + // If the node has a `vite-ignore` attribute, remove the attribute and early out + if ('vite-ignore' in attributes) { + return [ + { + type: 'remove', + attribute: 'vite-ignore', + value: '', + attributes, + location: node.sourceCodeLocation!.attrs!['vite-ignore'], }, - {} as Record, - ) - - const attrNames = Object.keys(attributes) - - for (let i = 0; i < node.attrs.length; i++) { - const attr = node.attrs[i] - - if ( - attr.value && - assetSource.attributes.includes(attrNames[i]) && - (!assetSource.filter || - assetSource.filter({ - attribute: attrNames[i], - value: attr.value, - attributes, - })) - ) { - assetAttrs.push({ - attribute: attr, - type: assetSource.type, - location: node.sourceCodeLocation!.attrs![attrNames[i]], - }) - } - } + ] } - return assetAttrs + const actions: HtmlAssetAction[] = [] + // Check src + matched.srcAttributes?.forEach((attribute) => { + const value = attributes[attribute] + if (!value) return + if (matched.filter && !matched.filter({ attribute, value, attributes })) { + return + } + actions.push({ + type: 'src', + attribute, + value, + attributes, + location: node.sourceCodeLocation!.attrs![attribute], + }) + }) + // Check srcset + matched.srcsetAttributes?.forEach((attribute) => { + const value = attributes[attribute] + if (!value) return + if (matched.filter && !matched.filter({ attribute, value, attributes })) { + return + } + actions.push({ + type: 'srcset', + attribute, + value, + attributes, + location: node.sourceCodeLocation!.attrs![attribute], + }) + }) + return actions } function getAttrKey(attr: Token.Attribute): string { @@ -118,7 +135,7 @@ const ALLOWED_META_PROPERTY = [ 'vk:image', ] -export const DEFAULT_HTML_ASSET_SOURCES: Record = { +const DEFAULT_HTML_ASSET_SOURCES: Record = { audio: { srcAttributes: ['src'], }, diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index 088185a31cb7cf..959af5883d56f6 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -33,7 +33,7 @@ import { resolveEnvPrefix } from '../env' import type { Logger } from '../logger' import { cleanUrl } from '../../shared/utils' import { usePerEnvironmentState } from '../environment' -import { DEFAULT_HTML_ASSET_SOURCES } from '../assetSource' +import { getNodeAssetActions } from '../assetSource' import { assetUrlRE, getPublicAssetFilename, @@ -495,94 +495,83 @@ 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 = DEFAULT_HTML_ASSET_SOURCES[node.nodeName] - if (assetAttrs) { - const nodeAttrs: Record = {} - for (const attr of node.attrs) { - nodeAttrs[getAttrKey(attr)] = attr.value - } - if ('vite-ignore' in nodeAttrs) { - // TODO: to be merged - // removeViteIgnoreAttr(s, node.sourceCodeLocation!) - } else { - for (const attrKey in nodeAttrs) { - const attrValue = nodeAttrs[attrKey] - if (!attrValue) continue - if (assetAttrs.srcsetAttributes?.includes(attrKey)) { + const assetActions = getNodeAssetActions(node) + for (const action of assetActions) { + if (action.type === 'remove') { + s.remove(action.location.startOffset, action.location.endOffset) + continue + } else if (action.type === 'srcset') { + assetUrlsPromises.push( + (async () => { + const processedEncodedUrl = await processSrcSet( + action.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 !== action.value) { + overwriteAttrValue(s, action.location, processedEncodedUrl) + } + })(), + ) + } else if (action.type === 'src') { + const url = decodeURI(action.value) + if (checkPublicFile(url, config)) { + overwriteAttrValue( + s, + action.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 action.attributes || + 'disabled' in action.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' && + action.attributes.rel && + parseRelAttr(action.attributes.rel).some((v) => + noInlineLinkRels.has(v), + ) + const shouldInline = isNoInlineLink ? false : undefined 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 - }, + const processedUrl = await processAssetUrl( + url, + shouldInline, ) - if (processedEncodedUrl !== attrValue) { + if (processedUrl !== url) { overwriteAttrValue( s, - getAttrSourceCodeLocation(node, attrKey), - processedEncodedUrl, + action.location, + partialEncodeURIPath(processedUrl), ) } })(), ) - } else if (assetAttrs.srcAttributes?.includes(attrKey)) { - const url = decodeURI(attrValue) - if (checkPublicFile(url, config)) { - overwriteAttrValue( - s, - getAttrSourceCodeLocation(node, attrKey), - 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 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), - ) - } - })(), - ) - } - } } } } @@ -1534,10 +1523,3 @@ function incrementIndent(indent: string = '') { 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] -} diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 25ef038bf4bafa..0bac403e1a1b9f 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -10,7 +10,6 @@ import { applyHtmlTransforms, extractImportExpressionFromClassicScript, findNeedTransformStyleAttribute, - getAttrKey, getScriptInfo, htmlEnvHook, htmlProxyResult, @@ -43,7 +42,7 @@ import { checkPublicFile } from '../../publicDir' import { isCSSRequest } from '../../plugins/css' import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap' import { cleanUrl, unwrapId, wrapId } from '../../../shared/utils' -import { DEFAULT_HTML_ASSET_SOURCES } from '../../assetSource' +import { getNodeAssetActions } from '../../assetSource' interface AssetNode { start: number @@ -330,37 +329,20 @@ const devHtmlHook: IndexHtmlTransformHook = async ( } // elements with [href/src] attrs - const assetAttrs = DEFAULT_HTML_ASSET_SOURCES[node.nodeName] - if (assetAttrs) { - const nodeAttrs: Record = {} - for (const attr of node.attrs) { - nodeAttrs[getAttrKey(attr)] = attr.value - } - if ('vite-ignore' in nodeAttrs) { - // TODO: to be merged - // removeViteIgnoreAttr(s, node.sourceCodeLocation!) + const assetActions = getNodeAssetActions(node) + for (const action of assetActions) { + if (action.type === 'remove') { + s.remove(action.location.startOffset, action.location.endOffset) } else { - for (const attrKey in nodeAttrs) { - const attrValue = nodeAttrs[attrKey] - if (!attrValue) continue - const isSrcSet = assetAttrs.srcsetAttributes?.includes(attrKey) - const isSrc = assetAttrs.srcAttributes?.includes(attrKey) - if (isSrcSet || isSrc) { - const processedUrl = processNodeUrl( - attrValue, - !!isSrcSet, - config, - htmlPath, - originalUrl, - ) - if (processedUrl !== attrValue) { - overwriteAttrValue( - s, - node.sourceCodeLocation!.attrs![attrKey], - processedUrl, - ) - } - } + const processedUrl = processNodeUrl( + action.value, + action.type === 'srcset', + config, + htmlPath, + originalUrl, + ) + if (processedUrl !== action.value) { + overwriteAttrValue(s, action.location, processedUrl) } } } From 0b8a200b8bbadd24e1e49eda8b4c0801ecde4ca8 Mon Sep 17 00:00:00 2001 From: bluwy Date: Wed, 30 Oct 2024 23:04:20 +0800 Subject: [PATCH 5/7] update --- packages/vite/src/node/assetSource.ts | 67 +++++++------------ packages/vite/src/node/plugins/html.ts | 35 +++++----- .../src/node/server/middlewares/indexHtml.ts | 18 ++--- 3 files changed, 50 insertions(+), 70 deletions(-) diff --git a/packages/vite/src/node/assetSource.ts b/packages/vite/src/node/assetSource.ts index f93208b118b791..d5e67e63763229 100644 --- a/packages/vite/src/node/assetSource.ts +++ b/packages/vite/src/node/assetSource.ts @@ -13,22 +13,25 @@ interface HtmlAssetSource { } interface HtmlAssetSourceFilterData { - attribute: string + key: string value: string attributes: Record } -interface HtmlAssetAction { +interface HtmlAssetAttribute { type: 'src' | 'srcset' | 'remove' - attribute: string + key: string value: string attributes: Record location: Token.Location } -export function getNodeAssetActions( +/** + * Given a HTML node, find all attributes that references an asset to be processed + */ +export function getNodeAssetAttributes( node: DefaultTreeAdapterMap['element'], -): HtmlAssetAction[] { +): HtmlAssetAttribute[] { const matched = DEFAULT_HTML_ASSET_SOURCES[node.nodeName] if (!matched) return [] @@ -38,11 +41,12 @@ export function getNodeAssetActions( } // 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', - attribute: 'vite-ignore', + key: 'vite-ignore', value: '', attributes, location: node.sourceCodeLocation!.attrs!['vite-ignore'], @@ -50,37 +54,16 @@ export function getNodeAssetActions( ] } - const actions: HtmlAssetAction[] = [] - // Check src - matched.srcAttributes?.forEach((attribute) => { - const value = attributes[attribute] + const actions: HtmlAssetAttribute[] = [] + function handleAttributeKey(key: string, type: 'src' | 'srcset') { + const value = attributes[key] if (!value) return - if (matched.filter && !matched.filter({ attribute, value, attributes })) { - return - } - actions.push({ - type: 'src', - attribute, - value, - attributes, - location: node.sourceCodeLocation!.attrs![attribute], - }) - }) - // Check srcset - matched.srcsetAttributes?.forEach((attribute) => { - const value = attributes[attribute] - if (!value) return - if (matched.filter && !matched.filter({ attribute, value, attributes })) { - return - } - actions.push({ - type: 'srcset', - attribute, - value, - attributes, - location: node.sourceCodeLocation!.attrs![attribute], - }) - }) + 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 } @@ -171,7 +154,7 @@ const DEFAULT_HTML_ASSET_SOURCES: Record = { link: { srcAttributes: ['href'], srcsetAttributes: ['imagesrcset'], - filter({ attribute, attributes }) { + filter({ key, attributes }) { if ( attributes.rel && ALLOWED_REL.includes(attributes.rel.trim().toLowerCase()) @@ -180,7 +163,7 @@ const DEFAULT_HTML_ASSET_SOURCES: Record = { } if ( - attribute === 'href' && + key === 'href' && attributes.itemprop && ALLOWED_ITEMPROP.includes(attributes.itemprop.trim().toLowerCase()) ) { @@ -192,9 +175,9 @@ const DEFAULT_HTML_ASSET_SOURCES: Record = { }, meta: { srcAttributes: ['content'], - filter({ attribute, attributes }) { + filter({ key, attributes }) { if ( - attribute === 'content' && + key === 'content' && attributes.name && ALLOWED_META_NAME.includes(attributes.name.trim().toLowerCase()) ) { @@ -202,7 +185,7 @@ const DEFAULT_HTML_ASSET_SOURCES: Record = { } if ( - attribute === 'content' && + key === 'content' && attributes.property && ALLOWED_META_PROPERTY.includes(attributes.property.trim().toLowerCase()) ) { @@ -210,7 +193,7 @@ const DEFAULT_HTML_ASSET_SOURCES: Record = { } if ( - attribute === 'content' && + key === 'content' && attributes.itemprop && ALLOWED_ITEMPROP.includes(attributes.itemprop.trim().toLowerCase()) ) { diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index 53e4981d65b243..71eaa1e47e22c3 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -33,7 +33,7 @@ import { resolveEnvPrefix } from '../env' import type { Logger } from '../logger' import { cleanUrl } from '../../shared/utils' import { usePerEnvironmentState } from '../environment' -import { getNodeAssetActions } from '../assetSource' +import { getNodeAssetAttributes } from '../assetSource' import { assetUrlRE, getPublicAssetFilename, @@ -516,16 +516,16 @@ 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 assetActions = getNodeAssetActions(node) - for (const action of assetActions) { - if (action.type === 'remove') { - s.remove(action.location.startOffset, action.location.endOffset) + const assetAttributes = getNodeAssetAttributes(node) + for (const attr of assetAttributes) { + if (attr.type === 'remove') { + s.remove(attr.location.startOffset, attr.location.endOffset) continue - } else if (action.type === 'srcset') { + } else if (attr.type === 'srcset') { assetUrlsPromises.push( (async () => { const processedEncodedUrl = await processSrcSet( - action.value, + attr.value, async ({ url }) => { const decodedUrl = decodeURI(url) if (!isExcludedUrl(decodedUrl)) { @@ -537,17 +537,17 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { return url }, ) - if (processedEncodedUrl !== action.value) { - overwriteAttrValue(s, action.location, processedEncodedUrl) + if (processedEncodedUrl !== attr.value) { + overwriteAttrValue(s, attr.location, processedEncodedUrl) } })(), ) - } else if (action.type === 'src') { - const url = decodeURI(action.value) + } else if (attr.type === 'src') { + const url = decodeURI(attr.value) if (checkPublicFile(url, config)) { overwriteAttrValue( s, - action.location, + attr.location, partialEncodeURIPath(toOutputPublicFilePath(url)), ) } else if (!isExcludedUrl(url)) { @@ -555,10 +555,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { node.nodeName === 'link' && isCSSRequest(url) && // should not be converted if following attributes are present (#6748) - !( - 'media' in action.attributes || - 'disabled' in action.attributes - ) + !('media' in attr.attributes || 'disabled' in attr.attributes) ) { // CSS references, convert to import const importExpression = `\nimport ${JSON.stringify(url)}` @@ -573,8 +570,8 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { // to `false` to force no inline. If `undefined`, it leaves to the default heuristics. const isNoInlineLink = node.nodeName === 'link' && - action.attributes.rel && - parseRelAttr(action.attributes.rel).some((v) => + attr.attributes.rel && + parseRelAttr(attr.attributes.rel).some((v) => noInlineLinkRels.has(v), ) const shouldInline = isNoInlineLink ? false : undefined @@ -587,7 +584,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { if (processedUrl !== url) { overwriteAttrValue( s, - action.location, + attr.location, partialEncodeURIPath(processedUrl), ) } diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 9adcc40089f4ff..e11c8470e34837 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -43,7 +43,7 @@ import { checkPublicFile } from '../../publicDir' import { isCSSRequest } from '../../plugins/css' import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap' import { cleanUrl, unwrapId, wrapId } from '../../../shared/utils' -import { getNodeAssetActions } from '../../assetSource' +import { getNodeAssetAttributes } from '../../assetSource' interface AssetNode { start: number @@ -331,20 +331,20 @@ const devHtmlHook: IndexHtmlTransformHook = async ( } // elements with [href/src] attrs - const assetActions = getNodeAssetActions(node) - for (const action of assetActions) { - if (action.type === 'remove') { - s.remove(action.location.startOffset, action.location.endOffset) + const assetAttributes = getNodeAssetAttributes(node) + for (const attr of assetAttributes) { + if (attr.type === 'remove') { + s.remove(attr.location.startOffset, attr.location.endOffset) } else { const processedUrl = processNodeUrl( - action.value, - action.type === 'srcset', + attr.value, + attr.type === 'srcset', config, htmlPath, originalUrl, ) - if (processedUrl !== action.value) { - overwriteAttrValue(s, action.location, processedUrl) + if (processedUrl !== attr.value) { + overwriteAttrValue(s, attr.location, processedUrl) } } } From 472ab9aab14fa0cdce070eb502c44b83e76e114f Mon Sep 17 00:00:00 2001 From: bluwy Date: Wed, 30 Oct 2024 23:29:39 +0800 Subject: [PATCH 6/7] add test --- .../src/node/__tests__/assetSource.spec.ts | 91 +++++++++++++ packages/vite/src/node/assetSource.ts | 120 +++++++++--------- 2 files changed, 153 insertions(+), 58 deletions(-) create mode 100644 packages/vite/src/node/__tests__/assetSource.spec.ts diff --git a/packages/vite/src/node/__tests__/assetSource.spec.ts b/packages/vite/src/node/__tests__/assetSource.spec.ts new file mode 100644 index 00000000000000..c542f3dde2027a --- /dev/null +++ b/packages/vite/src/node/__tests__/assetSource.spec.ts @@ -0,0 +1,91 @@ +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('') + 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('') + 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('