mirror of
https://github.com/vitejs/vite.git
synced 2024-11-21 22:59:10 +00:00
feat(html): support vite-ignore
attribute to opt-out of processing (#18494)
This commit is contained in:
parent
1507068b6d
commit
d9513104e2
@ -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:
|
||||
|
@ -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),
|
||||
)
|
||||
}
|
||||
})(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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 />
|
||||
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user