UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

195 lines (168 loc) 6.25 kB
/** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {reportAssets} from './report-assets.js'; /** @typedef {import('../../types/lhr/lhr').default} LHResult */ /** @typedef {import('../../types/lhr/flow-result').default} FlowResult */ class ReportGenerator { /** * Replaces all the specified strings in source without serial replacements. * @param {string} source * @param {!Array<{search: string, replacement: string}>} replacements * @return {string} */ static replaceStrings(source, replacements) { if (replacements.length === 0) { return source; } const firstReplacement = replacements[0]; const nextReplacements = replacements.slice(1); return source .split(firstReplacement.search) .map(part => ReportGenerator.replaceStrings(part, nextReplacements)) .join(firstReplacement.replacement); } /** * @param {unknown} object * @return {string} */ static sanitizeJson(object) { return JSON.stringify(object) .replace(/</g, '\\u003c') // replaces opening script tags .replace(/\u2028/g, '\\u2028') // replaces line separators () .replace(/\u2029/g, '\\u2029'); // replaces paragraph separators } /** * Returns the standalone report HTML as a string with the report JSON and renderer JS inlined. * @param {LHResult} lhr * @return {string} */ static generateReportHtml(lhr) { const sanitizedJson = ReportGenerator.sanitizeJson(lhr); // terser does its own sanitization, but keep this basic replace for when // we want to generate a report without minification. const sanitizedJavascript = reportAssets.REPORT_JAVASCRIPT.replace(/<\//g, '\\u003c/'); return ReportGenerator.replaceStrings(reportAssets.REPORT_TEMPLATE, [ {search: '%%LIGHTHOUSE_JSON%%', replacement: sanitizedJson}, {search: '%%LIGHTHOUSE_JAVASCRIPT%%', replacement: sanitizedJavascript}, ]); } /** * Returns the standalone flow report HTML as a string with the report JSON and renderer JS inlined. * @param {FlowResult} flow * @return {string} */ static generateFlowReportHtml(flow) { const sanitizedJson = ReportGenerator.sanitizeJson(flow); // terser does its own sanitization, but keep this basic replace for when // we want to generate a report without minification. const sanitizedJavascript = reportAssets.FLOW_REPORT_JAVASCRIPT.replace(/<\//g, '\\u003c/'); return ReportGenerator.replaceStrings(reportAssets.FLOW_REPORT_TEMPLATE, [ {search: '%%LIGHTHOUSE_FLOW_JSON%%', replacement: sanitizedJson}, {search: '%%LIGHTHOUSE_FLOW_JAVASCRIPT%%', replacement: sanitizedJavascript}, {search: '/*%%LIGHTHOUSE_FLOW_CSS%%*/', replacement: reportAssets.FLOW_REPORT_CSS}, ]); } /** * Converts the results to a CSV formatted string * Each row describes the result of 1 audit with * - the name of the category the audit belongs to * - the name of the audit * - a description of the audit * - the score type that is used for the audit * - the score value of the audit * * @param {LHResult} lhr * @return {string} */ static generateReportCSV(lhr) { // To keep things "official" we follow the CSV specification (RFC4180) // The document describes how to deal with escaping commas and quotes etc. const CRLF = '\r\n'; const separator = ','; /** @param {string} value @return {string} */ const escape = value => `"${value.replace(/"/g, '""')}"`; /** @param {ReadonlyArray<string | number | null>} row @return {string[]} */ const rowFormatter = row => row.map(value => { if (value === null) return 'null'; return value.toString(); }).map(escape); const rows = []; const topLevelKeys = /** @type {const} */( ['requestedUrl', 'finalDisplayedUrl', 'fetchTime', 'gatherMode']); // First we have metadata about the LHR. rows.push(rowFormatter(topLevelKeys)); rows.push(rowFormatter(topLevelKeys.map(key => lhr[key] ?? null))); // Some spacing. rows.push([]); // Categories. rows.push(['category', 'score']); for (const category of Object.values(lhr.categories)) { rows.push(rowFormatter([ category.id, category.score, ])); } rows.push([]); // Audits. rows.push(['category', 'audit', 'score', 'displayValue', 'description']); for (const category of Object.values(lhr.categories)) { for (const auditRef of category.auditRefs) { const audit = lhr.audits[auditRef.id]; if (!audit) continue; rows.push(rowFormatter([ category.id, auditRef.id, audit.score, audit.displayValue || '', audit.description, ])); } } return rows .map(row => row.join(separator)) .join(CRLF); } /** * @param {LHResult|FlowResult} result * @return {result is FlowResult} */ static isFlowResult(result) { return 'steps' in result; } /** * Creates the results output in a format based on the `mode`. * @param {LHResult|FlowResult} result * @param {LHResult['configSettings']['output']} outputModes * @return {string|string[]} */ static generateReport(result, outputModes) { const outputAsArray = Array.isArray(outputModes); if (typeof outputModes === 'string') outputModes = [outputModes]; const output = outputModes.map(outputMode => { // HTML report. if (outputMode === 'html') { if (ReportGenerator.isFlowResult(result)) { return ReportGenerator.generateFlowReportHtml(result); } return ReportGenerator.generateReportHtml(result); } // CSV report. if (outputMode === 'csv') { if (ReportGenerator.isFlowResult(result)) { throw new Error('CSV output is not support for user flows'); } return ReportGenerator.generateReportCSV(result); } // JSON report. if (outputMode === 'json') { return JSON.stringify(result, null, 2); } throw new Error('Invalid output mode: ' + outputMode); }); return outputAsArray ? output : output[0]; } } export {ReportGenerator};