Skip to content

Commit

Permalink
Rewrite urls in CSS files in Vite
Browse files Browse the repository at this point in the history
  • Loading branch information
thecrypticace committed Nov 5, 2024
1 parent 4c627c4 commit 2c978a3
Show file tree
Hide file tree
Showing 5 changed files with 354 additions and 3 deletions.
23 changes: 21 additions & 2 deletions packages/@tailwindcss-node/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,37 @@ import {
compile as _compile,
} from 'tailwindcss'
import { getModuleDependencies } from './get-module-dependencies'
import { rewriteUrls } from './urls'

export async function compile(
css: string,
{ base, onDependency }: { base: string; onDependency: (path: string) => void },
{
base,
onDependency,
shouldRewriteUrls,
}: {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean
},
) {
let compiler = await _compile(css, {
base,
async loadModule(id, base) {
return loadModule(id, base, onDependency)
},
async loadStylesheet(id, base) {
return loadStylesheet(id, base, onDependency)
let sheet = await loadStylesheet(id, base, onDependency)

if (shouldRewriteUrls) {
sheet.content = await rewriteUrls({
css: sheet.content,
root: base,
base: sheet.base,
})
}

return sheet
},
})

Expand Down
127 changes: 127 additions & 0 deletions packages/@tailwindcss-node/src/urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { expect, test } from 'vitest'
import { rewriteUrls } from './urls'

const css = String.raw

test('URLs can be rewritten', async () => {
let root = '/root'

let result = await rewriteUrls({
root,
base: '/root/foo/bar',
// prettier-ignore
css: css`
.foo {
/* Relative URLs: replaced */
background: url(./image.jpg);
background: url(../image.jpg);
background: url('./image.jpg');
background: url("./image.jpg");
/* External URL: ignored */
background: url(http://example.com/image.jpg);
background: url('http://example.com/image.jpg');
background: url("http://example.com/image.jpg");
/* Data URI: ignored */
/* background: url(data:image/png;base64,abc==); */
background: url('data:image/png;base64,abc==');
background: url("data:image/png;base64,abc==");
/* Function calls: ignored */
background: url(var(--foo));
background: url(var(--foo, './image.jpg'));
background: url(var(--foo, "./image.jpg"));
/* Fragments: ignored */
background: url(#dont-touch-this);
/* Image Sets - Raw URL: replaced */
background: image-set(
image1.jpg 1x,
image2.jpg 2x
);
background: image-set(
'image1.jpg' 1x,
'image2.jpg' 2x
);
background: image-set(
"image1.jpg" 1x,
"image2.jpg" 2x
);
/* Image Sets - Relative URLs: replaced */
background: image-set(
url('image1.jpg') 1x,
url('image2.jpg') 2x
);
background: image-set(
url("image1.jpg") 1x,
url("image2.jpg") 2x
);
background: image-set(
url('image1.avif') type('image/avif'),
url('image2.jpg') type('image/jpeg')
);
background: image-set(
url("image1.avif") type('image/avif'),
url("image2.jpg") type('image/jpeg')
);
/* Image Sets - Function calls: ignored */
background: image-set(
linear-gradient(blue, white) 1x,
linear-gradient(blue, green) 2x
);
/* Image Sets - Mixed: replaced */
background: image-set(
linear-gradient(blue, white) 1x,
url("image2.jpg") 2x
);
}
/* Fonts - Multiple URLs: replaced */
@font-face {
font-family: "Newman";
src:
local("Newman"),
url("newman-COLRv1.otf") format("opentype") tech(color-COLRv1),
url("newman-outline.otf") format("opentype"),
url("newman-outline.woff") format("woff");
}
`,
})

expect(result).toMatchInlineSnapshot(`
".foo {
background: url(./foo/bar/image.jpg);
background: url(./foo/image.jpg);
background: url('./foo/bar/image.jpg');
background: url("./foo/bar/image.jpg");
background: url(http://example.com/image.jpg);
background: url('http://example.com/image.jpg');
background: url("http://example.com/image.jpg");
background: url('data:image/png;base64,abc==');
background: url("data:image/png;base64,abc==");
background: url(var(--foo));
background: url(var(--foo, './image.jpg'));
background: url(var(--foo, "./image.jpg"));
background: url(#dont-touch-this);
background: image-set(url(./foo/bar/image1.jpg) 1x, url(./foo/bar/image2.jpg) 2x);
background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x);
background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x);
background: image-set(url('./foo/bar/image1.jpg') 1x, url('./foo/bar/image2.jpg') 2x);
background: image-set(url("./foo/bar/image1.jpg") 1x, url("./foo/bar/image2.jpg") 2x);
background: image-set(url('./foo/bar/image1.avif') type('image/avif'), url('./foo/bar/image2.jpg') type('image/jpeg'));
background: image-set(url("./foo/bar/image1.avif") type('image/avif'), url("./foo/bar/image2.jpg") type('image/jpeg'));
background: image-set(linear-gradient(blue, white) 1x, linear-gradient(blue, green) 2x);
background: image-set(linear-gradient(blue, white) 1x, url("./foo/bar/image2.jpg") 2x);
}
@font-face {
font-family: "Newman";
src: local("Newman"), url("./foo/bar/newman-COLRv1.otf") format("opentype") tech(color-COLRv1), url("./foo/bar/newman-outline.otf") format("opentype"), url("./foo/bar/newman-outline.woff") format("woff");
}
"
`)
})
203 changes: 203 additions & 0 deletions packages/@tailwindcss-node/src/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Inlined version of code from Vite <https://github.com/vitejs/vite>
// Copyright (c) 2019-present, VoidZero Inc. and Vite contributors
// Released under the MIT License.
//
// Minor modifications have been made to work with the Tailwind CSS codebase

import * as path from 'node:path'
import { toCss, walk } from '../../tailwindcss/src/ast'
import { parse } from '../../tailwindcss/src/css-parser'
import { normalizePath } from './normalize-path'

const cssUrlRE =
/(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/
const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/
const cssNotProcessedRE = /(?:gradient|element|cross-fade|image)\(/

const dataUrlRE = /^\s*data:/i
const externalRE = /^([a-z]+:)?\/\//
const functionCallRE = /^[A-Z_][.\w-]*\(/i

const imageCandidateRE =
/(?:^|\s)(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>\w[^,]+))?(?:,|$)/g
const nonEscapedDoubleQuoteRE = /(?<!\\)"/g
const escapedSpaceCharactersRE = /(?: |\\t|\\n|\\f|\\r)+/g

const isDataUrl = (url: string): boolean => dataUrlRE.test(url)
const isExternalUrl = (url: string): boolean => externalRE.test(url)

type CssUrlReplacer = (url: string, importer?: string) => string | Promise<string>

interface ImageCandidate {
url: string
descriptor: string
}

export async function rewriteUrls({
css,
base,
root,
}: {
css: string
base: string
root: string
}) {
if (!css.includes('url(') && !css.includes('image-set(')) {
return css
}

let ast = parse(css)

let promises: Promise<void>[] = []

function replacerForDeclaration(url: string) {
let absoluteUrl = path.posix.join(normalizePath(base), url)
let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl)

// If the path points to a file in the same directory, `path.relative` will
// remove the leading `./` and we need to add it back in order to still
// consider the path relative
if (!relativeUrl.startsWith('.')) {
relativeUrl = './' + relativeUrl
}

return relativeUrl
}

walk(ast, (node) => {
if (node.kind !== 'declaration') return
if (!node.value) return

let isCssUrl = cssUrlRE.test(node.value)
let isCssImageSet = cssImageSetRE.test(node.value)

if (isCssUrl || isCssImageSet) {
let rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls

promises.push(
rewriterToUse(node.value, replacerForDeclaration).then((url) => {
node.value = url
}),
)
}
})

if (promises.length) {
await Promise.all(promises)
}

return toCss(ast, {
printUtilitiesNode: true,
})
}

function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise<string> {
return asyncReplace(css, cssUrlRE, async (match) => {
const [matched, rawUrl] = match
return await doUrlReplace(rawUrl.trim(), matched, replacer)
})
}

async function rewriteCssImageSet(css: string, replacer: CssUrlReplacer): Promise<string> {
return await asyncReplace(css, cssImageSetRE, async (match) => {
const [, rawUrl] = match
const url = await processSrcSet(rawUrl, async ({ url }) => {
// the url maybe url(...)
if (cssUrlRE.test(url)) {
return await rewriteCssUrls(url, replacer)
}
if (!cssNotProcessedRE.test(url)) {
return await doUrlReplace(url, url, replacer)
}
return url
})
return url
})
}

async function doUrlReplace(
rawUrl: string,
matched: string,
replacer: CssUrlReplacer,
funcName: string = 'url',
) {
let wrap = ''
const first = rawUrl[0]
if (first === `"` || first === `'`) {
wrap = first
rawUrl = rawUrl.slice(1, -1)
}

if (skipUrlReplacer(rawUrl)) {
return matched
}

let newUrl = await replacer(rawUrl)
// The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement
if (wrap === '' && newUrl !== encodeURI(newUrl)) {
wrap = '"'
}
// If wrapping in single quotes and newUrl also contains single quotes, switch to double quotes.
// Give preference to double quotes since SVG inlining converts double quotes to single quotes.
if (wrap === "'" && newUrl.includes("'")) {
wrap = '"'
}
// Escape double quotes if they exist (they also tend to be rarer than single quotes)
if (wrap === '"' && newUrl.includes('"')) {
newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"')
}
return `${funcName}(${wrap}${newUrl}${wrap})`
}

function skipUrlReplacer(rawUrl: string) {
return (
isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl[0] === '#' || functionCallRE.test(rawUrl)
)
}

function processSrcSet(
srcs: string,
replacer: (arg: ImageCandidate) => Promise<string>,
): Promise<string> {
return Promise.all(
parseSrcset(srcs).map(async ({ url, descriptor }) => ({
url: await replacer({ url, descriptor }),
descriptor,
})),
).then(joinSrcset)
}

function parseSrcset(string: string): ImageCandidate[] {
const matches = string
.trim()
.replace(escapedSpaceCharactersRE, ' ')
.replace(/\r?\n/, '')
.replace(/,\s+/, ', ')
.replaceAll(/\s+/g, ' ')
.matchAll(imageCandidateRE)
return Array.from(matches, ({ groups }) => ({
url: groups?.url?.trim() ?? '',
descriptor: groups?.descriptor?.trim() ?? '',
})).filter(({ url }) => !!url)
}

function joinSrcset(ret: ImageCandidate[]) {
return ret.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : '')).join(', ')
}

async function asyncReplace(
input: string,
re: RegExp,
replacer: (match: RegExpExecArray) => string | Promise<string>,
): Promise<string> {
let match: RegExpExecArray | null
let remaining = input
let rewritten = ''
while ((match = re.exec(remaining))) {
rewritten += remaining.slice(0, match.index)
rewritten += await replacer(match)
remaining = remaining.slice(match.index + match[0].length)
}
rewritten += remaining
return rewritten
}
1 change: 1 addition & 0 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ class Root {
env.DEBUG && console.time('[@tailwindcss/vite] Setup compiler')
this.compiler = await compile(content, {
base: inputBase,
shouldRewriteUrls: true,
onDependency: (path) => {
addWatchFile(path)
this.dependencies.add(path)
Expand Down
3 changes: 2 additions & 1 deletion packages/tailwindcss/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export function walkDepth(
}
}

export function toCss(ast: AstNode[]) {
export function toCss(ast: AstNode[], { printUtilitiesNode = false } = {}) {
let atRoots: string = ''
let seenAtProperties = new Set<string>()
let propertyFallbacksRoot: Declaration[] = []
Expand All @@ -224,6 +224,7 @@ export function toCss(ast: AstNode[]) {
// AtRule
else if (node.kind === 'at-rule') {
if (
!printUtilitiesNode &&
node.name === '@tailwind' &&
(node.params === 'utilities' || node.params.startsWith('utilities'))
) {
Expand Down

0 comments on commit 2c978a3

Please sign in to comment.