Skip to content

Commit

Permalink
fix(css): .css?url support (vitejs#15259)
Browse files Browse the repository at this point in the history
  • Loading branch information
sapphi-red committed Jan 12, 2024
1 parent 67a50f1 commit ed56d96
Show file tree
Hide file tree
Showing 11 changed files with 232 additions and 54 deletions.
10 changes: 5 additions & 5 deletions packages/vite/src/node/plugins/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import {
injectQuery,
joinUrlSegments,
normalizePath,
rawRE,
removeLeadingSlash,
removeUrlQuery,
urlRE,
withTrailingSlash,
} from '../utils'
import { DEFAULT_ASSETS_INLINE_LIMIT, FS_PREFIX } from '../constants'
Expand All @@ -32,10 +35,7 @@ import type { ModuleGraph } from '../server/moduleGraph'
// referenceId is base64url but replaces - with $
export const assetUrlRE = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g

const rawRE = /(?:\?|&)raw(?:&|$)/
export const urlRE = /(\?|&)url(?:&|$)/
const jsSourceMapRE = /\.[cm]?js\.map$/
const unnededFinalQueryCharRE = /[?&]$/

const assetCache = new WeakMap<ResolvedConfig, Map<string, string>>()

Expand Down Expand Up @@ -191,11 +191,11 @@ export function assetPlugin(config: ResolvedConfig): Plugin {
)}`
}

if (!config.assetsInclude(cleanUrl(id)) && !urlRE.test(id)) {
if (!urlRE.test(id) && !config.assetsInclude(cleanUrl(id))) {
return
}

id = id.replace(urlRE, '$1').replace(unnededFinalQueryCharRE, '')
id = removeUrlQuery(id)
let url = await fileToUrl(id, config, this)

// Inherit HMR timestamp if this asset was invalidated
Expand Down
200 changes: 159 additions & 41 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import type { RawSourceMap } from '@ampproject/remapping'
import { getCodeWithSourcemap, injectSourcesContent } from '../server/sourcemap'
import type { ModuleNode } from '../server/moduleGraph'
import type { ResolveFn, ViteDevServer } from '../'
import { resolveUserExternal, toOutputFilePathInCss } from '../build'
import {
createToImportMetaURLBasedRelativeRuntime,
resolveUserExternal,
toOutputFilePathInCss,
toOutputFilePathInJS,
} from '../build'
import {
CLIENT_PUBLIC_PATH,
CSS_LANGS_RE,
Expand All @@ -42,10 +47,12 @@ import {
asyncReplace,
cleanUrl,
combineSourcemaps,
createSerialPromiseQueue,
emptyCssComments,
generateCodeFrame,
getHash,
getPackageManagerCommand,
injectQuery,
isDataUrl,
isExternalUrl,
isObject,
Expand All @@ -54,10 +61,12 @@ import {
parseRequest,
processSrcSet,
removeDirectQuery,
removeUrlQuery,
requireResolveFromRootWithFallback,
slash,
stripBase,
stripBomTag,
urlRE,
} from '../utils'
import type { Logger } from '../logger'
import { addToHTMLProxyTransformResult } from './html'
Expand Down Expand Up @@ -167,6 +176,7 @@ const inlineRE = /[?&]inline\b/
const inlineCSSRE = /[?&]inline-css\b/
const styleAttrRE = /[?&]style-attr\b/
const functionCallRE = /^[A-Z_][\w-]*\(/i
const transformOnlyRE = /[?&]transform-only\b/
const nonEscapedDoubleQuoteRe = /(?<!\\)(")/g

const cssBundleName = 'style.css'
Expand Down Expand Up @@ -220,10 +230,13 @@ function encodePublicUrlsInCSS(config: ResolvedConfig) {
return config.command === 'build'
}

const cssUrlAssetRE = /__VITE_CSS_URL__([\da-f]+)__/g

/**
* Plugin applied before user plugins
*/
export function cssPlugin(config: ResolvedConfig): Plugin {
const isBuild = config.command === 'build'
let server: ViteDevServer
let moduleCache: Map<string, Record<string, string>>

Expand Down Expand Up @@ -253,6 +266,32 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
removedPureCssFilesCache.set(config, new Map<string, RenderedChunk>())
},

async load(id) {
if (!isCSSRequest(id)) return

if (urlRE.test(id)) {
if (isModuleCSSRequest(id)) {
throw new Error(
`?url is not supported with CSS modules. (tried to import ${JSON.stringify(
id,
)})`,
)
}

// *.css?url
// in dev, it's handled by assets plugin.
if (isBuild) {
id = injectQuery(removeUrlQuery(id), 'transform-only')
return (
`import ${JSON.stringify(id)};` +
`export default "__VITE_CSS_URL__${Buffer.from(id).toString(
'hex',
)}__"`
)
}
}
},

async transform(raw, id, options) {
if (
!isCSSRequest(id) ||
Expand Down Expand Up @@ -374,8 +413,9 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
export function cssPostPlugin(config: ResolvedConfig): Plugin {
// styles initialization in buildStart causes a styling loss in watch
const styles: Map<string, string> = new Map<string, string>()
// list of css emit tasks to guarantee the files are emitted in a deterministic order
let emitTasks: Promise<void>[] = []
// queue to emit css serially to guarantee the files are emitted in a deterministic order
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
const urlEmitQueue = createSerialPromiseQueue<unknown>()
let pureCssChunks: Set<RenderedChunk>

// when there are multiple rollup outputs and extracting CSS, only emit once,
Expand Down Expand Up @@ -414,7 +454,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
pureCssChunks = new Set<RenderedChunk>()
hasEmitted = false
chunkCSSMap = new Map()
emitTasks = []
codeSplitEmitQueue = createSerialPromiseQueue()
},

async transform(css, id, options) {
Expand Down Expand Up @@ -530,10 +570,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
const ids = Object.keys(chunk.modules)
for (const id of ids) {
if (styles.has(id)) {
chunkCSS += styles.get(id)
// a css module contains JS, so it makes this not a pure css chunk
if (cssModuleRE.test(id)) {
isPureCssChunk = false
// ?transform-only is used for ?url and shouldn't be included in normal CSS chunks
if (!transformOnlyRE.test(id)) {
chunkCSS += styles.get(id)
// a css module contains JS, so it makes this not a pure css chunk
if (cssModuleRE.test(id)) {
isPureCssChunk = false
}
}
} else {
// if the module does not have a style, then it's not a pure css chunk.
Expand All @@ -543,10 +586,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
}
}

if (!chunkCSS) {
return null
}

const publicAssetUrlMap = publicAssetUrlCache.get(config)!

// resolve asset URL placeholders to their built file URLs
Expand Down Expand Up @@ -608,6 +647,98 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
)
}

let s: MagicString | undefined
const urlEmitTasks: Array<{
cssAssetName: string
originalFilename: string
content: string
start: number
end: number
}> = []

if (code.includes('__VITE_CSS_URL__')) {
let match: RegExpExecArray | null
cssUrlAssetRE.lastIndex = 0
while ((match = cssUrlAssetRE.exec(code))) {
const [full, idHex] = match
const id = Buffer.from(idHex, 'hex').toString()
const originalFilename = cleanUrl(id)
const cssAssetName = ensureFileExt(
path.basename(originalFilename),
'.css',
)
if (!styles.has(id)) {
throw new Error(
`css content for ${JSON.stringify(id)} was not found`,
)
}

let cssContent = styles.get(id)!

cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName)

urlEmitTasks.push({
cssAssetName,
originalFilename,
content: cssContent,
start: match.index,
end: match.index + full.length,
})
}
}

// should await even if this chunk does not include __VITE_CSS_URL__
// so that code after this line runs in the same order
await urlEmitQueue.run(async () =>
Promise.all(
urlEmitTasks.map(async (info) => {
info.content = await finalizeCss(info.content, true, config)
}),
),
)
if (urlEmitTasks.length > 0) {
const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime(
opts.format,
config.isWorker,
)
s ||= new MagicString(code)

for (const {
cssAssetName,
originalFilename,
content,
start,
end,
} of urlEmitTasks) {
const referenceId = this.emitFile({
name: cssAssetName,
type: 'asset',
source: content,
})
generatedAssets
.get(config)!
.set(referenceId, { originalName: originalFilename })

const replacement = toOutputFilePathInJS(
this.getFileName(referenceId),
'asset',
chunk.fileName,
'js',
config,
toRelativeRuntime,
)
const replacementString =
typeof replacement === 'string'
? JSON.stringify(replacement).slice(1, -1)
: `"+${replacement.runtime}+"`
s.update(start, end, replacementString)
}
}

if (!chunkCSS && !s) {
return null
}

if (config.build.cssCodeSplit) {
if (opts.format === 'es' || opts.format === 'cjs') {
if (isPureCssChunk) {
Expand All @@ -633,22 +764,11 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {

chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssAssetName)

const previousTask = emitTasks[emitTasks.length - 1]
// finalizeCss is async which makes `emitFile` non-deterministic, so
// we use a `.then` to wait for previous tasks before finishing this
const thisTask = finalizeCss(chunkCSS, true, config).then((css) => {
chunkCSS = css
// make sure the previous task is also finished, this works recursively
return previousTask
// wait for previous tasks as well
chunkCSS = await codeSplitEmitQueue.run(async () => {
return finalizeCss(chunkCSS, true, config)
})

// push this task so the next task can wait for this one
emitTasks.push(thisTask)
const emitTasksLength = emitTasks.length

// wait for this and previous tasks to finish
await thisTask

// emit corresponding css file
const referenceId = this.emitFile({
name: cssAssetName,
Expand All @@ -659,11 +779,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
.get(config)!
.set(referenceId, { originalName: originalFilename, isEntry })
chunk.viteMetadata!.importedCss.add(this.getFileName(referenceId))

if (emitTasksLength === emitTasks.length) {
// this is the last task, clear `emitTasks` to free up memory
emitTasks = []
}
} else if (!config.build.ssr) {
// legacy build and inline css

Expand Down Expand Up @@ -697,24 +812,27 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
const insertMark = "'use strict';"
injectionPoint = code.indexOf(insertMark) + insertMark.length
}
const s = new MagicString(code)
s ||= new MagicString(code)
s.appendRight(injectionPoint, injectCode)
if (config.build.sourcemap) {
// resolve public URL from CSS paths, we need to use absolute paths
return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' }),
}
} else {
return { code: s.toString() }
}
}
} else {
// resolve public URL from CSS paths, we need to use absolute paths
chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssBundleName)
// finalizeCss is called for the aggregated chunk in generateBundle

chunkCSSMap.set(chunk.fileName, chunkCSS)
}

if (s) {
if (config.build.sourcemap) {
return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' }),
}
} else {
return { code: s.toString() }
}
}
return null
},

Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/plugins/importAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
timeFrom,
transformStableResult,
unwrapId,
urlRE,
withTrailingSlash,
wrapId,
} from '../utils'
Expand All @@ -58,7 +59,6 @@ import type { ResolvedConfig } from '../config'
import type { Plugin } from '../plugin'
import { shouldExternalizeForSSR } from '../ssr/ssrExternal'
import { getDepsOptimizer, optimizedDepNeedsInterop } from '../optimizer'
import { urlRE } from './asset'
import { throwOutdatedRequest } from './optimizedDeps'
import { isCSSRequest, isDirectCSSRequest } from './css'
import { browserExternalId } from './resolve'
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/server/middlewares/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
removeImportQuery,
removeTimestampQuery,
unwrapId,
urlRE,
withTrailingSlash,
} from '../../utils'
import { send } from '../send'
Expand All @@ -38,7 +39,6 @@ import {
} from '../../plugins/optimizedDeps'
import { ERR_CLOSED_SERVER } from '../pluginContainer'
import { getDepsOptimizer } from '../../optimizer'
import { urlRE } from '../../plugins/asset'

const debugCache = createDebugger('vite:cache')

Expand Down

0 comments on commit ed56d96

Please sign in to comment.