Skip to content

Commit

Permalink
feat(html): support vite-ignore attribute to opt-out of processing (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
bluwy authored Oct 30, 2024
1 parent 1507068 commit d951310
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 153 deletions.
29 changes: 29 additions & 0 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
288 changes: 156 additions & 132 deletions packages/vite/src/node/plugins/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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*(.)/
Expand Down Expand Up @@ -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}
*/
Expand Down Expand Up @@ -437,158 +454,165 @@ 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),
)
}
}

// 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)) {
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
},
)
if (processedEncodedUrl !== p.value) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
processedEncodedUrl,
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
},
)
}
})(),
)
} else {
const url = decodeURI(p.value)
if (checkPublicFile(url, config)) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(toOutputPublicFilePath(url)),
if (processedEncodedUrl !== attrValue) {
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)
!node.attrs.some(
(p) =>
p.prefix === undefined &&
(p.name === 'media' || p.name === 'disabled'),
} else {
const url = decodeURI(attrValue)
if (checkPublicFile(url, config)) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(toOutputPublicFilePath(url)),
)
) {
// 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 =
} else if (!isExcludedUrl(url)) {
if (
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,
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),
)
if (processedUrl !== url) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(processedUrl),
const shouldInline = isNoInlineLink ? false : undefined
assetUrlsPromises.push(
(async () => {
const processedUrl = await processAssetUrl(
url,
shouldInline,
)
}
})(),
)
if (processedUrl !== url) {
overwriteAttrValue(
s,
getAttrSourceCodeLocation(node, attrKey),
partialEncodeURIPath(processedUrl),
)
}
})(),
)
}
}
}
}
Expand Down
Loading

0 comments on commit d951310

Please sign in to comment.