feat(html): support vite-ignore attribute to opt-out of processing (#18494)

This commit is contained in:
Bjorn Lu 2024-10-30 15:20:34 +08:00 committed by GitHub
parent 1507068b6d
commit d9513104e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 272 additions and 156 deletions

View File

@ -160,6 +160,35 @@ For example, to make the default import of `*.svg` a React component:
:::
## HTML
HTML files stand [front-and-center](/guide/#index-html-and-project-root) of a Vite project, serving as the entry points for your application, making it simple to build single-page and [multi-page applications](/guide/build.html#multi-page-app).
Any HTML files in your project root can be directly accessed by its respective directory path:
- `<root>/index.html` -> `http://localhost:5173/`
- `<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
<!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>
</html>
```
To opt-out of HTML processing on certain elements, you can add the `vite-ignore` attribute on the element, which can be useful when referencing external assets or CDN.
## Vue
Vite provides first-class Vue support:

View File

@ -211,11 +211,13 @@ export function getScriptInfo(node: DefaultTreeAdapterMap['element']): {
sourceCodeLocation: Token.Location | undefined
isModule: boolean
isAsync: boolean
isIgnored: boolean
} {
let src: Token.Attribute | undefined
let sourceCodeLocation: Token.Location | undefined
let isModule = false
let isAsync = false
let isIgnored = false
for (const p of node.attrs) {
if (p.prefix !== undefined) continue
if (p.name === 'src') {
@ -227,9 +229,11 @@ export function getScriptInfo(node: DefaultTreeAdapterMap['element']): {
isModule = true
} else if (p.name === 'async') {
isAsync = true
} else if (p.name === 'vite-ignore') {
isIgnored = true
}
}
return { src, sourceCodeLocation, isModule, isAsync }
return { src, sourceCodeLocation, isModule, isAsync, isIgnored }
}
const attrValueStartRE = /=\s*(.)/
@ -260,6 +264,19 @@ export function overwriteAttrValue(
return s
}
export function removeViteIgnoreAttr(
s: MagicString,
sourceCodeLocation: Token.Location,
): MagicString {
const loc = (sourceCodeLocation as Token.LocationWithAttributes).attrs?.[
'vite-ignore'
]
if (loc) {
s.remove(loc.startOffset, loc.endOffset)
}
return s
}
/**
* Format parse5 @type {ParserError} to @type {RollupError}
*/
@ -437,68 +454,72 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
// script tags
if (node.nodeName === 'script') {
const { src, sourceCodeLocation, isModule, isAsync } =
const { src, sourceCodeLocation, isModule, isAsync, isIgnored } =
getScriptInfo(node)
const url = src && src.value
const isPublicFile = !!(url && checkPublicFile(url, config))
if (isPublicFile) {
// referencing public dir url, prefix with base
overwriteAttrValue(
s,
sourceCodeLocation!,
partialEncodeURIPath(toOutputPublicFilePath(url)),
)
}
if (isModule) {
inlineModuleIndex++
if (url && !isExcludedUrl(url) && !isPublicFile) {
setModuleSideEffectPromises.push(
this.resolve(url, id)
.then((resolved) => {
if (!resolved) {
return Promise.reject()
}
return this.load(resolved)
})
.then((mod) => {
// set this to keep the module even if `treeshake.moduleSideEffects=false` is set
mod.moduleSideEffects = true
}),
if (isIgnored) {
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
} else {
const url = src && src.value
const isPublicFile = !!(url && checkPublicFile(url, config))
if (isPublicFile) {
// referencing public dir url, prefix with base
overwriteAttrValue(
s,
sourceCodeLocation!,
partialEncodeURIPath(toOutputPublicFilePath(url)),
)
// <script type="module" src="..."/>
// add it as an import
js += `\nimport ${JSON.stringify(url)}`
shouldRemove = true
}
if (isModule) {
inlineModuleIndex++
if (url && !isExcludedUrl(url) && !isPublicFile) {
setModuleSideEffectPromises.push(
this.resolve(url, id)
.then((resolved) => {
if (!resolved) {
return Promise.reject()
}
return this.load(resolved)
})
.then((mod) => {
// set this to keep the module even if `treeshake.moduleSideEffects=false` is set
mod.moduleSideEffects = true
}),
)
// <script type="module" src="..."/>
// add it as an import
js += `\nimport ${JSON.stringify(url)}`
shouldRemove = true
} else if (node.childNodes.length) {
const scriptNode =
node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
const contents = scriptNode.value
// <script type="module">...</script>
const filePath = id.replace(normalizePath(config.root), '')
addToHTMLProxyCache(config, filePath, inlineModuleIndex, {
code: contents,
})
js += `\nimport "${id}?html-proxy&index=${inlineModuleIndex}.js"`
shouldRemove = true
}
everyScriptIsAsync &&= isAsync
someScriptsAreAsync ||= isAsync
someScriptsAreDefer ||= !isAsync
} else if (url && !isPublicFile) {
if (!isExcludedUrl(url)) {
config.logger.warn(
`<script src="${url}"> in "${publicPath}" can't be bundled without type="module" attribute`,
)
}
} else if (node.childNodes.length) {
const scriptNode =
node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
const contents = scriptNode.value
// <script type="module">...</script>
const filePath = id.replace(normalizePath(config.root), '')
addToHTMLProxyCache(config, filePath, inlineModuleIndex, {
code: contents,
})
js += `\nimport "${id}?html-proxy&index=${inlineModuleIndex}.js"`
shouldRemove = true
}
everyScriptIsAsync &&= isAsync
someScriptsAreAsync ||= isAsync
someScriptsAreDefer ||= !isAsync
} else if (url && !isPublicFile) {
if (!isExcludedUrl(url)) {
config.logger.warn(
`<script src="${url}"> in "${publicPath}" can't be bundled without type="module" attribute`,
scriptUrls.push(
...extractImportExpressionFromClassicScript(scriptNode),
)
}
} else if (node.childNodes.length) {
const scriptNode =
node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
scriptUrls.push(
...extractImportExpressionFromClassicScript(scriptNode),
)
}
}
@ -506,89 +527,92 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
// 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)) {
if (attrKey === 'srcset') {
assetUrlsPromises.push(
(async () => {
const processedEncodedUrl = await processSrcSet(
p.value,
async ({ url }) => {
const decodedUrl = decodeURI(url)
if (!isExcludedUrl(decodedUrl)) {
const result = await processAssetUrl(url)
return result !== decodedUrl
? encodeURIPath(result)
: url
}
return url
},
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,
)
}
})(),
)
} else {
const url = decodeURI(attrValue)
if (checkPublicFile(url, config)) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(toOutputPublicFilePath(url)),
)
if (processedEncodedUrl !== p.value) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
processedEncodedUrl,
} 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),
)
}
})(),
)
}
})(),
)
} else {
const url = decodeURI(p.value)
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)
!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: 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' &&
node.attrs.some(
(p) =>
p.name === 'rel' &&
parseRelAttr(p.value).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),
)
}
})(),
)
}
}
}

View File

@ -21,6 +21,7 @@ import {
overwriteAttrValue,
postImportMapHook,
preImportMapHook,
removeViteIgnoreAttr,
resolveHtmlTransforms,
traverseHtml,
} from '../../plugins/html'
@ -117,8 +118,6 @@ function isBareRelative(url: string) {
return wordCharRE.test(url[0]) && !url.includes(':')
}
const isSrcSet = (attr: Token.Attribute) =>
attr.name === 'srcset' && attr.prefix === undefined
const processNodeUrl = (
url: string,
useSrcSetReplacer: boolean,
@ -270,12 +269,15 @@ const devHtmlHook: IndexHtmlTransformHook = async (
// script tags
if (node.nodeName === 'script') {
const { src, sourceCodeLocation, isModule } = getScriptInfo(node)
const { src, sourceCodeLocation, isModule, isIgnored } =
getScriptInfo(node)
if (src) {
if (isIgnored) {
removeViteIgnoreAttr(s, sourceCodeLocation!)
} else if (src) {
const processedUrl = processNodeUrl(
src.value,
isSrcSet(src),
getAttrKey(src) === 'srcset',
config,
htmlPath,
originalUrl,
@ -332,22 +334,32 @@ 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)) {
const processedUrl = processNodeUrl(
p.value,
isSrcSet(p),
config,
htmlPath,
originalUrl,
)
if (processedUrl !== p.value) {
overwriteAttrValue(
s,
node.sourceCodeLocation!.attrs![attrKey],
processedUrl,
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)) {
const processedUrl = processNodeUrl(
attrValue,
attrKey === 'srcset',
config,
htmlPath,
originalUrl,
)
if (processedUrl !== attrValue) {
overwriteAttrValue(
s,
node.sourceCodeLocation!.attrs![attrKey],
processedUrl,
)
}
}
}
}

View File

@ -7,6 +7,7 @@ import {
isBuild,
isServe,
page,
serverLogs,
untilBrowserLogAfter,
viteServer,
viteTestUrl,
@ -101,6 +102,27 @@ describe('main', () => {
expect(html).toMatch(`<!-- comment one -->`)
expect(html).toMatch(`<!-- comment two -->`)
})
test('external paths works with vite-ignore attribute', async () => {
expect(await page.textContent('.external-path')).toBe('works')
expect(await page.getAttribute('.external-path', 'vite-ignore')).toBe(null)
expect(await getColor('.external-path')).toBe('red')
if (isServe) {
expect(serverLogs).not.toEqual(
expect.arrayContaining([
expect.stringMatching('Failed to load url /external-path.js'),
]),
)
} else {
expect(serverLogs).not.toEqual(
expect.arrayContaining([
expect.stringMatching(
'can\'t be bundled without type="module" attribute',
),
]),
)
}
})
})
describe('nested', () => {

View File

@ -7,5 +7,9 @@
<script type="module" src="/main.js"></script>
<link rel="icon" href="{{cdn_host}}/cdn/images/favicon.ico" />
<link rel="stylesheet" href="{{cdn_host}}/css.css" type="text/css" />
<script src="{{cdn_host}}/js.js"></script>
<script src="{{cdn_host}}/js.js" vite-ignore></script>
<p>index.html (fallback)</p>
<div>External path: <span class="external-path"></span></div>
<script type="module" src="/external-path.js" vite-ignore></script>
<link rel="stylesheet" href="/external-path.css" vite-ignore />

View File

@ -231,5 +231,30 @@ ${
},
},
},
serveExternalPathPlugin(),
],
})
/** @returns {import('vite').Plugin} */
function serveExternalPathPlugin() {
const handler = (req, res, next) => {
if (req.url === '/external-path.js') {
res.setHeader('Content-Type', 'application/javascript')
res.end('document.querySelector(".external-path").textContent = "works"')
} else if (req.url === '/external-path.css') {
res.setHeader('Content-Type', 'text/css')
res.end('.external-path{color:red}')
} else {
next()
}
}
return {
name: 'serve-external-path',
configureServer(server) {
server.middlewares.use(handler)
},
configurePreviewServer(server) {
server.middlewares.use(handler)
},
}
}