• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

addyosmani / critical / 25976187252

16 May 2026 11:50PM UTC coverage: 90.27% (+1.6%) from 88.691%
25976187252

Pull #620

github

bezoerb
fix(ci): add @vitest/coverage-v8 and Node 24 to matrix

- Add missing @vitest/coverage-v8 devDependency for coverage reports
- Add Node.js 24 to CI test matrix
Pull Request #620: vite+ & dependency update

535 of 611 branches covered (87.56%)

Branch coverage included in aggregate %.

163 of 176 new or added lines in 7 files covered. (92.61%)

1 existing line in 1 file now uncovered.

634 of 684 relevant lines covered (92.69%)

559.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.6
/src/file.js
1
/* eslint-disable complexity */
2
import { Buffer } from "node:buffer";
3
import fs from "node:fs";
4
import os from "node:os";
5
import path from "node:path";
6
import process from "node:process";
7
import url from "node:url";
8
import { promisify } from "node:util";
9
import parseCssUrls from "css-url-parser";
10
import { dataUriToBuffer } from "data-uri-to-buffer";
11
import debugBase from "debug";
12
import { findUpMultiple } from "find-up";
13
import { globby } from "globby";
14
import { parse } from "@adobe/css-tools";
15
import got from "got";
16
import isGlob from "is-glob";
17
import { makeDirectory } from "make-dir";
18
import oust from "oust";
19
import pico from "picocolors";
20
import postcss from "postcss";
21
import postcssUrl from "postcss-url";
22
import slash from "slash";
23
import { temporaryDirectory, temporaryFile } from "tempy";
24
import Vinyl from "vinyl";
25
import { filterAsync, forEachAsync, mapAsync, reduceAsync } from "./array.js";
26
import { FileNotFoundError } from "./errors.js";
27

28
const debug = debugBase("critical:file");
16✔
29

30
export const BASE_WARNING = `${pico.yellow(
16✔
31
  "Warning:",
32
)} Missing base path. Consider 'base' option. https://goo.gl/PwvFVb`;
33

34
const warn = (text) => process.stderr.write(pico.yellow(`${text}${os.EOL}`));
72✔
35

36
const unlinkAsync = promisify(fs.unlink);
16✔
37
const readFileAsync = promisify(fs.readFile);
16✔
38
const writeFileAsync = promisify(fs.writeFile);
16✔
39

40
export const checkCssOption = (css) =>
16✔
41
  Boolean((!Array.isArray(css) && css) || (Array.isArray(css) && css.length > 0));
2,048✔
42

43
export async function outputFileAsync(file, data) {
44
  const dir = path.dirname(file);
1,540✔
45

46
  if (!fs.existsSync(dir)) {
1,540✔
47
    await makeDirectory(dir);
524✔
48
  }
49

50
  return writeFileAsync(file, data);
1,540✔
51
}
52

53
/**
54
 * Fixup slashes in file paths for Windows and remove volume definition in front
55
 * @param {string} str Path
56
 * @returns {string} Normalized path
57
 */
58
export function normalizePath(str) {
59
  return process.platform === "win32" ? slash(str.replace(/^[a-zA-Z]:/, "")) : str;
1,700✔
60
}
61

62
/**
63
 * Check whether a resource is external or not
64
 * @param {string} href Path
65
 * @returns {boolean} True if the path is remote
66
 */
67
export function isRemote(href) {
68
  return typeof href === "string" && /(^\/\/)|(:\/\/)/.test(href) && !href.startsWith("file:");
26,444✔
69
}
70

71
/**
72
 * Parse Url
73
 * @param {string} str The URL
74
 * @returns {URL|object} return new URL Object
75
 */
76
export function urlParse(str = "") {
1,628✔
77
  if (/^\w+:\/\//.test(str)) {
1,628✔
78
    return new URL(str);
1,492✔
79
  }
80

81
  if (str.startsWith("//")) {
136!
NEW
82
    return new URL(str, "https://ba.se");
×
83
  }
84

85
  return { pathname: str };
136✔
86
}
87

88
/**
89
 * Get file uri considering OS
90
 * @param {string} file Absolute filepath
91
 * @returns {string} file uri
92
 */
93
function getFileUri(file) {
94
  if (!isAbsolute(file)) {
592!
NEW
95
    throw new Error("Path must be absolute to compute file uri. Received: " + file);
×
96
  }
97

98
  const fileUrl =
99
    process.platform === "win32" ? new URL(`file:///${file}`) : new URL(`file://${file}`);
592✔
100

101
  return fileUrl.href;
592✔
102
}
103

104
/**
105
 * Resolve Url
106
 * @param {string} from Resolve from
107
 * @param {string} to Resolve to
108
 * @returns {string} The resolved url
109
 */
110
export function urlResolve(from = "", to = "") {
1,864✔
111
  if (isRemote(from)) {
932✔
112
    const { href: base } = urlParse(from);
844✔
113
    const { href } = new URL(to, base);
844✔
114
    return href;
844✔
115
  }
116

117
  if (isAbsolute(to)) {
88✔
118
    return to;
12✔
119
  }
120

121
  return path.join(from.replace(/[^/]+$/, ""), to);
76✔
122
}
123

124
function isFilePath(href) {
125
  return typeof href === "string" && !isRemote(href);
9,768✔
126
}
127

128
export function isAbsolute(href) {
129
  return isFilePath(href) && path.isAbsolute(href);
6,844✔
130
}
131

132
/**
133
 * Check whether a resource is relative or not
134
 * @param {string} href Path
135
 * @returns {boolean} True if the path is relative
136
 */
137
function isRelative(href) {
138
  return isFilePath(href) && !isAbsolute(href);
2,924✔
139
}
140

141
/**
142
 * Wrapper for File.isVinyl to detect vinyl objects generated by gulp (vinyl < v0.5.6)
143
 * @param {*} file Object to check
144
 * @returns {boolean} True if it's a valid vinyl object
145
 */
146
function isVinyl(file) {
147
  return (
10,328✔
148
    Vinyl.isVinyl(file) ||
37,184!
149
    file instanceof Vinyl ||
150
    (file && /function File\(/.test(file.constructor.toString()) && file.contents && file.path)
151
  );
152
}
153

154
/**
155
 * Check if a file exists (remote & local)
156
 * @param {string} href Path
157
 * @param {object} options Critical options
158
 * @returns {Promise<boolean>} Resolves to true if the file exists
159
 */
160
export async function fileExists(href, options = {}) {
5,328✔
161
  if (isVinyl(href)) {
5,328✔
162
    return !href.isNull();
8✔
163
  }
164

165
  if (Buffer.isBuffer(href)) {
5,320✔
166
    return true;
40✔
167
  }
168

169
  if (isRemote(href)) {
5,280✔
170
    const { request = {} } = options;
940✔
171
    const method = request.method || "head";
940✔
172
    try {
940✔
173
      const response = await fetch(href, { ...options, request: { ...request, method } });
940✔
174
      const { statusCode } = response;
940✔
175

176
      if (method === "head") {
940✔
177
        return Number.parseInt(statusCode, 10) < 400;
912✔
178
      }
179

180
      return Boolean(response);
12✔
181
    } catch {
182
      return false;
16✔
183
    }
184
  }
185

186
  return fs.existsSync(href) || fs.existsSync(href.replace(/\?.*$/, ""));
4,340✔
187
}
188

189
/**
190
 * Remove temporary files
191
 * @param {array} files Array of temp files
192
 * @returns {Promise<void>|*} Promise resolves when all files removed
193
 */
194
const getCleanup = (files) => () =>
592✔
195
  forEachAsync(files, (file) => {
388✔
196
    try {
848✔
197
      unlinkAsync(file);
848✔
198
    } catch {
199
      debug(`${file} was already deleted`);
×
200
    }
201
  });
202

203
/**
204
 * Path join considering urls
205
 * @param {string} base Base path part
206
 * @param {string} part Path part to append
207
 * @returns {string} Joined path/url
208
 */
209
export function joinPath(base, part) {
210
  if (!part) {
1,816!
211
    return base;
×
212
  }
213

214
  if (isRemote(base)) {
1,816✔
215
    return urlResolve(base, part);
128✔
216
  }
217

218
  return path.join(base, part.replace(/\?.*$/, ""));
1,688✔
219
}
220

221
/**
222
 * Resolve path
223
 * @param {string} href Path
224
 * @param {[string]} search Paths to search in
225
 * @param {object} options Critical options
226
 * @returns {Promise<string>} Resolves to found path, rejects with FileNotFoundError otherwise
227
 */
228
export async function resolve(href, search = [], options = {}) {
1,368✔
229
  let exists = await fileExists(href, options);
684✔
230
  if (exists) {
684!
231
    return href;
×
232
  }
233

234
  for (const ref of search) {
684✔
235
    const checkPath = joinPath(ref, href);
1,148✔
236
    exists = await fileExists(checkPath, options); /* eslint-disable-line no-await-in-loop */
1,148✔
237
    if (exists) {
1,148✔
238
      return checkPath;
672✔
239
    }
240
  }
241

242
  throw new FileNotFoundError(href, search);
12✔
243
}
244

245
/**
246
 * Glob pattern
247
 * @param {array|string} pattern Glob pattern
248
 * @param {string} base Critical base option
249
 * @returns {Promise<[string]>} Found files
250
 */
251
function glob(pattern, { base } = {}) {
196✔
252
  // Evaluate globs based on base path
253
  const patterns = Array.isArray(pattern) ? pattern : [pattern];
196!
254
  // Prepend base if it's not empty & not remote
255
  const prependBase = (pattern) => (base && !isRemote(base) ? [path.join(base, pattern)] : []);
196!
256

257
  return reduceAsync([], patterns, async (files, pattern) => {
196✔
258
    if (isGlob(pattern)) {
196!
259
      const result = await globby([...prependBase(pattern), pattern]);
×
260
      return [...files, ...result];
×
261
    }
262

263
    return [...files, pattern];
196✔
264
  });
265
}
266

267
/**
268
 * Rebase image url in css
269
 *
270
 * @param {Buffer|string} css Stylesheet
271
 * @param {string} from Rebase from url
272
 * @param {string} to Rebase to url
273
 * @param {opject} options
274
 *    method: {string|function} method Rebase method. See https://github.com/postcss/postcss-url#options-combinations
275
 *    strict: fail on invalid css
276
 *    inlined: boolean flag indicating inlined css
277
 * @param {boolean} strict fail on invalid css
278
 * @returns {Buffer} Rebased css
279
 */
280
async function rebaseAssets(css, from, to, options = {}) {
832✔
281
  const { method = "rebase", strict = false, inlined = false } = options;
832✔
282
  let rebased = css.toString();
832✔
283

284
  debug("Rebase assets", { from, to });
832✔
285

286
  if (to.endsWith("/")) {
832!
NEW
287
    to += "temp.html";
×
288
  }
289

290
  if (from.endsWith("/")) {
832!
NEW
291
    from += "temp.css";
×
292
  }
293

294
  if (isRemote(from)) {
832✔
295
    const { pathname } = urlParse(from);
128✔
296
    from = pathname;
128✔
297
  }
298

299
  try {
832✔
300
    if (typeof method === "function") {
832✔
301
      const transform = (asset, ...rest) => {
148✔
302
        const assetNormalized = {
320✔
303
          ...asset,
304
          absolutePath: normalizePath(asset.absolutePath),
305
          relativePath: normalizePath(asset.relativePath),
306
        };
307

308
        return method(assetNormalized, ...rest);
320✔
309
      };
310

311
      const result = await postcss()
148✔
312
        .use(postcssUrl({ url: transform }))
313
        .process(css, { from, to });
314
      rebased = result.css;
148✔
315
    } else if (from && to) {
684!
316
      const result = await postcss()
684✔
317
        .use(postcssUrl({ url: method }))
318
        .process(css, { from, to });
319
      rebased = result.css;
684✔
320
    }
321
  } catch (error) {
322
    if (strict) {
×
323
      if (inlined) {
×
NEW
324
        error.message = error.message.replace(from, "Inlined stylesheet");
×
325
      }
326

327
      throw error;
×
328
    }
329

330
    debug(`CSS parse error: ${error.message}`);
×
NEW
331
    rebased = "";
×
332
  }
333

334
  return Buffer.from(rebased);
832✔
335
}
336

337
/**
338
 * Token generated by concatenating username and password with `:` character within a base64 encoded string.
339
 * @param  {String} user User identifier.
340
 * @param  {String} pass Password.
341
 * @returns {String} Base64 encoded authentication token.
342
 */
343
export const token = (user, pass) => Buffer.from([user, pass].join(":")).toString("base64");
16✔
344

345
/**
346
 * Get external resource. Try https and falls back to http
347
 * @param {string} uri Source uri
348
 * @param {object} options Options passed to critical
349
 * @param {boolean} secure Use https?
350
 * @returns {Promise<Buffer|response>} Resolves to fetched content or response object for HEAD request
351
 */
352
async function fetch(uri, options = {}, secure = true) {
3,784✔
353
  const { user, pass, userAgent, request: requestOptions = {} } = options;
1,892✔
354
  const { headers = {}, method = "get", https } = requestOptions;
1,892✔
355
  let resourceUrl = uri;
1,892✔
356
  let protocolRelative = false;
1,892✔
357

358
  // Consider protocol-relative urls
359
  if (uri.startsWith("//")) {
1,892✔
360
    protocolRelative = true;
32✔
361
    resourceUrl = urlResolve(`http${secure ? "s" : ""}://te.st`, uri);
32!
362
  }
363

364
  requestOptions.https = { rejectUnauthorized: true, ...https };
1,892✔
365
  if (user && pass) {
1,892!
366
    headers.Authorization = `Basic ${token(user, pass)}`;
×
367
  }
368

369
  if (userAgent) {
1,892✔
370
    headers["User-Agent"] = userAgent;
32✔
371
  }
372

373
  debug(`Fetching resource: ${resourceUrl}`, { ...requestOptions, headers });
1,892✔
374

375
  try {
1,892✔
376
    const response = await got(resourceUrl, { ...requestOptions, headers });
1,892✔
377
    if (method === "head") {
1,560✔
378
      return response;
1,080✔
379
    }
380

381
    return Buffer.from(response.body || "");
480✔
382
  } catch (error) {
383
    // Try again with http
384
    if (secure && protocolRelative) {
332!
385
      debug(`${error.message} - trying again over http`);
×
386
      return fetch(uri, options, false);
×
387
    }
388

389
    debug(`${resourceUrl} failed: ${error.message}`);
332✔
390

391
    if (method === "head") {
332✔
392
      return error.response;
324✔
393
    }
394

395
    if (error.response) {
8!
396
      return Buffer.from(error.response.body || "");
8!
397
    }
398

399
    throw error;
×
400
  }
401
}
402

403
/**
404
 * Extract stylesheet urls from html document
405
 * @param {Vinyl} file Vinyl file object (document)
406
 * @param {object} options Options passed to critical
407
 * @returns {[string]} Stylesheet urls from document source
408
 */
409
function getStylesheetObjects(file, options) {
410
  const { ignoreInlinedStyles } = options || {};
1,292✔
411
  if (!isVinyl(file)) {
1,292!
NEW
412
    throw new Error("Parameter file needs to be a vinyl object");
×
413
  }
414

415
  // Already computed stylesheetObjects
416
  if (file.stylesheetObjects) {
1,292✔
417
    return file.stylesheetObjects;
592✔
418
  }
419

420
  const stylesheets = oust.raw(file.contents.toString(), ["stylesheets", "preload", "styles"]);
700✔
421

422
  const isNotPrint = (el) =>
700✔
423
    el.attr("media") !== "print" ||
912!
424
    (Boolean(el.attr("onload")) && el.attr("onload").includes("media"));
425

426
  const isMediaQuery = (media) =>
700✔
427
    typeof media === "string" && !["all", "print", "screen"].includes(media);
892✔
428

429
  const allowedInlinedStylesheet = (type) => type !== "styles" || !ignoreInlinedStyles;
896✔
430

431
  const objects = stylesheets
700✔
432
    .filter(
433
      (link) => isNotPrint(link.$el) && Boolean(link.value) && allowedInlinedStylesheet(link.type),
912✔
434
    )
435
    .map((link) => {
436
      const media = isMediaQuery(link.$el.attr("media")) ? link.$el.attr("media") : "";
892✔
437

438
      // support base64 encoded styles
439
      if (link.value.startsWith("data:")) {
892✔
440
        const parsed = dataUriToBuffer(link.value);
12✔
441
        return {
12✔
442
          media,
443
          value: Buffer.from(parsed.buffer),
444
        };
445
      }
446

447
      if (link.type === "styles") {
880✔
448
        return {
16✔
449
          media,
450
          value: Buffer.from(link.value),
451
        };
452
      }
453

454
      return {
864✔
455
        media,
456
        value: link.value,
457
      };
458
    });
459

460
  const isEqual = (a, b) => Buffer.from(a).compare(Buffer.from(b)) === 0;
2,336✔
461
  const compare = (a, b) => isEqual(a.media, b.media) && isEqual(a.value, b.value);
1,168✔
462
  // Make objects unique
463
  const stylesheetObjects = objects.filter((a, index, array) => {
700✔
464
    return array.findIndex((b) => compare(a, b)) === index;
1,168✔
465
  });
466

467
  // cache them for later use
468
  file.stylesheetObjects = stylesheetObjects;
700✔
469

470
  return stylesheetObjects;
700✔
471
}
472

473
/**
474
 * Extract stylesheet urls from html document
475
 * @param {Vinyl} file Vinyl file object (document)
476
 * @param {object} options Options passed to critical
477
 * @returns {[string]} Stylesheet urls from document source
478
 */
479
export function getStylesheetHrefs(file, options) {
480
  return getStylesheetObjects(file, options).map((object) => object.value);
892✔
481
}
482

483
/**
484
 * Extract stylesheet urls from html document
485
 * @param {Vinyl} file Vinyl file object (document)
486
 * @param {object} options Options passed to critical
487
 * @returns {[string]} Stylesheet urls from document source
488
 */
489
export function getStylesheetsMedia(file, options) {
490
  return getStylesheetObjects(file, options).map((object) => object.media);
744✔
491
}
492

493
/**
494
 * Extract asset urls from stylesheet
495
 * @param {Vinyl} file Vinyl file object (stylesheet)
496
 * @returns {[string]} Asset urls from stylesheet source
497
 */
498
export function getAssets(file) {
499
  if (!isVinyl(file)) {
20!
NEW
500
    throw new Error("Parameter file needs to be a vinyl object");
×
501
  }
502

503
  return parseCssUrls(file.contents.toString());
20✔
504
}
505

506
/**
507
 * Compute Path to Html document based on docroot
508
 * @param {Vinyl} file The file we want to check
509
 * @param {object} options Critical options object
510
 * @returns {Promise<string>} Computed path
511
 */
512
export async function getDocumentPath(file, options = {}) {
660✔
513
  let { base } = options;
660✔
514

515
  // Check remote
516
  if (file.remote) {
660✔
517
    let { pathname } = file.urlObj;
208✔
518
    if (pathname.endsWith("/")) {
208✔
519
      pathname += "index.html";
24✔
520
    }
521

522
    return pathname;
208✔
523
  }
524

525
  // If we don't have a file path and
526
  if (!file.path) {
452✔
527
    return "";
116✔
528
  }
529

530
  if (base) {
336✔
531
    base = path.resolve(base);
244✔
532
    return normalizePath(`/${path.relative(base, file.path || base)}`);
244!
533
  }
534

535
  // Check local and assume base path based on relative stylesheets
536
  if (file.stylesheets) {
92!
537
    const relativeRefs = file.stylesheets.filter((href) => isRelative(href));
136✔
538
    const absoluteRefs = file.stylesheets.filter((href) => isAbsolute(href));
136✔
539

540
    // If we have no stylesheets inside, fall back to path relative to process cwd
541
    if (relativeRefs.length === 0 && absoluteRefs.length === 0) {
92✔
542
      process.stderr.write(BASE_WARNING);
16✔
543

544
      return normalizePath(`/${path.relative(process.cwd(), file.path)}`);
16✔
545
    }
546

547
    // Compute base path based on absolute links
548
    if (relativeRefs.length === 0) {
76✔
549
      const [ref] = absoluteRefs;
8✔
550
      const paths = await getAssetPaths(file, ref, options);
8✔
551
      try {
8✔
552
        const filepath = await resolve(ref, paths, options);
8✔
553
        return normalizePath(
8✔
554
          `/${path.relative(normalizePath(filepath).replace(ref, ""), file.path)}`,
555
        );
556
      } catch {
557
        process.stderr.write(BASE_WARNING);
×
558

559
        return normalizePath(`/${path.relative(process.cwd(), file.path)}`);
×
560
      }
561
    }
562

563
    // Compute path based on relative stylesheet links
564
    const dots = relativeRefs.reduce((res, href) => {
68✔
565
      const match = /^(\.\.\/)+/.exec(href);
108✔
566

567
      return match && match[0].length > res.length ? match[0] : res;
108✔
568
    }, "./");
569

570
    const tmpBase = path.resolve(path.dirname(file.path), dots);
68✔
571

572
    return normalizePath(`/${path.relative(tmpBase, file.path)}`);
68✔
573
  }
574

NEW
575
  return "";
×
576
}
577

578
/**
579
 * Get path for remote stylesheet. Compares document host with stylesheet host
580
 * @param {object} fileObj Result of urlParse(style url)
581
 * @param {object} documentObj Result of urlParse(document url)
582
 * @param {string} filename Filename
583
 * @returns {string} Path to css (can be remote or local relative to document base)
584
 */
585
function getRemoteStylesheetPath(fileObj, documentObj, filename) {
586
  let { hostname: styleHost, port: stylePort, pathname } = fileObj;
264✔
587
  const { hostname: docHost, port: docPort } = documentObj || {};
264✔
588

589
  if (filename) {
264✔
590
    pathname = joinPath(path.dirname(pathname), path.basename(filename));
12✔
591
    fileObj.pathname = normalizePath(pathname);
12✔
592
  }
593

594
  if (`${styleHost}:${stylePort}` === `${docHost}:${docPort}`) {
264✔
595
    return pathname;
76✔
596
  }
597

598
  return url.format(fileObj);
188✔
599
}
600

601
/**
602
 * Get path to stylesheet based on docroot
603
 * @param {Vinyl} document Optional reference document
604
 * @param {Vinyl} file the file we want to check
605
 * @param {object} options Critical options object
606
 * @returns {Promise<string>} Computed path
607
 */
608
export function getStylesheetPath(document, file, options = {}) {
1,000✔
609
  let { base } = options;
1,000✔
610

611
  // Check inline styles
612
  if (file.inline) {
1,000✔
613
    return normalizePath(`${document.virtualPath}.css`);
40✔
614
  }
615

616
  // Check remote
617
  if (file.remote) {
960✔
618
    return getRemoteStylesheetPath(file.urlObj, document.urlObj);
252✔
619
  }
620

621
  // Generate path relative to document if stylesheet is referenced relative
622
  //
623
  if (isRelative(file.path) && document.virtualPath) {
708✔
624
    return normalizePath(joinPath(path.dirname(document.virtualPath), file.path));
408✔
625
  }
626

627
  if (base && path.resolve(file.path).includes(path.resolve(base))) {
300✔
628
    base = path.resolve(base);
164✔
629
    return normalizePath(`/${path.relative(path.resolve(base), path.resolve(file.path))}`);
164✔
630
  }
631

632
  // Try to compute path based on document link tags with same name
633
  const stylesheet = document.stylesheets
136✔
634
    .filter((href) => !Buffer.isBuffer(href))
172✔
635
    .find((href) => {
636
      const { pathname } = urlParse(href);
152✔
637
      const name = path.basename(pathname);
152✔
638
      return name === path.basename(file.path);
152✔
639
    });
640

641
  if (stylesheet && isRelative(stylesheet) && document.virtualPath) {
136✔
642
    return normalizePath(joinPath(path.dirname(document.virtualPath), stylesheet));
12✔
643
  }
644

645
  if (stylesheet && isRemote(stylesheet)) {
124!
646
    return getRemoteStylesheetPath(urlParse(stylesheet), document.urlObj);
×
647
  }
648

649
  if (stylesheet) {
124✔
650
    return stylesheet;
92✔
651
  }
652

653
  // Try to find stylesheet path based on document link tags
654
  const [unsafestylesheet] = document.stylesheets
32✔
655
    .filter((href) => !Buffer.isBuffer(href))
36✔
656
    .sort((a) => (isRemote(a) ? 1 : -1));
4!
657
  if (unsafestylesheet && isRelative(unsafestylesheet) && document.virtualPath) {
32✔
658
    return normalizePath(
8✔
659
      joinPath(
660
        path.dirname(document.virtualPath),
661
        joinPath(path.dirname(unsafestylesheet), path.basename(file.path)),
662
      ),
663
    );
664
  }
665

666
  if (unsafestylesheet && isRemote(unsafestylesheet)) {
24✔
667
    return getRemoteStylesheetPath(
12✔
668
      urlParse(unsafestylesheet),
669
      document.urlObj,
670
      path.basename(file.path),
671
    );
672
  }
673

674
  if (stylesheet) {
12!
675
    return stylesheet;
×
676
  }
677

678
  process.stderr.write(BASE_WARNING);
12✔
679
  if (document.virtualPath && file.path) {
12!
680
    return normalizePath(joinPath(path.dirname(document.virtualPath), path.basename(file.path)));
×
681
  }
682

683
  return "";
12✔
684
}
685

686
/**
687
 * Get a list of possible asset paths
688
 * Guess this is rather expensive so this method should only be used if
689
 * there's no other possible way
690
 *
691
 * @param {Vinyl} document Html document
692
 * @param {string} file File path
693
 * @param {object} options Critical options
694
 * @param {boolean} strict Check for file existence
695
 * @returns {Promise<[string]>} List of asset paths
696
 */
697
export async function getAssetPaths(document, file, options = {}, strict = true) {
1,512✔
698
  const { base, rebase = {}, assetPaths = [] } = options;
756✔
699
  const { history = [], url: docurl = "", urlObj } = document;
756✔
700
  const { from, to } = rebase;
756✔
701
  const { pathname: urlPath } = urlObj || {};
756✔
702
  const [docpath] = history;
756✔
703

704
  if (isVinyl(file)) {
756!
705
    return [];
×
706
  }
707

708
  // consider base tag in document
709
  const baseTagHref = document?.contents?.toString()?.match(/<base\s+href=['"]([^'"]+)['"]/)?.[1];
756✔
710
  // Remove double dots in the middle
711
  const normalized = path.join(file);
756✔
712
  // Count directory hops
713
  const hops = normalized.split(path.sep).reduce((cnt, part) => (part === ".." ? cnt + 1 : cnt), 0);
1,704✔
714
  // Also findup first real dir path
715
  const [first] = normalized.split(path.sep).filter((p) => p && p !== ".."); // eslint-disable-line unicorn/prefer-array-find
1,704✔
716
  const mappedAssetPaths = base ? assetPaths.map((a) => joinPath(base, a)) : [];
756✔
717

718
  // Make a list of possible paths
719
  const paths = [
756✔
720
    ...new Set([
721
      base,
722
      baseTagHref,
723
      baseTagHref && !isRemote(baseTagHref) && path.join(base, baseTagHref),
772✔
724
      base && isRelative(base) && path.join(process.cwd(), base),
1,360✔
725
      docurl,
726
      urlPath && urlResolve(urlObj.href, path.dirname(urlPath)),
1,020✔
727
      urlPath &&
1,020!
728
        !path.dirname(urlPath).endsWith("/") &&
729
        urlResolve(urlObj.href, `${path.dirname(urlPath)}/`),
730
      docurl && urlResolve(docurl, file),
1,060✔
731
      docpath && path.dirname(docpath),
1,196✔
732
      ...assetPaths,
733
      ...mappedAssetPaths,
734
      to,
735
      from,
736
      base && docpath && path.join(base, path.dirname(docpath)),
1,708✔
737
      base && to && path.join(base, path.dirname(to)),
1,392✔
738
      base && from && path.join(base, path.dirname(from)),
1,356!
739
      base && isRelative(file) && hops
2,668✔
740
        ? path.join(base, ...Array.from({ length: hops }).fill("tmpdir"), file)
741
        : "",
742
      process.cwd(),
743
    ]),
744
  ];
745

746
  // Filter non-existent paths
747
  const filtered = await filterAsync(paths, (f) => {
756✔
748
    if (!f || (isAbsolute(f) && !f?.includes(process.cwd()))) {
5,272✔
749
      return false;
2,276✔
750
    }
751

752
    return !strict || fileExists(f, options);
2,996✔
753
  });
754

755
  // Findup first directory in search path and add to the list if available
756
  const all = await reduceAsync(filtered, [...new Set(filtered)], async (result, cwd) => {
756✔
757
    if (isRemote(cwd)) {
2,456✔
758
      return [...result, cwd];
440✔
759
    }
760

761
    // const up = await findUp(first, {cwd, type: 'directory'});
762
    const up = await findUpMultiple(first, { cwd, type: "directory", stopAt: process.cwd() });
2,016✔
763
    const additionalDirectories = up.flatMap((u) => {
2,016✔
764
      const upDir = path.dirname(u);
1,424✔
765

766
      if (hops) {
1,424✔
767
        // Add additional directories based on dirHops
768
        const additional = path.relative(upDir, cwd).split(path.sep).slice(0, hops);
252✔
769

770
        return [upDir, path.join(upDir, ...additional)];
252✔
771
      }
772

773
      return [upDir];
1,172✔
774
    });
775

776
    return [...result, ...additionalDirectories];
2,016✔
777
  });
778

779
  debug(`(getAssetPaths) Search file "${file}" in:`, [...new Set(all)]);
756✔
780

781
  // Return uniquq result
782
  return [...new Set(all)];
756✔
783
}
784

785
/**
786
 * Create vinyl object from filepath
787
 * @param {object} src File descriptor either pass "filepath" or "html"
788
 * @param {object} options Critical options
789
 * @returns {Promise<Vinyl>} The vinyl object
790
 */
791
export async function vinylize(src, options = {}) {
1,752✔
792
  const { filepath, html } = src;
1,752✔
793
  const { rebase = {}, request = {} } = options;
1,752✔
794
  const file = new Vinyl();
1,752✔
795
  file.cwd = "/";
1,752✔
796
  file.remote = false;
1,752✔
797
  file.inline = false;
1,752✔
798

799
  if (html) {
1,752✔
800
    const { to } = rebase;
136✔
801
    file.contents = Buffer.from(html);
136✔
802
    file.path = to || "";
136✔
803
    file.virtualPath = to || "";
136✔
804
  } else if (filepath && Buffer.isBuffer(filepath)) {
1,616✔
805
    file.path = "";
40✔
806
    file.virtualPath = "";
40✔
807
    file.contents = filepath;
40✔
808
    file.inline = true;
40✔
809
  } else if (filepath && isVinyl(filepath)) {
1,576✔
810
    return filepath;
28✔
811
  } else if (filepath && isRemote(filepath)) {
1,548✔
812
    let url = filepath;
476✔
813
    try {
476✔
814
      const response = await fetch(filepath, {
476✔
815
        ...options,
816
        request: { ...request, method: "head" },
817
      });
818
      if (response.url !== url) {
476✔
819
        debug(`(vinylize) found redirect from ${url} to ${response.url}`);
28✔
820
        url = response.url;
28✔
821
      }
822
    } catch {}
823

824
    file.remote = true;
476✔
825
    file.url = url;
476✔
826
    file.urlObj = urlParse(url);
476✔
827
    file.contents = await fetch(url, options);
476✔
828
    file.virtualPath = file.urlObj.pathname;
476✔
829
  } else if (filepath && fs.existsSync(filepath)) {
1,072✔
830
    file.path = filepath;
1,064✔
831
    file.virtualPath = filepath;
1,064✔
832
    file.contents = await readFileAsync(filepath);
1,064✔
833
  } else {
834
    throw new FileNotFoundError(filepath);
8✔
835
  }
836

837
  return file;
1,716✔
838
}
839

840
/**
841
 * Get stylesheet file object
842
 * @param {Vinyl} document Document vinyl object
843
 * @param {string} filepath Path/Url to css file
844
 * @param {object} options Critical options
845
 * @returns {Promise<Vinyl>} Vinyl representation fo the stylesheet
846
 */
847
export async function getStylesheet(document, filepath, options = {}) {
948✔
848
  const { rebase = {}, css, strict, media } = options;
948✔
849
  const originalPath = filepath;
948✔
850

851
  const exists = await fileExists(filepath, options);
948✔
852

853
  if (!exists) {
948✔
854
    const searchPaths = await getAssetPaths(document, filepath, options);
660✔
855
    try {
660✔
856
      filepath = await resolve(filepath, searchPaths, options);
660✔
857
    } catch (error) {
858
      if (!isRemote(filepath) || strict) {
8!
859
        throw error;
×
860
      }
861

862
      return new Vinyl();
8✔
863
    }
864
  }
865

866
  // Create absolute file paths for local files passed via css option
867
  // to prevent document relative stylesheet paths if they are not relative specified
868
  if (
940✔
869
    !Buffer.isBuffer(originalPath) &&
3,412✔
870
    !isVinyl(filepath) &&
871
    !isRemote(filepath) &&
872
    checkCssOption(css)
873
  ) {
874
    filepath = path.resolve(filepath);
140✔
875
  }
876

877
  const file = await vinylize({ filepath }, options);
940✔
878
  if (media) {
940✔
879
    file.contents = Buffer.from(`@media ${media} { ${file.contents.toString()} }`);
8✔
880
  }
881

882
  // Restore original path for local files referenced from document and not from options
883
  if (!Buffer.isBuffer(originalPath) && !isRemote(originalPath) && !checkCssOption(css)) {
940✔
884
    file.path = originalPath;
600✔
885
  }
886

887
  // Get stylesheet path. Keeps stylesheet url if it differs from document url
888
  const stylepath = await getStylesheetPath(document, file, options);
940✔
889
  if (Buffer.isBuffer(originalPath)) {
940✔
890
    file.path = stylepath;
40✔
891
    file.virtualPath = stylepath;
40✔
892
  }
893

894
  debug("(getStylesheet) Virtual Stylesheet Path:", stylepath);
940✔
895
  // We can safely rebase assets if we have:
896
  // - a url to the stylesheet
897
  // - if rebase.from and rebase.to is specified
898
  // - a valid document path and a stylesheet path
899
  // - an absolute positioned stylesheet so we can make the images absolute
900
  // - and rebase is not disabled (#359)
901
  // First respect the user input
902
  if (rebase === false) {
940✔
903
    return file;
36✔
904
  }
905

906
  if (rebase.from && rebase.to) {
904✔
907
    file.contents = await rebaseAssets(file.contents, rebase.from, rebase.to, {
16✔
908
      method: "rebase",
909
      strict: options.strict,
910
      inlined: Buffer.isBuffer(originalPath),
911
    });
912
  } else if (typeof rebase === "function") {
888✔
913
    file.contents = await rebaseAssets(file.contents, stylepath, document.virtualPath, {
8✔
914
      method: rebase,
915
      strict: options.strict,
916
      inlined: Buffer.isBuffer(originalPath),
917
    });
918
    // Next rebase to the stylesheet url
919
  } else if (isRemote(rebase.to || stylepath)) {
880✔
920
    const from = rebase.from || stylepath;
128✔
921
    const to = rebase.to || stylepath;
128✔
922
    const method = (asset) =>
128✔
923
      isRemote(asset.originUrl) ? asset.originUrl : urlResolve(to, asset.originUrl);
300✔
924
    file.contents = await rebaseAssets(file.contents, from, to, {
128✔
925
      method,
926
      strict: options.strict,
927
      inlined: Buffer.isBuffer(originalPath),
928
    });
929

930
    // Use relative path to document (local)
931
  } else if (document.virtualPath) {
752✔
932
    file.contents = await rebaseAssets(
668✔
933
      file.contents,
934
      rebase.from || stylepath,
1,336✔
935
      rebase.to || document.virtualPath,
1,300✔
936
      {
937
        method: "rebase",
938
        strict: options.strict,
939
        inlined: Buffer.isBuffer(originalPath),
940
      },
941
    );
942
  } else if (document.remote) {
84!
NEW
943
    const { pathname } = document.urlObj;
×
NEW
944
    file.contents = await rebaseAssets(
×
945
      file.contents,
946
      rebase.from || stylepath,
×
947
      rebase.to || pathname,
×
948
      {
949
        method: "rebase",
950
        strict: options.strict,
951
        inlined: Buffer.isBuffer(originalPath),
952
      },
953
    );
954

955
    // Make images absolute if we have an absolute positioned stylesheet
956
  } else if (isAbsolute(stylepath)) {
84✔
957
    file.contents = await rebaseAssets(
12✔
958
      file.contents,
959
      rebase.from || stylepath,
24✔
960
      rebase.to || "/index.html",
24✔
961
      {
962
        method: (asset) => normalizePath(asset.absolutePath),
12✔
963
        strict: options.strict,
964
        inlined: Buffer.isBuffer(originalPath),
965
      },
966
    );
967
  } else {
968
    warn(`Not rebasing assets for ${originalPath}. Use "rebase" option`);
72✔
969
  }
970

971
  debug("(getStylesheet) Result:", file);
904✔
972

973
  return file;
904✔
974
}
975

976
const isCssSource = (string) => {
16✔
977
  try {
208✔
978
    parse(string);
208✔
979
    return true;
208✔
980
  } catch {
981
    return false;
196✔
982
  }
983
};
984

985
/**
986
 * Get css for document
987
 * @param {Vinyl} document Vinyl representation of HTML document
988
 * @param {object} options Critical options
989
 * @returns {Promise<string>} Css string unoptimized, Multiple stylesheets are concatenated with EOL
990
 */
991
export async function getCss(document, options = {}) {
596✔
992
  const { css } = options;
596✔
993
  let stylesheets = [];
596✔
994

995
  if (checkCssOption(css)) {
596✔
996
    const cssArray = Array.isArray(css) ? css : [css];
188✔
997

998
    // merge css files & css source strings passed as css option
999
    const filesRaw = await Promise.all(
188✔
1000
      cssArray.map((value) => {
1001
        if (isCssSource(value)) {
208✔
1002
          return Buffer.from(value);
12✔
1003
        }
1004

1005
        return glob(value, options);
196✔
1006
      }),
1007
    );
1008

1009
    const files = filesRaw.flat();
188✔
1010
    stylesheets = await mapAsync(files, (file) => getStylesheet(document, file, options));
208✔
1011
    debug("(getCss) css option set", files, stylesheets);
188✔
1012
  } else {
1013
    stylesheets = await mapAsync(document.stylesheets, (file, index) => {
408✔
1014
      const media = (document.stylesheetsMedia || [])[index];
604!
1015
      return getStylesheet(document, file, { ...options, media });
604✔
1016
    });
1017
    debug("(getCss) extract from document", document.stylesheets, stylesheets);
408✔
1018
  }
1019

1020
  return stylesheets
596✔
1021
    .filter((stylesheet) => !stylesheet.isNull())
812✔
1022
    .map((stylesheet) => stylesheet.contents.toString())
804✔
1023
    .join(os.EOL);
1024
}
1025

1026
/**
1027
 * We need to make sure the html file is available alongside the relative css files
1028
 * as they are required by penthouse/puppeteer to render the html correctly
1029
 * @see https://github.com/pocketjoso/penthouse/issues/280
1030
 *
1031
 * @param {Vinyl} document Vinyl representation of HTML document
1032
 * @returns {Promise<string>} File url to html file for use in penthouse
1033
 */
1034
async function preparePenthouseData(document) {
1035
  const tmp = [];
592✔
1036
  const stylesheets = document.stylesheets || [];
592!
1037
  const [stylesheet, ...canBeEmpty] = stylesheets
592✔
1038
    .filter((file) => isRelative(file))
744✔
1039
    .map((file) => file.replace(/\?.*$/, ""));
616✔
1040

1041
  // Make sure we go as deep inside the temp folder as required by relative stylesheet hrefs
1042
  const subfolders = [stylesheet, ...canBeEmpty]
592✔
1043
    .reduce((res, href) => {
1044
      const match = /^(\.\.\/)+/.exec(href || "");
768✔
1045
      return match && match[0].length > res.length ? match[0] : res;
768✔
1046
    }, "./")
1047
    .replaceAll("../", "sub/");
1048
  const dir = path.join(temporaryDirectory(), subfolders);
592✔
1049
  const filename = path.basename(temporaryFile({ extension: "html" }));
592✔
1050
  const file = path.join(dir, filename);
592✔
1051

1052
  const htmlContent = document.contents.toString();
592✔
1053
  // Inject all styles to make sure we have everything in place
1054
  // because puppeteer doesn't seem to fetch protocol relative links
1055
  // when served from file://
1056
  const injected = htmlContent.replaceAll(
592✔
1057
    /(<head(?:\s[^>]*)?>)/gi,
1058
    `$1<style>${document.css.toString()}</style>`,
1059
  );
1060
  // Write html to temp file
1061
  await outputFileAsync(file, injected);
592✔
1062

1063
  tmp.push(file);
592✔
1064

1065
  // Write styles to first stylesheet
1066
  if (stylesheet) {
592✔
1067
    const filename = path.join(dir, stylesheet);
440✔
1068
    tmp.push(filename);
440✔
1069
    await outputFileAsync(filename, document.css);
440✔
1070
  }
1071

1072
  // Write empty string to rest of the linked stylesheets
1073
  await forEachAsync(canBeEmpty, (dummy) => {
592✔
1074
    const filename = path.join(dir, dummy);
176✔
1075
    tmp.push(filename);
176✔
1076
    outputFileAsync(filename, "");
176✔
1077
  });
1078

1079
  return [getFileUri(file), getCleanup(tmp)];
592✔
1080
}
1081

1082
/**
1083
 * Get document file object
1084
 * @param {string} filepath Path/Url to html file
1085
 * @param {object} options Critical options
1086
 * @returns {Promise<Vinyl>} Vinyl representation of HTML document
1087
 */
1088
export async function getDocument(filepath, options = {}) {
460✔
1089
  const { rebase = {}, base } = options;
460✔
1090

1091
  if (!isVinyl(filepath) && !isRemote(filepath) && !fs.existsSync(filepath) && base) {
460✔
1092
    filepath = joinPath(base, filepath);
196✔
1093
  }
1094

1095
  const document = await vinylize({ filepath }, options);
460✔
1096

1097
  document.stylesheets = await getStylesheetHrefs(document, options);
456✔
1098
  document.stylesheetsMedia = await getStylesheetsMedia(document, options);
456✔
1099
  document.virtualPath = rebase.to || (await getDocumentPath(document, options));
456✔
1100

1101
  document.cwd = base || process.cwd();
460✔
1102
  if (!base && document.path) {
460✔
1103
    document.cwd = document.path.replace(document.virtualPath, "");
60✔
1104
  }
1105

1106
  debug("(getDocument) Result: ", {
456✔
1107
    path: document.path,
1108
    url: document.url,
1109
    remote: Boolean(document.remote),
1110
    virtualPath: document.virtualPath,
1111
    stylesheets: document.stylesheets,
1112
    cwd: document.cwd,
1113
  });
1114

1115
  document.css = await getCss(document, options);
456✔
1116

1117
  const [url, cleanup] = await preparePenthouseData(document);
456✔
1118
  document.url = url;
456✔
1119
  document.cleanup = cleanup;
456✔
1120

1121
  return document;
456✔
1122
}
1123

1124
/**
1125
 * Get document file object from raw html source
1126
 * @param {string} html HTML source
1127
 * @param {object} options Critical options
1128
 * @returns {Promise<*>} Vinyl representation of HTML document
1129
 */
1130
export async function getDocumentFromSource(html, options = {}) {
136✔
1131
  const { rebase = {}, base } = options;
136✔
1132
  const document = await vinylize({ html }, options);
136✔
1133

1134
  document.stylesheets = await getStylesheetHrefs(document);
136✔
1135
  document.stylesheetsMedia = await getStylesheetsMedia(document);
136✔
1136
  document.virtualPath = rebase.to || (await getDocumentPath(document, options));
136✔
1137
  document.cwd = base || process.cwd();
136✔
1138

1139
  debug("(getDocumentFromSource) Result: ", {
136✔
1140
    path: document.path,
1141
    url: document.url,
1142
    remote: Boolean(document.remote),
1143
    virtualPath: document.virtualPath,
1144
    stylesheets: document.stylesheets,
1145
    cwd: document.cwd,
1146
  });
1147

1148
  document.css = await getCss(document, options);
136✔
1149

1150
  const [url, cleanup] = await preparePenthouseData(document);
136✔
1151
  document.url = url;
136✔
1152
  document.cleanup = cleanup;
136✔
1153

1154
  return document;
136✔
1155
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc