UNPKG

lighthouse

Version:

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

574 lines (494 loc) 21 kB
/** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** @typedef {import('./dom.js').DOM} DOM */ /** @typedef {import('./report-renderer.js').ReportRenderer} ReportRenderer */ /** @typedef {import('./details-renderer.js').DetailsRenderer} DetailsRenderer */ /** @typedef {'failed'|'warning'|'manual'|'passed'|'notApplicable'} TopLevelClumpId */ import {ReportUtils} from './report-utils.js'; import {Globals} from './report-globals.js'; export class CategoryRenderer { /** * @param {DOM} dom * @param {DetailsRenderer} detailsRenderer */ constructor(dom, detailsRenderer) { /** @type {DOM} */ this.dom = dom; /** @type {DetailsRenderer} */ this.detailsRenderer = detailsRenderer; } /** * Display info per top-level clump. Define on class to avoid race with Util init. */ get _clumpTitles() { return { warning: Globals.strings.warningAuditsGroupTitle, manual: Globals.strings.manualAuditsGroupTitle, passed: Globals.strings.passedAuditsGroupTitle, notApplicable: Globals.strings.notApplicableAuditsGroupTitle, }; } /** * @param {LH.ReportResult.AuditRef} audit * @return {HTMLElement} */ renderAudit(audit) { const strings = Globals.strings; const component = this.dom.createComponent('audit'); const auditEl = this.dom.find('div.lh-audit', component); auditEl.id = audit.result.id; const scoreDisplayMode = audit.result.scoreDisplayMode; if (audit.result.displayValue) { this.dom.find('.lh-audit__display-text', auditEl).textContent = audit.result.displayValue; } const titleEl = this.dom.find('.lh-audit__title', auditEl); titleEl.append(this.dom.convertMarkdownCodeSnippets(audit.result.title)); const descEl = this.dom.find('.lh-audit__description', auditEl); descEl.append(this.dom.convertMarkdownLinkSnippets(audit.result.description)); for (const relevantMetric of audit.relevantMetrics || []) { const adornEl = this.dom.createChildOf(descEl, 'span', 'lh-audit__adorn'); adornEl.title = `Relevant to ${relevantMetric.result.title}`; adornEl.textContent = relevantMetric.acronym || relevantMetric.id; } if (audit.stackPacks) { audit.stackPacks.forEach(pack => { const packElmImg = this.dom.createElement('img', 'lh-audit__stackpack__img'); packElmImg.src = pack.iconDataURL; packElmImg.alt = pack.title; const snippets = this.dom.convertMarkdownLinkSnippets(pack.description, { alwaysAppendUtmSource: true, }); const packElm = this.dom.createElement('div', 'lh-audit__stackpack'); packElm.append(packElmImg, snippets); this.dom.find('.lh-audit__stackpacks', auditEl) .append(packElm); }); } const header = this.dom.find('details', auditEl); if (audit.result.details) { const elem = this.detailsRenderer.render(audit.result.details); if (elem) { elem.classList.add('lh-details'); header.append(elem); } } // Add chevron SVG to the end of the summary this.dom.find('.lh-chevron-container', auditEl).append(this._createChevron()); this._setRatingClass(auditEl, audit.result.score, scoreDisplayMode); if (audit.result.scoreDisplayMode === 'error') { auditEl.classList.add(`lh-audit--error`); const textEl = this.dom.find('.lh-audit__display-text', auditEl); textEl.textContent = strings.errorLabel; textEl.classList.add('lh-tooltip-boundary'); const tooltip = this.dom.createChildOf(textEl, 'div', 'lh-tooltip lh-tooltip--error'); tooltip.textContent = audit.result.errorMessage || strings.errorMissingAuditInfo; } else if (audit.result.explanation) { const explEl = this.dom.createChildOf(titleEl, 'div', 'lh-audit-explanation'); explEl.textContent = audit.result.explanation; } const warnings = audit.result.warnings; if (!warnings || warnings.length === 0) return auditEl; // Add list of warnings or singular warning const summaryEl = this.dom.find('summary', header); const warningsEl = this.dom.createChildOf(summaryEl, 'div', 'lh-warnings'); this.dom.createChildOf(warningsEl, 'span').textContent = strings.warningHeader; if (warnings.length === 1) { warningsEl.append(this.dom.createTextNode(warnings.join(''))); } else { const warningsUl = this.dom.createChildOf(warningsEl, 'ul'); for (const warning of warnings) { const item = this.dom.createChildOf(warningsUl, 'li'); item.textContent = warning; } } return auditEl; } /** * Inject the final screenshot next to the score gauge of the first category (likely Performance) * @param {HTMLElement} categoriesEl * @param {LH.ReportResult['audits']} audits * @param {Element} scoreScaleEl */ injectFinalScreenshot(categoriesEl, audits, scoreScaleEl) { const audit = audits['final-screenshot']; if (!audit || audit.scoreDisplayMode === 'error') return null; if (!audit.details || audit.details.type !== 'screenshot') return null; const imgEl = this.dom.createElement('img', 'lh-final-ss-image'); const finalScreenshotDataUri = audit.details.data; imgEl.src = finalScreenshotDataUri; imgEl.alt = audit.title; const firstCatHeaderEl = this.dom.find('.lh-category .lh-category-header', categoriesEl); const leftColEl = this.dom.createElement('div', 'lh-category-headercol'); const separatorEl = this.dom.createElement('div', 'lh-category-headercol lh-category-headercol--separator'); const rightColEl = this.dom.createElement('div', 'lh-category-headercol'); leftColEl.append(...firstCatHeaderEl.childNodes); leftColEl.append(scoreScaleEl); rightColEl.append(imgEl); firstCatHeaderEl.append(leftColEl, separatorEl, rightColEl); firstCatHeaderEl.classList.add('lh-category-header__finalscreenshot'); } /** * @return {Element} */ _createChevron() { const component = this.dom.createComponent('chevron'); const chevronEl = this.dom.find('svg.lh-chevron', component); return chevronEl; } /** * @param {Element} element DOM node to populate with values. * @param {number|null} score * @param {string} scoreDisplayMode * @return {!Element} */ _setRatingClass(element, score, scoreDisplayMode) { const rating = ReportUtils.calculateRating(score, scoreDisplayMode); element.classList.add(`lh-audit--${scoreDisplayMode.toLowerCase()}`); if (scoreDisplayMode !== 'informative') { element.classList.add(`lh-audit--${rating}`); } return element; } /** * @param {LH.ReportResult.Category} category * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions * @param {{gatherMode: LH.Result.GatherMode}=} options * @return {DocumentFragment} */ renderCategoryHeader(category, groupDefinitions, options) { const component = this.dom.createComponent('categoryHeader'); const gaugeContainerEl = this.dom.find('.lh-score__gauge', component); const gaugeEl = this.renderCategoryScore(category, groupDefinitions, options); gaugeContainerEl.append(gaugeEl); if (category.description) { const descEl = this.dom.convertMarkdownLinkSnippets(category.description); this.dom.find('.lh-category-header__description', component).append(descEl); } return component; } /** * Renders the group container for a group of audits. Individual audit elements can be added * directly to the returned element. * @param {LH.Result.ReportGroup} group * @return {[Element, Element | null]} */ renderAuditGroup(group) { const groupEl = this.dom.createElement('div', 'lh-audit-group'); const auditGroupHeader = this.dom.createElement('div', 'lh-audit-group__header'); this.dom.createChildOf(auditGroupHeader, 'span', 'lh-audit-group__title') .textContent = group.title; groupEl.append(auditGroupHeader); let footerEl = null; if (group.description) { footerEl = this.dom.convertMarkdownLinkSnippets(group.description); footerEl.classList.add('lh-audit-group__description', 'lh-audit-group__footer'); groupEl.append(footerEl); } return [groupEl, footerEl]; } /** * Takes an array of auditRefs, groups them if requested, then returns an * array of audit and audit-group elements. * @param {Array<LH.ReportResult.AuditRef>} auditRefs * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions * @return {Array<Element>} */ _renderGroupedAudits(auditRefs, groupDefinitions) { // Audits grouped by their group (or under notAGroup). /** @type {Map<string, Array<LH.ReportResult.AuditRef>>} */ const grouped = new Map(); // Add audits without a group first so they will appear first. const notAGroup = 'NotAGroup'; grouped.set(notAGroup, []); for (const auditRef of auditRefs) { const groupId = auditRef.group || notAGroup; const groupAuditRefs = grouped.get(groupId) || []; groupAuditRefs.push(auditRef); grouped.set(groupId, groupAuditRefs); } /** @type {Array<Element>} */ const auditElements = []; for (const [groupId, groupAuditRefs] of grouped) { if (groupId === notAGroup) { // Push not-grouped audits individually. for (const auditRef of groupAuditRefs) { auditElements.push(this.renderAudit(auditRef)); } continue; } // Push grouped audits as a group. const groupDef = groupDefinitions[groupId]; const [auditGroupElem, auditGroupFooterEl] = this.renderAuditGroup(groupDef); for (const auditRef of groupAuditRefs) { auditGroupElem.insertBefore(this.renderAudit(auditRef), auditGroupFooterEl); } auditGroupElem.classList.add(`lh-audit-group--${groupId}`); auditElements.push(auditGroupElem); } return auditElements; } /** * Take a set of audits, group them if they have groups, then render in a top-level * clump that can't be expanded/collapsed. * @param {Array<LH.ReportResult.AuditRef>} auditRefs * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions * @return {Element} */ renderUnexpandableClump(auditRefs, groupDefinitions) { const clumpElement = this.dom.createElement('div'); const elements = this._renderGroupedAudits(auditRefs, groupDefinitions); elements.forEach(elem => clumpElement.append(elem)); return clumpElement; } /** * Take a set of audits and render in a top-level, expandable clump that starts * in a collapsed state. * @param {Exclude<TopLevelClumpId, 'failed'>} clumpId * @param {{auditRefsOrEls: Array<LH.ReportResult.AuditRef | HTMLElement>, description?: string, openByDefault?: boolean}} clumpOpts * @return {!Element} */ renderClump(clumpId, {auditRefsOrEls, description, openByDefault}) { const clumpComponent = this.dom.createComponent('clump'); const clumpElement = this.dom.find('.lh-clump', clumpComponent); if (openByDefault) { clumpElement.setAttribute('open', ''); } const headerEl = this.dom.find('.lh-audit-group__header', clumpElement); const title = this._clumpTitles[clumpId]; this.dom.find('.lh-audit-group__title', headerEl).textContent = title; const itemCountEl = this.dom.find('.lh-audit-group__itemcount', clumpElement); itemCountEl.textContent = `(${auditRefsOrEls.length})`; // Add all audit results to the clump. const auditElements = auditRefsOrEls.map(audit => { if (audit instanceof HTMLElement) { return audit; } else { return this.renderAudit(audit); } }); clumpElement.append(...auditElements); const el = this.dom.find('.lh-audit-group', clumpComponent); if (description) { const descriptionEl = this.dom.convertMarkdownLinkSnippets(description); descriptionEl.classList.add('lh-audit-group__description', 'lh-audit-group__footer'); el.append(descriptionEl); } this.dom.find('.lh-clump-toggletext--show', el).textContent = Globals.strings.show; this.dom.find('.lh-clump-toggletext--hide', el).textContent = Globals.strings.hide; clumpElement.classList.add(`lh-clump--${clumpId.toLowerCase()}`); return el; } /** * @param {LH.ReportResult.Category} category * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions * @param {{gatherMode: LH.Result.GatherMode, omitLabel?: boolean, onPageAnchorRendered?: (link: HTMLAnchorElement) => void}=} options * @return {DocumentFragment} */ renderCategoryScore(category, groupDefinitions, options) { let categoryScore; if (options && ReportUtils.shouldDisplayAsFraction(options.gatherMode)) { categoryScore = this.renderCategoryFraction(category); } else { categoryScore = this.renderScoreGauge(category, groupDefinitions); } if (options?.omitLabel) { const label = this.dom.find('.lh-gauge__label,.lh-fraction__label', categoryScore); label.remove(); } if (options?.onPageAnchorRendered) { const anchor = this.dom.find('a', categoryScore); options.onPageAnchorRendered(anchor); } return categoryScore; } /** * @param {LH.ReportResult.Category} category * @param {Record<string, LH.Result.ReportGroup>} groupDefinitions * @return {DocumentFragment} */ renderScoreGauge(category, groupDefinitions) { // eslint-disable-line no-unused-vars const tmpl = this.dom.createComponent('gauge'); const wrapper = this.dom.find('a.lh-gauge__wrapper', tmpl); if (ReportUtils.isPluginCategory(category.id)) { wrapper.classList.add('lh-gauge__wrapper--plugin'); } // Cast `null` to 0 const numericScore = Number(category.score); const gauge = this.dom.find('.lh-gauge', tmpl); const gaugeArc = this.dom.find('circle.lh-gauge-arc', gauge); if (gaugeArc) this._setGaugeArc(gaugeArc, numericScore); const scoreOutOf100 = Math.round(numericScore * 100); const percentageEl = this.dom.find('div.lh-gauge__percentage', tmpl); percentageEl.textContent = scoreOutOf100.toString(); if (category.score === null) { percentageEl.classList.add('lh-gauge--error'); percentageEl.textContent = ''; percentageEl.title = Globals.strings.errorLabel; } // Render a numerical score if the category has applicable audits, or no audits whatsoever. if (category.auditRefs.length === 0 || this.hasApplicableAudits(category)) { wrapper.classList.add(`lh-gauge__wrapper--${ReportUtils.calculateRating(category.score)}`); } else { wrapper.classList.add(`lh-gauge__wrapper--not-applicable`); percentageEl.textContent = '-'; percentageEl.title = Globals.strings.notApplicableAuditsGroupTitle; } this.dom.find('.lh-gauge__label', tmpl).textContent = category.title; return tmpl; } /** * @param {LH.ReportResult.Category} category * @return {DocumentFragment} */ renderCategoryFraction(category) { const tmpl = this.dom.createComponent('fraction'); const wrapper = this.dom.find('a.lh-fraction__wrapper', tmpl); const {numPassed, numPassableAudits, totalWeight} = ReportUtils.calculateCategoryFraction(category); const fraction = numPassed / numPassableAudits; const content = this.dom.find('.lh-fraction__content', tmpl); const text = this.dom.createElement('span'); text.textContent = `${numPassed}/${numPassableAudits}`; content.append(text); let rating = ReportUtils.calculateRating(fraction); // If none of the available audits can affect the score, a rating isn't useful. // The flow report should display the fraction with neutral icon and coloring in this case. if (totalWeight === 0) { rating = 'null'; } wrapper.classList.add(`lh-fraction__wrapper--${rating}`); this.dom.find('.lh-fraction__label', tmpl).textContent = category.title; return tmpl; } /** * Returns true if an LH category has any non-"notApplicable" audits. * @param {LH.ReportResult.Category} category * @return {boolean} */ hasApplicableAudits(category) { return category.auditRefs.some(ref => ref.result.scoreDisplayMode !== 'notApplicable'); } /** * Define the score arc of the gauge * Credit to xgad for the original technique: https://codepen.io/xgad/post/svg-radial-progress-meters * @param {SVGCircleElement} arcElem * @param {number} percent */ _setGaugeArc(arcElem, percent) { const circumferencePx = 2 * Math.PI * Number(arcElem.getAttribute('r')); // The rounded linecap of the stroke extends the arc past its start and end. // First, we tweak the -90deg rotation to start exactly at the top of the circle. const strokeWidthPx = Number(arcElem.getAttribute('stroke-width')); const rotationalAdjustmentPercent = 0.25 * strokeWidthPx / circumferencePx; arcElem.style.transform = `rotate(${-90 + rotationalAdjustmentPercent * 360}deg)`; // Then, we terminate the line a little early as well. let arcLengthPx = percent * circumferencePx - strokeWidthPx / 2; // Special cases. No dot for 0, and full ring if 100 if (percent === 0) arcElem.style.opacity = '0'; if (percent === 1) arcLengthPx = circumferencePx; arcElem.style.strokeDasharray = `${Math.max(arcLengthPx, 0)} ${circumferencePx}`; } /** * @param {LH.ReportResult.AuditRef} audit * @return {boolean} */ _auditHasWarning(audit) { return Boolean(audit.result.warnings?.length); } /** * Returns the id of the top-level clump to put this audit in. * @param {LH.ReportResult.AuditRef} auditRef * @return {TopLevelClumpId} */ _getClumpIdForAuditRef(auditRef) { const scoreDisplayMode = auditRef.result.scoreDisplayMode; if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'notApplicable') { return scoreDisplayMode; } if (ReportUtils.showAsPassed(auditRef.result)) { if (this._auditHasWarning(auditRef)) { return 'warning'; } else { return 'passed'; } } else { return 'failed'; } } /** * Renders a set of top level sections (clumps), under a status of failed, warning, * manual, passed, or notApplicable. The result ends up something like: * * failed clump * ├── audit 1 (w/o group) * ├── audit 2 (w/o group) * ├── audit group * | ├── audit 3 * | └── audit 4 * └── audit group * ├── audit 5 * └── audit 6 * other clump (e.g. 'manual') * ├── audit 1 * ├── audit 2 * ├── … * ⋮ * @param {LH.ReportResult.Category} category * @param {Object<string, LH.Result.ReportGroup>=} groupDefinitions * @param {{gatherMode: LH.Result.GatherMode}=} options * @return {Element} */ render(category, groupDefinitions = {}, options) { const element = this.dom.createElement('div', 'lh-category'); element.id = category.id; element.append(this.renderCategoryHeader(category, groupDefinitions, options)); // Top level clumps for audits, in order they will appear in the report. /** @type {Map<TopLevelClumpId, Array<LH.ReportResult.AuditRef>>} */ const clumps = new Map(); clumps.set('failed', []); clumps.set('warning', []); clumps.set('manual', []); clumps.set('passed', []); clumps.set('notApplicable', []); // Sort audits into clumps. for (const auditRef of category.auditRefs) { if (auditRef.group === 'hidden') continue; const clumpId = this._getClumpIdForAuditRef(auditRef); const clump = /** @type {Array<LH.ReportResult.AuditRef>} */ (clumps.get(clumpId)); // already defined clump.push(auditRef); clumps.set(clumpId, clump); } // Sort audits by weight. for (const auditRefs of clumps.values()) { auditRefs.sort((a, b) => { return b.weight - a.weight; }); } const numFailingAudits = clumps.get('failed')?.length; // Render each clump. for (const [clumpId, auditRefs] of clumps) { if (auditRefs.length === 0) continue; if (clumpId === 'failed') { const clumpElem = this.renderUnexpandableClump(auditRefs, groupDefinitions); clumpElem.classList.add(`lh-clump--failed`); element.append(clumpElem); continue; } const description = clumpId === 'manual' ? category.manualDescription : undefined; // Expand on warning, or manual audits when there are no failing audits. const openByDefault = clumpId === 'warning' || (clumpId === 'manual' && numFailingAudits === 0); const clumpElem = this.renderClump(clumpId, { auditRefsOrEls: auditRefs, description, openByDefault, }); element.append(clumpElem); } return element; } }