lighthouse/report/renderer/report-renderer.js

340 строки
12 KiB
JavaScript

/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* Dummy text for ensuring report robustness: </script> pre$`post %%LIGHTHOUSE_JSON%%
* (this is handled by terser)
*/
/** @typedef {import('./dom.js').DOM} DOM */
import {CategoryRenderer} from './category-renderer.js';
import {DetailsRenderer} from './details-renderer.js';
import {ElementScreenshotRenderer} from './element-screenshot-renderer.js';
import {I18nFormatter} from './i18n-formatter.js';
import {PerformanceCategoryRenderer} from './performance-category-renderer.js';
import {ReportUtils} from './report-utils.js';
import {Globals} from './report-globals.js';
export class ReportRenderer {
/**
* @param {DOM} dom
*/
constructor(dom) {
/** @type {DOM} */
this._dom = dom;
/** @type {LH.Renderer.Options} */
this._opts = {};
}
/**
* @param {LH.Result} lhr
* @param {HTMLElement?} rootEl Report root element containing the report
* @param {LH.Renderer.Options=} opts
* @return {!Element}
*/
renderReport(lhr, rootEl, opts) {
// Allow legacy report rendering API
if (!this._dom.rootEl && rootEl) {
console.warn('Please adopt the new report API in renderer/api.js.');
const closestRoot = rootEl.closest('.lh-root');
if (closestRoot) {
this._dom.rootEl = /** @type {HTMLElement} */ (closestRoot);
} else {
rootEl.classList.add('lh-root', 'lh-vars');
this._dom.rootEl = rootEl;
}
} else if (this._dom.rootEl && rootEl) {
// Handle legacy flow-report case
this._dom.rootEl = rootEl;
}
if (opts) {
this._opts = opts;
}
this._dom.setLighthouseChannel(lhr.configSettings.channel || 'unknown');
const report = ReportUtils.prepareReportResult(lhr);
this._dom.rootEl.textContent = ''; // Remove previous report.
this._dom.rootEl.append(this._renderReport(report));
if (this._opts.occupyEntireViewport) {
this._dom.rootEl.classList.add('lh-max-viewport');
}
return this._dom.rootEl;
}
/**
* @param {LH.ReportResult} report
* @return {DocumentFragment}
*/
_renderReportTopbar(report) {
const el = this._dom.createComponent('topbar');
const metadataUrl = this._dom.find('a.lh-topbar__url', el);
metadataUrl.textContent = report.finalDisplayedUrl;
metadataUrl.title = report.finalDisplayedUrl;
this._dom.safelySetHref(metadataUrl, report.finalDisplayedUrl);
return el;
}
/**
* @return {DocumentFragment}
*/
_renderReportHeader() {
const el = this._dom.createComponent('heading');
const domFragment = this._dom.createComponent('scoresWrapper');
const placeholder = this._dom.find('.lh-scores-wrapper-placeholder', el);
placeholder.replaceWith(domFragment);
return el;
}
/**
* @param {LH.ReportResult} report
* @return {DocumentFragment}
*/
_renderReportFooter(report) {
const footer = this._dom.createComponent('footer');
this._renderMetaBlock(report, footer);
this._dom.find('.lh-footer__version_issue', footer).textContent = Globals.strings.footerIssue;
this._dom.find('.lh-footer__version', footer).textContent = report.lighthouseVersion;
return footer;
}
/**
* @param {LH.ReportResult} report
* @param {DocumentFragment} footer
*/
_renderMetaBlock(report, footer) {
const envValues = ReportUtils.getEmulationDescriptions(report.configSettings || {});
const match = report.userAgent.match(/(\w*Chrome\/[\d.]+)/); // \w* to include 'HeadlessChrome'
const chromeVer = Array.isArray(match)
? match[1].replace('/', ' ').replace('Chrome', 'Chromium')
: 'Chromium';
const channel = report.configSettings.channel;
const benchmarkIndex = report.environment.benchmarkIndex.toFixed(0);
const axeVersion = report.environment.credits?.['axe-core'];
const devicesTooltipTextLines = [
`${Globals.strings.runtimeSettingsBenchmark}: ${benchmarkIndex}`,
`${Globals.strings.runtimeSettingsCPUThrottling}: ${envValues.cpuThrottling}`,
];
if (envValues.screenEmulation) {
devicesTooltipTextLines.push(
`${Globals.strings.runtimeSettingsScreenEmulation}: ${envValues.screenEmulation}`);
}
if (axeVersion) {
devicesTooltipTextLines.push(`${Globals.strings.runtimeSettingsAxeVersion}: ${axeVersion}`);
}
let stopwatchLabel = Globals.strings.runtimeAnalysisWindow;
if (report.gatherMode === 'timespan') {
stopwatchLabel = Globals.strings.runtimeAnalysisWindowTimespan;
} else if (report.gatherMode === 'snapshot') {
stopwatchLabel = Globals.strings.runtimeAnalysisWindowSnapshot;
}
// [CSS icon class, textContent, tooltipText]
const metaItems = [
['date',
`Captured at ${Globals.i18n.formatDateTime(report.fetchTime)}`],
['devices',
`${envValues.deviceEmulation} with Lighthouse ${report.lighthouseVersion}`,
devicesTooltipTextLines.join('\n')],
['samples-one',
Globals.strings.runtimeSingleLoad,
Globals.strings.runtimeSingleLoadTooltip],
['stopwatch',
stopwatchLabel],
['networkspeed',
`${envValues.summary}`,
`${Globals.strings.runtimeSettingsNetworkThrottling}: ${envValues.networkThrottling}`],
['chrome',
`Using ${chromeVer}` + (channel ? ` with ${channel}` : ''),
`${Globals.strings.runtimeSettingsUANetwork}: "${report.environment.networkUserAgent}"`],
];
const metaItemsEl = this._dom.find('.lh-meta__items', footer);
for (const [iconname, text, tooltip] of metaItems) {
const itemEl = this._dom.createChildOf(metaItemsEl, 'li', 'lh-meta__item');
itemEl.textContent = text;
if (tooltip) {
itemEl.classList.add('lh-tooltip-boundary');
const tooltipEl = this._dom.createChildOf(itemEl, 'div', 'lh-tooltip');
tooltipEl.textContent = tooltip;
}
itemEl.classList.add('lh-report-icon', `lh-report-icon--${iconname}`);
}
}
/**
* Returns a div with a list of top-level warnings, or an empty div if no warnings.
* @param {LH.ReportResult} report
* @return {Node}
*/
_renderReportWarnings(report) {
if (!report.runWarnings || report.runWarnings.length === 0) {
return this._dom.createElement('div');
}
const container = this._dom.createComponent('warningsToplevel');
const message = this._dom.find('.lh-warnings__msg', container);
message.textContent = Globals.strings.toplevelWarningsMessage;
const warnings = [];
for (const warningString of report.runWarnings) {
const warning = this._dom.createElement('li');
warning.append(this._dom.convertMarkdownLinkSnippets(warningString));
warnings.push(warning);
}
this._dom.find('ul', container).append(...warnings);
return container;
}
/**
* @param {LH.ReportResult} report
* @param {CategoryRenderer} categoryRenderer
* @param {Record<string, CategoryRenderer>} specificCategoryRenderers
* @return {!DocumentFragment[]}
*/
_renderScoreGauges(report, categoryRenderer, specificCategoryRenderers) {
// Group gauges in this order: default, plugins.
const defaultGauges = [];
const pluginGauges = [];
for (const category of Object.values(report.categories)) {
const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
const categoryGauge = renderer.renderCategoryScore(
category,
report.categoryGroups || {},
{gatherMode: report.gatherMode}
);
const gaugeWrapperEl = this._dom.find('a.lh-gauge__wrapper, a.lh-fraction__wrapper',
categoryGauge);
if (gaugeWrapperEl) {
this._dom.safelySetHref(gaugeWrapperEl, `#${category.id}`);
// Handle navigation clicks by scrolling to target without changing the page's URL.
// Why? Some report embedding clients have their own routing and updating the location.hash
// can introduce problems. Others may have an unpredictable `<base>` URL which ensures
// navigation to `${baseURL}#categoryid` will be unintended.
gaugeWrapperEl.addEventListener('click', e => {
if (!gaugeWrapperEl.matches('[href^="#"]')) return;
const selector = gaugeWrapperEl.getAttribute('href');
const reportRoot = this._dom.rootEl;
if (!selector || !reportRoot) return;
const destEl = this._dom.find(selector, reportRoot);
e.preventDefault();
destEl.scrollIntoView();
});
this._opts.onPageAnchorRendered?.(gaugeWrapperEl);
}
if (ReportUtils.isPluginCategory(category.id)) {
pluginGauges.push(categoryGauge);
} else {
defaultGauges.push(categoryGauge);
}
}
return [...defaultGauges, ...pluginGauges];
}
/**
* @param {LH.ReportResult} report
* @return {!DocumentFragment}
*/
_renderReport(report) {
Globals.apply({
providedStrings: report.i18n.rendererFormattedStrings,
i18n: new I18nFormatter(report.configSettings.locale),
reportJson: report,
});
const detailsRenderer = new DetailsRenderer(this._dom, {
fullPageScreenshot: report.fullPageScreenshot ?? undefined,
entities: report.entities,
});
const categoryRenderer = new CategoryRenderer(this._dom, detailsRenderer);
/** @type {Record<string, CategoryRenderer>} */
const specificCategoryRenderers = {
performance: new PerformanceCategoryRenderer(this._dom, detailsRenderer),
};
const headerContainer = this._dom.createElement('div');
headerContainer.append(this._renderReportHeader());
const reportContainer = this._dom.createElement('div', 'lh-container');
const reportSection = this._dom.createElement('div', 'lh-report');
reportSection.append(this._renderReportWarnings(report));
let scoreHeader;
const isSoloCategory = Object.keys(report.categories).length === 1;
if (!isSoloCategory) {
scoreHeader = this._dom.createElement('div', 'lh-scores-header');
} else {
headerContainer.classList.add('lh-header--solo-category');
}
const scoreScale = this._dom.createElement('div');
scoreScale.classList.add('lh-scorescale-wrap');
scoreScale.append(this._dom.createComponent('scorescale'));
if (scoreHeader) {
const scoresContainer = this._dom.find('.lh-scores-container', headerContainer);
scoreHeader.append(
...this._renderScoreGauges(report, categoryRenderer, specificCategoryRenderers));
scoresContainer.append(scoreHeader, scoreScale);
const stickyHeader = this._dom.createElement('div', 'lh-sticky-header');
stickyHeader.append(
...this._renderScoreGauges(report, categoryRenderer, specificCategoryRenderers));
reportContainer.append(stickyHeader);
}
const categories = this._dom.createElement('div', 'lh-categories');
reportSection.append(categories);
const categoryOptions = {gatherMode: report.gatherMode};
for (const category of Object.values(report.categories)) {
const renderer = specificCategoryRenderers[category.id] || categoryRenderer;
// .lh-category-wrapper is full-width and provides horizontal rules between categories.
// .lh-category within has the max-width: var(--report-content-max-width);
const wrapper = renderer.dom.createChildOf(categories, 'div', 'lh-category-wrapper');
wrapper.append(renderer.render(
category,
report.categoryGroups,
categoryOptions
));
}
categoryRenderer.injectFinalScreenshot(categories, report.audits, scoreScale);
const reportFragment = this._dom.createFragment();
if (!this._opts.omitGlobalStyles) {
reportFragment.append(this._dom.createComponent('styles'));
}
if (!this._opts.omitTopbar) {
reportFragment.append(this._renderReportTopbar(report));
}
reportFragment.append(reportContainer);
reportSection.append(this._renderReportFooter(report));
reportContainer.append(headerContainer, reportSection);
if (report.fullPageScreenshot) {
ElementScreenshotRenderer.installFullPageScreenshot(
this._dom.rootEl, report.fullPageScreenshot.screenshot);
}
return reportFragment;
}
}