Skip to content

Commit d951310

Browse files
authoredOct 30, 2024··
feat(html): support vite-ignore attribute to opt-out of processing (#18494)
1 parent 1507068 commit d951310

File tree

6 files changed

+269
-153
lines changed

6 files changed

+269
-153
lines changed
 

‎docs/guide/features.md

+29
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,35 @@ For example, to make the default import of `*.svg` a React component:
160160

161161
:::
162162

163+
## HTML
164+
165+
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).
166+
167+
Any HTML files in your project root can be directly accessed by its respective directory path:
168+
169+
- `<root>/index.html` -> `http://localhost:5173/`
170+
- `<root>/about.html` -> `http://localhost:5173/about.html`
171+
- `<root>/blog/index.html` -> `http://localhost:5173/blog/index.html`
172+
173+
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.
174+
175+
```html
176+
<!doctype html>
177+
<html>
178+
<head>
179+
<link rel="icon" href="/favicon.ico" />
180+
<link rel="stylesheet" href="/src/styles.css" />
181+
</head>
182+
<body>
183+
<div id="app"></div>
184+
<img src="/src/images/logo.svg" alt="logo" />
185+
<script type="module" src="/src/main.js"></script>
186+
</body>
187+
</html>
188+
```
189+
190+
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.
191+
163192
## Vue
164193

165194
Vite provides first-class Vue support:

‎packages/vite/src/node/plugins/html.ts

+156-132
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,13 @@ export function getScriptInfo(node: DefaultTreeAdapterMap['element']): {
211211
sourceCodeLocation: Token.Location | undefined
212212
isModule: boolean
213213
isAsync: boolean
214+
isIgnored: boolean
214215
} {
215216
let src: Token.Attribute | undefined
216217
let sourceCodeLocation: Token.Location | undefined
217218
let isModule = false
218219
let isAsync = false
220+
let isIgnored = false
219221
for (const p of node.attrs) {
220222
if (p.prefix !== undefined) continue
221223
if (p.name === 'src') {
@@ -227,9 +229,11 @@ export function getScriptInfo(node: DefaultTreeAdapterMap['element']): {
227229
isModule = true
228230
} else if (p.name === 'async') {
229231
isAsync = true
232+
} else if (p.name === 'vite-ignore') {
233+
isIgnored = true
230234
}
231235
}
232-
return { src, sourceCodeLocation, isModule, isAsync }
236+
return { src, sourceCodeLocation, isModule, isAsync, isIgnored }
233237
}
234238

235239
const attrValueStartRE = /=\s*(.)/
@@ -260,6 +264,19 @@ export function overwriteAttrValue(
260264
return s
261265
}
262266

267+
export function removeViteIgnoreAttr(
268+
s: MagicString,
269+
sourceCodeLocation: Token.Location,
270+
): MagicString {
271+
const loc = (sourceCodeLocation as Token.LocationWithAttributes).attrs?.[
272+
'vite-ignore'
273+
]
274+
if (loc) {
275+
s.remove(loc.startOffset, loc.endOffset)
276+
}
277+
return s
278+
}
279+
263280
/**
264281
* Format parse5 @type {ParserError} to @type {RollupError}
265282
*/
@@ -437,158 +454,165 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
437454

438455
// script tags
439456
if (node.nodeName === 'script') {
440-
const { src, sourceCodeLocation, isModule, isAsync } =
457+
const { src, sourceCodeLocation, isModule, isAsync, isIgnored } =
441458
getScriptInfo(node)
442459

443-
const url = src && src.value
444-
const isPublicFile = !!(url && checkPublicFile(url, config))
445-
if (isPublicFile) {
446-
// referencing public dir url, prefix with base
447-
overwriteAttrValue(
448-
s,
449-
sourceCodeLocation!,
450-
partialEncodeURIPath(toOutputPublicFilePath(url)),
451-
)
452-
}
453-
454-
if (isModule) {
455-
inlineModuleIndex++
456-
if (url && !isExcludedUrl(url) && !isPublicFile) {
457-
setModuleSideEffectPromises.push(
458-
this.resolve(url, id)
459-
.then((resolved) => {
460-
if (!resolved) {
461-
return Promise.reject()
462-
}
463-
return this.load(resolved)
464-
})
465-
.then((mod) => {
466-
// set this to keep the module even if `treeshake.moduleSideEffects=false` is set
467-
mod.moduleSideEffects = true
468-
}),
460+
if (isIgnored) {
461+
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
462+
} else {
463+
const url = src && src.value
464+
const isPublicFile = !!(url && checkPublicFile(url, config))
465+
if (isPublicFile) {
466+
// referencing public dir url, prefix with base
467+
overwriteAttrValue(
468+
s,
469+
sourceCodeLocation!,
470+
partialEncodeURIPath(toOutputPublicFilePath(url)),
469471
)
470-
// <script type="module" src="..."/>
471-
// add it as an import
472-
js += `\nimport ${JSON.stringify(url)}`
473-
shouldRemove = true
472+
}
473+
474+
if (isModule) {
475+
inlineModuleIndex++
476+
if (url && !isExcludedUrl(url) && !isPublicFile) {
477+
setModuleSideEffectPromises.push(
478+
this.resolve(url, id)
479+
.then((resolved) => {
480+
if (!resolved) {
481+
return Promise.reject()
482+
}
483+
return this.load(resolved)
484+
})
485+
.then((mod) => {
486+
// set this to keep the module even if `treeshake.moduleSideEffects=false` is set
487+
mod.moduleSideEffects = true
488+
}),
489+
)
490+
// <script type="module" src="..."/>
491+
// add it as an import
492+
js += `\nimport ${JSON.stringify(url)}`
493+
shouldRemove = true
494+
} else if (node.childNodes.length) {
495+
const scriptNode =
496+
node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
497+
const contents = scriptNode.value
498+
// <script type="module">...</script>
499+
const filePath = id.replace(normalizePath(config.root), '')
500+
addToHTMLProxyCache(config, filePath, inlineModuleIndex, {
501+
code: contents,
502+
})
503+
js += `\nimport "${id}?html-proxy&index=${inlineModuleIndex}.js"`
504+
shouldRemove = true
505+
}
506+
507+
everyScriptIsAsync &&= isAsync
508+
someScriptsAreAsync ||= isAsync
509+
someScriptsAreDefer ||= !isAsync
510+
} else if (url && !isPublicFile) {
511+
if (!isExcludedUrl(url)) {
512+
config.logger.warn(
513+
`<script src="${url}"> in "${publicPath}" can't be bundled without type="module" attribute`,
514+
)
515+
}
474516
} else if (node.childNodes.length) {
475517
const scriptNode =
476518
node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
477-
const contents = scriptNode.value
478-
// <script type="module">...</script>
479-
const filePath = id.replace(normalizePath(config.root), '')
480-
addToHTMLProxyCache(config, filePath, inlineModuleIndex, {
481-
code: contents,
482-
})
483-
js += `\nimport "${id}?html-proxy&index=${inlineModuleIndex}.js"`
484-
shouldRemove = true
485-
}
486-
487-
everyScriptIsAsync &&= isAsync
488-
someScriptsAreAsync ||= isAsync
489-
someScriptsAreDefer ||= !isAsync
490-
} else if (url && !isPublicFile) {
491-
if (!isExcludedUrl(url)) {
492-
config.logger.warn(
493-
`<script src="${url}"> in "${publicPath}" can't be bundled without type="module" attribute`,
519+
scriptUrls.push(
520+
...extractImportExpressionFromClassicScript(scriptNode),
494521
)
495522
}
496-
} else if (node.childNodes.length) {
497-
const scriptNode =
498-
node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
499-
scriptUrls.push(
500-
...extractImportExpressionFromClassicScript(scriptNode),
501-
)
502523
}
503524
}
504525

505526
// For asset references in index.html, also generate an import
506527
// statement for each - this will be handled by the asset plugin
507528
const assetAttrs = assetAttrsConfig[node.nodeName]
508529
if (assetAttrs) {
509-
for (const p of node.attrs) {
510-
const attrKey = getAttrKey(p)
511-
if (p.value && assetAttrs.includes(attrKey)) {
512-
if (attrKey === 'srcset') {
513-
assetUrlsPromises.push(
514-
(async () => {
515-
const processedEncodedUrl = await processSrcSet(
516-
p.value,
517-
async ({ url }) => {
518-
const decodedUrl = decodeURI(url)
519-
if (!isExcludedUrl(decodedUrl)) {
520-
const result = await processAssetUrl(url)
521-
return result !== decodedUrl
522-
? encodeURIPath(result)
523-
: url
524-
}
525-
return url
526-
},
527-
)
528-
if (processedEncodedUrl !== p.value) {
529-
overwriteAttrValue(
530-
s,
531-
getAttrSourceCodeLocation(node, attrKey),
532-
processedEncodedUrl,
530+
const nodeAttrs: Record<string, string> = {}
531+
for (const attr of node.attrs) {
532+
nodeAttrs[getAttrKey(attr)] = attr.value
533+
}
534+
const shouldIgnore =
535+
node.nodeName === 'link' && 'vite-ignore' in nodeAttrs
536+
if (shouldIgnore) {
537+
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
538+
} else {
539+
for (const attrKey in nodeAttrs) {
540+
const attrValue = nodeAttrs[attrKey]
541+
if (attrValue && assetAttrs.includes(attrKey)) {
542+
if (attrKey === 'srcset') {
543+
assetUrlsPromises.push(
544+
(async () => {
545+
const processedEncodedUrl = await processSrcSet(
546+
attrValue,
547+
async ({ url }) => {
548+
const decodedUrl = decodeURI(url)
549+
if (!isExcludedUrl(decodedUrl)) {
550+
const result = await processAssetUrl(url)
551+
return result !== decodedUrl
552+
? encodeURIPath(result)
553+
: url
554+
}
555+
return url
556+
},
533557
)
534-
}
535-
})(),
536-
)
537-
} else {
538-
const url = decodeURI(p.value)
539-
if (checkPublicFile(url, config)) {
540-
overwriteAttrValue(
541-
s,
542-
getAttrSourceCodeLocation(node, attrKey),
543-
partialEncodeURIPath(toOutputPublicFilePath(url)),
558+
if (processedEncodedUrl !== attrValue) {
559+
overwriteAttrValue(
560+
s,
561+
getAttrSourceCodeLocation(node, attrKey),
562+
processedEncodedUrl,
563+
)
564+
}
565+
})(),
544566
)
545-
} else if (!isExcludedUrl(url)) {
546-
if (
547-
node.nodeName === 'link' &&
548-
isCSSRequest(url) &&
549-
// should not be converted if following attributes are present (#6748)
550-
!node.attrs.some(
551-
(p) =>
552-
p.prefix === undefined &&
553-
(p.name === 'media' || p.name === 'disabled'),
567+
} else {
568+
const url = decodeURI(attrValue)
569+
if (checkPublicFile(url, config)) {
570+
overwriteAttrValue(
571+
s,
572+
getAttrSourceCodeLocation(node, attrKey),
573+
partialEncodeURIPath(toOutputPublicFilePath(url)),
554574
)
555-
) {
556-
// CSS references, convert to import
557-
const importExpression = `\nimport ${JSON.stringify(url)}`
558-
styleUrls.push({
559-
url,
560-
start: nodeStartWithLeadingWhitespace(node),
561-
end: node.sourceCodeLocation!.endOffset,
562-
})
563-
js += importExpression
564-
} else {
565-
// If the node is a link, check if it can be inlined. If not, set `shouldInline`
566-
// to `false` to force no inline. If `undefined`, it leaves to the default heuristics.
567-
const isNoInlineLink =
575+
} else if (!isExcludedUrl(url)) {
576+
if (
568577
node.nodeName === 'link' &&
569-
node.attrs.some(
570-
(p) =>
571-
p.name === 'rel' &&
572-
parseRelAttr(p.value).some((v) =>
573-
noInlineLinkRels.has(v),
574-
),
575-
)
576-
const shouldInline = isNoInlineLink ? false : undefined
577-
assetUrlsPromises.push(
578-
(async () => {
579-
const processedUrl = await processAssetUrl(
580-
url,
581-
shouldInline,
578+
isCSSRequest(url) &&
579+
// should not be converted if following attributes are present (#6748)
580+
!('media' in nodeAttrs || 'disabled' in nodeAttrs)
581+
) {
582+
// CSS references, convert to import
583+
const importExpression = `\nimport ${JSON.stringify(url)}`
584+
styleUrls.push({
585+
url,
586+
start: nodeStartWithLeadingWhitespace(node),
587+
end: node.sourceCodeLocation!.endOffset,
588+
})
589+
js += importExpression
590+
} else {
591+
// If the node is a link, check if it can be inlined. If not, set `shouldInline`
592+
// to `false` to force no inline. If `undefined`, it leaves to the default heuristics.
593+
const isNoInlineLink =
594+
node.nodeName === 'link' &&
595+
nodeAttrs.rel &&
596+
parseRelAttr(nodeAttrs.rel).some((v) =>
597+
noInlineLinkRels.has(v),
582598
)
583-
if (processedUrl !== url) {
584-
overwriteAttrValue(
585-
s,
586-
getAttrSourceCodeLocation(node, attrKey),
587-
partialEncodeURIPath(processedUrl),
599+
const shouldInline = isNoInlineLink ? false : undefined
600+
assetUrlsPromises.push(
601+
(async () => {
602+
const processedUrl = await processAssetUrl(
603+
url,
604+
shouldInline,
588605
)
589-
}
590-
})(),
591-
)
606+
if (processedUrl !== url) {
607+
overwriteAttrValue(
608+
s,
609+
getAttrSourceCodeLocation(node, attrKey),
610+
partialEncodeURIPath(processedUrl),
611+
)
612+
}
613+
})(),
614+
)
615+
}
592616
}
593617
}
594618
}

‎packages/vite/src/node/server/middlewares/indexHtml.ts

+32-20
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
overwriteAttrValue,
2222
postImportMapHook,
2323
preImportMapHook,
24+
removeViteIgnoreAttr,
2425
resolveHtmlTransforms,
2526
traverseHtml,
2627
} from '../../plugins/html'
@@ -117,8 +118,6 @@ function isBareRelative(url: string) {
117118
return wordCharRE.test(url[0]) && !url.includes(':')
118119
}
119120

120-
const isSrcSet = (attr: Token.Attribute) =>
121-
attr.name === 'srcset' && attr.prefix === undefined
122121
const processNodeUrl = (
123122
url: string,
124123
useSrcSetReplacer: boolean,
@@ -270,12 +269,15 @@ const devHtmlHook: IndexHtmlTransformHook = async (
270269

271270
// script tags
272271
if (node.nodeName === 'script') {
273-
const { src, sourceCodeLocation, isModule } = getScriptInfo(node)
272+
const { src, sourceCodeLocation, isModule, isIgnored } =
273+
getScriptInfo(node)
274274

275-
if (src) {
275+
if (isIgnored) {
276+
removeViteIgnoreAttr(s, sourceCodeLocation!)
277+
} else if (src) {
276278
const processedUrl = processNodeUrl(
277279
src.value,
278-
isSrcSet(src),
280+
getAttrKey(src) === 'srcset',
279281
config,
280282
htmlPath,
281283
originalUrl,
@@ -332,22 +334,32 @@ const devHtmlHook: IndexHtmlTransformHook = async (
332334
// elements with [href/src] attrs
333335
const assetAttrs = assetAttrsConfig[node.nodeName]
334336
if (assetAttrs) {
335-
for (const p of node.attrs) {
336-
const attrKey = getAttrKey(p)
337-
if (p.value && assetAttrs.includes(attrKey)) {
338-
const processedUrl = processNodeUrl(
339-
p.value,
340-
isSrcSet(p),
341-
config,
342-
htmlPath,
343-
originalUrl,
344-
)
345-
if (processedUrl !== p.value) {
346-
overwriteAttrValue(
347-
s,
348-
node.sourceCodeLocation!.attrs![attrKey],
349-
processedUrl,
337+
const nodeAttrs: Record<string, string> = {}
338+
for (const attr of node.attrs) {
339+
nodeAttrs[getAttrKey(attr)] = attr.value
340+
}
341+
const shouldIgnore =
342+
node.nodeName === 'link' && 'vite-ignore' in nodeAttrs
343+
if (shouldIgnore) {
344+
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
345+
} else {
346+
for (const attrKey in nodeAttrs) {
347+
const attrValue = nodeAttrs[attrKey]
348+
if (attrValue && assetAttrs.includes(attrKey)) {
349+
const processedUrl = processNodeUrl(
350+
attrValue,
351+
attrKey === 'srcset',
352+
config,
353+
htmlPath,
354+
originalUrl,
350355
)
356+
if (processedUrl !== attrValue) {
357+
overwriteAttrValue(
358+
s,
359+
node.sourceCodeLocation!.attrs![attrKey],
360+
processedUrl,
361+
)
362+
}
351363
}
352364
}
353365
}

‎playground/html/__tests__/html.spec.ts

+22
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isBuild,
88
isServe,
99
page,
10+
serverLogs,
1011
untilBrowserLogAfter,
1112
viteServer,
1213
viteTestUrl,
@@ -101,6 +102,27 @@ describe('main', () => {
101102
expect(html).toMatch(`<!-- comment one -->`)
102103
expect(html).toMatch(`<!-- comment two -->`)
103104
})
105+
106+
test('external paths works with vite-ignore attribute', async () => {
107+
expect(await page.textContent('.external-path')).toBe('works')
108+
expect(await page.getAttribute('.external-path', 'vite-ignore')).toBe(null)
109+
expect(await getColor('.external-path')).toBe('red')
110+
if (isServe) {
111+
expect(serverLogs).not.toEqual(
112+
expect.arrayContaining([
113+
expect.stringMatching('Failed to load url /external-path.js'),
114+
]),
115+
)
116+
} else {
117+
expect(serverLogs).not.toEqual(
118+
expect.arrayContaining([
119+
expect.stringMatching(
120+
'can\'t be bundled without type="module" attribute',
121+
),
122+
]),
123+
)
124+
}
125+
})
104126
})
105127

106128
describe('nested', () => {

‎playground/html/index.html

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,9 @@ <h1>Hello</h1>
77
<script type="module" src="/main.js"></script>
88
<link rel="icon" href="{{cdn_host}}/cdn/images/favicon.ico" />
99
<link rel="stylesheet" href="{{cdn_host}}/css.css" type="text/css" />
10-
<script src="{{cdn_host}}/js.js"></script>
10+
<script src="{{cdn_host}}/js.js" vite-ignore></script>
1111
<p>index.html (fallback)</p>
12+
13+
<div>External path: <span class="external-path"></span></div>
14+
<script type="module" src="/external-path.js" vite-ignore></script>
15+
<link rel="stylesheet" href="/external-path.css" vite-ignore />

‎playground/html/vite.config.js

+25
Original file line numberDiff line numberDiff line change
@@ -231,5 +231,30 @@ ${
231231
},
232232
},
233233
},
234+
serveExternalPathPlugin(),
234235
],
235236
})
237+
238+
/** @returns {import('vite').Plugin} */
239+
function serveExternalPathPlugin() {
240+
const handler = (req, res, next) => {
241+
if (req.url === '/external-path.js') {
242+
res.setHeader('Content-Type', 'application/javascript')
243+
res.end('document.querySelector(".external-path").textContent = "works"')
244+
} else if (req.url === '/external-path.css') {
245+
res.setHeader('Content-Type', 'text/css')
246+
res.end('.external-path{color:red}')
247+
} else {
248+
next()
249+
}
250+
}
251+
return {
252+
name: 'serve-external-path',
253+
configureServer(server) {
254+
server.middlewares.use(handler)
255+
},
256+
configurePreviewServer(server) {
257+
server.middlewares.use(handler)
258+
},
259+
}
260+
}

0 commit comments

Comments
 (0)
Please sign in to comment.