|
| 1 | +// Inlined version of code from Vite <https://github.com/vitejs/vite> |
| 2 | +// Copyright (c) 2019-present, VoidZero Inc. and Vite contributors |
| 3 | +// Released under the MIT License. |
| 4 | +// |
| 5 | +// Minor modifications have been made to work with the Tailwind CSS codebase |
| 6 | + |
| 7 | +import * as path from 'node:path' |
| 8 | +import { toCss, walk } from '../../tailwindcss/src/ast' |
| 9 | +import { parse } from '../../tailwindcss/src/css-parser' |
| 10 | +import { normalizePath } from './normalize-path' |
| 11 | + |
| 12 | +const cssUrlRE = |
| 13 | + /(?<!@import\s+)(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']+'|"[^"]+")\s*|[^'")]+)\)/ |
| 14 | +const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/ |
| 15 | +const cssNotProcessedRE = /(?:gradient|element|cross-fade|image)\(/ |
| 16 | + |
| 17 | +const dataUrlRE = /^\s*data:/i |
| 18 | +const externalRE = /^([a-z]+:)?\/\// |
| 19 | +const functionCallRE = /^[A-Z_][.\w-]*\(/i |
| 20 | + |
| 21 | +const imageCandidateRE = |
| 22 | + /(?:^|\s)(?<url>[\w-]+\([^)]*\)|"[^"]*"|'[^']*'|[^,]\S*[^,])\s*(?:\s(?<descriptor>\w[^,]+))?(?:,|$)/g |
| 23 | +const nonEscapedDoubleQuoteRE = /(?<!\\)"/g |
| 24 | +const escapedSpaceCharactersRE = /(?: |\\t|\\n|\\f|\\r)+/g |
| 25 | + |
| 26 | +const isDataUrl = (url: string): boolean => dataUrlRE.test(url) |
| 27 | +const isExternalUrl = (url: string): boolean => externalRE.test(url) |
| 28 | + |
| 29 | +type CssUrlReplacer = (url: string, importer?: string) => string | Promise<string> |
| 30 | + |
| 31 | +interface ImageCandidate { |
| 32 | + url: string |
| 33 | + descriptor: string |
| 34 | +} |
| 35 | + |
| 36 | +export async function rewriteUrls({ |
| 37 | + css, |
| 38 | + base, |
| 39 | + root, |
| 40 | +}: { |
| 41 | + css: string |
| 42 | + base: string |
| 43 | + root: string |
| 44 | +}) { |
| 45 | + if (!css.includes('url(') && !css.includes('image-set(')) { |
| 46 | + return css |
| 47 | + } |
| 48 | + |
| 49 | + let ast = parse(css) |
| 50 | + |
| 51 | + let promises: Promise<void>[] = [] |
| 52 | + |
| 53 | + function replacerForDeclaration(url: string) { |
| 54 | + let absoluteUrl = path.posix.join(normalizePath(base), url) |
| 55 | + let relativeUrl = path.posix.relative(normalizePath(root), absoluteUrl) |
| 56 | + |
| 57 | + // If the path points to a file in the same directory, `path.relative` will |
| 58 | + // remove the leading `./` and we need to add it back in order to still |
| 59 | + // consider the path relative |
| 60 | + if (!relativeUrl.startsWith('.')) { |
| 61 | + relativeUrl = './' + relativeUrl |
| 62 | + } |
| 63 | + |
| 64 | + return relativeUrl |
| 65 | + } |
| 66 | + |
| 67 | + walk(ast, (node) => { |
| 68 | + if (node.kind !== 'declaration') return |
| 69 | + if (!node.value) return |
| 70 | + |
| 71 | + let isCssUrl = cssUrlRE.test(node.value) |
| 72 | + let isCssImageSet = cssImageSetRE.test(node.value) |
| 73 | + |
| 74 | + if (isCssUrl || isCssImageSet) { |
| 75 | + let rewriterToUse = isCssImageSet ? rewriteCssImageSet : rewriteCssUrls |
| 76 | + |
| 77 | + promises.push( |
| 78 | + rewriterToUse(node.value, replacerForDeclaration).then((url) => { |
| 79 | + node.value = url |
| 80 | + }), |
| 81 | + ) |
| 82 | + } |
| 83 | + }) |
| 84 | + |
| 85 | + if (promises.length) { |
| 86 | + await Promise.all(promises) |
| 87 | + } |
| 88 | + |
| 89 | + return toCss(ast, { |
| 90 | + printUtilitiesNode: true, |
| 91 | + }) |
| 92 | +} |
| 93 | + |
| 94 | +function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise<string> { |
| 95 | + return asyncReplace(css, cssUrlRE, async (match) => { |
| 96 | + const [matched, rawUrl] = match |
| 97 | + return await doUrlReplace(rawUrl.trim(), matched, replacer) |
| 98 | + }) |
| 99 | +} |
| 100 | + |
| 101 | +async function rewriteCssImageSet(css: string, replacer: CssUrlReplacer): Promise<string> { |
| 102 | + return await asyncReplace(css, cssImageSetRE, async (match) => { |
| 103 | + const [, rawUrl] = match |
| 104 | + const url = await processSrcSet(rawUrl, async ({ url }) => { |
| 105 | + // the url maybe url(...) |
| 106 | + if (cssUrlRE.test(url)) { |
| 107 | + return await rewriteCssUrls(url, replacer) |
| 108 | + } |
| 109 | + if (!cssNotProcessedRE.test(url)) { |
| 110 | + return await doUrlReplace(url, url, replacer) |
| 111 | + } |
| 112 | + return url |
| 113 | + }) |
| 114 | + return url |
| 115 | + }) |
| 116 | +} |
| 117 | + |
| 118 | +async function doUrlReplace( |
| 119 | + rawUrl: string, |
| 120 | + matched: string, |
| 121 | + replacer: CssUrlReplacer, |
| 122 | + funcName: string = 'url', |
| 123 | +) { |
| 124 | + let wrap = '' |
| 125 | + const first = rawUrl[0] |
| 126 | + if (first === `"` || first === `'`) { |
| 127 | + wrap = first |
| 128 | + rawUrl = rawUrl.slice(1, -1) |
| 129 | + } |
| 130 | + |
| 131 | + if (skipUrlReplacer(rawUrl)) { |
| 132 | + return matched |
| 133 | + } |
| 134 | + |
| 135 | + let newUrl = await replacer(rawUrl) |
| 136 | + // The new url might need wrapping even if the original did not have it, e.g. if a space was added during replacement |
| 137 | + if (wrap === '' && newUrl !== encodeURI(newUrl)) { |
| 138 | + wrap = '"' |
| 139 | + } |
| 140 | + // If wrapping in single quotes and newUrl also contains single quotes, switch to double quotes. |
| 141 | + // Give preference to double quotes since SVG inlining converts double quotes to single quotes. |
| 142 | + if (wrap === "'" && newUrl.includes("'")) { |
| 143 | + wrap = '"' |
| 144 | + } |
| 145 | + // Escape double quotes if they exist (they also tend to be rarer than single quotes) |
| 146 | + if (wrap === '"' && newUrl.includes('"')) { |
| 147 | + newUrl = newUrl.replace(nonEscapedDoubleQuoteRE, '\\"') |
| 148 | + } |
| 149 | + return `${funcName}(${wrap}${newUrl}${wrap})` |
| 150 | +} |
| 151 | + |
| 152 | +function skipUrlReplacer(rawUrl: string) { |
| 153 | + return ( |
| 154 | + isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl[0] === '#' || functionCallRE.test(rawUrl) |
| 155 | + ) |
| 156 | +} |
| 157 | + |
| 158 | +function processSrcSet( |
| 159 | + srcs: string, |
| 160 | + replacer: (arg: ImageCandidate) => Promise<string>, |
| 161 | +): Promise<string> { |
| 162 | + return Promise.all( |
| 163 | + parseSrcset(srcs).map(async ({ url, descriptor }) => ({ |
| 164 | + url: await replacer({ url, descriptor }), |
| 165 | + descriptor, |
| 166 | + })), |
| 167 | + ).then(joinSrcset) |
| 168 | +} |
| 169 | + |
| 170 | +function parseSrcset(string: string): ImageCandidate[] { |
| 171 | + const matches = string |
| 172 | + .trim() |
| 173 | + .replace(escapedSpaceCharactersRE, ' ') |
| 174 | + .replace(/\r?\n/, '') |
| 175 | + .replace(/,\s+/, ', ') |
| 176 | + .replaceAll(/\s+/g, ' ') |
| 177 | + .matchAll(imageCandidateRE) |
| 178 | + return Array.from(matches, ({ groups }) => ({ |
| 179 | + url: groups?.url?.trim() ?? '', |
| 180 | + descriptor: groups?.descriptor?.trim() ?? '', |
| 181 | + })).filter(({ url }) => !!url) |
| 182 | +} |
| 183 | + |
| 184 | +function joinSrcset(ret: ImageCandidate[]) { |
| 185 | + return ret.map(({ url, descriptor }) => url + (descriptor ? ` ${descriptor}` : '')).join(', ') |
| 186 | +} |
| 187 | + |
| 188 | +async function asyncReplace( |
| 189 | + input: string, |
| 190 | + re: RegExp, |
| 191 | + replacer: (match: RegExpExecArray) => string | Promise<string>, |
| 192 | +): Promise<string> { |
| 193 | + let match: RegExpExecArray | null |
| 194 | + let remaining = input |
| 195 | + let rewritten = '' |
| 196 | + while ((match = re.exec(remaining))) { |
| 197 | + rewritten += remaining.slice(0, match.index) |
| 198 | + rewritten += await replacer(match) |
| 199 | + remaining = remaining.slice(match.index + match[0].length) |
| 200 | + } |
| 201 | + rewritten += remaining |
| 202 | + return rewritten |
| 203 | +} |
0 commit comments