All files / src/css-url index.ts

100% Statements 56/56
100% Branches 23/23
100% Functions 6/6
100% Lines 54/54

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 1472x 2x 2x   2x 2x     2x                                       2x         48x 48x 48x 61x 61x   48x 48x     2x 44x         44x   44x 44x 44x   44x           62x 14x           48x   48x         61x 5x     56x   56x 56x 56x   52x 52x     52x 52x 1x 1x     51x 11x 11x           11x 10x 10x           11x     40x 1x         40x 40x     4x 4x       48x 8x   40x 40x                          
import path from "path";
import mime from "mime-types";
import { createFilter } from "@rollup/pluginutils";
import { InputPluginOption, TransformResult } from "rollup";
import MagicString from "magic-string";
import { computeContentHash } from "../crypto";
 
// Regex to find URLs in CSS/SASS
const CSS_URL_REGEX = /url\(['"]?([^'")]+\.[^.)'"]+)['"]?\)/g;
 
interface CssUrlOptions {
  include?: string | string[];
  exclude?: string | string[];
  shouldExtract?: (
    filePath: string,
    fileContent: Uint8Array,
    mimeType: string,
  ) => boolean;
  getExtractNameAndUrl?: (
    url: string,
    filePath: string,
    mimeType: string,
    contentHash: string,
  ) => { name: string; url: string };
  warnSize?: number;
}
 
// helper so we can await inside String.replace
const replaceAsync = async (
  str: string,
  re: RegExp,
  fn: (...args: string[]) => Promise<string>,
): Promise<string> => {
  const parts: string[] = [];
  let last = 0;
  for (const m of str.matchAll(re)) {
    parts.push(str.slice(last, m.index!), await fn(...m));
    last = m.index! + m[0].length;
  }
  parts.push(str.slice(last));
  return parts.join("");
};
 
export function cssUrl(options: CssUrlOptions = {}): InputPluginOption {
  const filter = createFilter(
    options.include || ["**/*.scss", "**/*.sass", "**/*.css"],
    options.exclude,
  );
  const shouldExtract =
    options.shouldExtract || ((_, content) => content.length > 14 * 1024);
  const getExtractNameAndUrl =
    options.getExtractNameAndUrl || ((url) => ({ name: url, url }));
  const warnSize = options.warnSize || 100 * 1024;
  const emittedFiles = new Set();
 
  return {
    name: "css-url",
    transform: async function (
      code: string,
      id: string,
    ): Promise<TransformResult> {
      if (!filter(id)) {
        return null;
      }
 
      // Collect resolved asset file paths so the source manifest plugin
      // (and any other downstream consumer) can discover them via
      // this.getModuleInfo(id).meta[‘css-url’].assets.
      const resolvedAssets: string[] = [];
 
      const resultCode = await replaceAsync(
        code,
        CSS_URL_REGEX,
        async (match, url) => {
          // Don’t touch http(s) or already-inlined urls
          if (/^(?:data:|https?:|\/\/)/i.test(url)) {
            return match;
          }
 
          try {
            // Resolve path relative to the current file
            const currentDir = path.resolve(path.dirname(id));
            const filePath = path.join(currentDir, url);
            const fileContent = await this.fs.readFile(filePath);
 
            this.addWatchFile(filePath);
            resolvedAssets.push(filePath);
 
            // Get MIME type
            const mimeType = mime.lookup(filePath);
            if (!mimeType) {
              this.warn(`Could not determine MIME type for file ${url}`);
              return match;
            }
 
            if (shouldExtract(filePath, fileContent, mimeType)) {
              const contentHash = await computeContentHash(fileContent);
              const { name, url: extractedUrl } = getExtractNameAndUrl(
                url,
                filePath,
                mimeType,
                contentHash,
              );
              if (!emittedFiles.has(name)) {
                emittedFiles.add(name);
                this.emitFile({
                  type: "asset",
                  fileName: name,
                  source: fileContent,
                });
              }
              return `url('${extractedUrl}')`;
            } else {
              // Warn if file is large
              if (fileContent.length > warnSize) {
                this.warn(
                  `Inlining file ${url} with large size ${fileContent.length}`,
                );
              }
              // inline as base64 data URL
              const base64 = Buffer.from(fileContent).toString("base64");
              return `url('data:${mimeType};base64,${base64}')`;
            }
          } catch (err) {
            this.warn(`Error processing file ${url}: ${err}`);
            return match;
          }
        },
      );
      if (resultCode === code) {
        return null;
      }
      const magicString = new MagicString(resultCode);
      return {
        code: magicString.toString(),
        map: magicString.generateMap({
          includeContent: true,
          hires: true,
        }),
        meta: {
          "css-url": { assets: resolvedAssets },
        },
      };
    },
  };
}