import * as path from 'node:path'
import { toCss } from '../../tailwindcss/src/ast'
import { parse } from '../../tailwindcss/src/css-parser'
import { walk } from '../../tailwindcss/src/walk'
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) {
if (url[0] === '/') return url
let absoluteUrl = path.posix.join(normalizePath(base), url)
let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl)
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)
}
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 }) => {
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)
if (wrap === '' && newUrl !== encodeURI(newUrl)) {
wrap = '"'
}
if (wrap === "'" && newUrl.includes("'")) {
wrap = '"'
}
if (wrap === '"' && newUrl.includes('"')) {
newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"')
}
return `${funcName}(${wrap}${newUrl}${wrap})`
}
function skipUrlReplacer(rawUrl: string, aliases?: string[]) {
return (
isExternalUrl(rawUrl) ||
isDataUrl(rawUrl) ||
!rawUrl[0].match(/[\.a-zA-Z0-9_]/) ||
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
}