Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
728 lines
24 KiB
Text
728 lines
24 KiB
Text
/**
|
|
* @license
|
|
* Copyright 2017 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/** @typedef {import('./dom.js').DOM} DOM */
|
|
|
|
// Convenience types for localized AuditDetails.
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details>} AuditDetails */
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details.Opportunity>} OpportunityTable */
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details.Table>} Table */
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details.TableItem>} TableItem */
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details.ItemValue>} TableItemValue */
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details.TableColumnHeading>} TableColumnHeading */
|
|
/** @typedef {LH.FormattedIcu<LH.Audit.Details.Table | LH.Audit.Details.Opportunity>} TableLike */
|
|
|
|
import {Util} from '../../shared/util.js';
|
|
import {CriticalRequestChainRenderer} from './crc-details-renderer.js';
|
|
import {ElementScreenshotRenderer} from './element-screenshot-renderer.js';
|
|
import {Globals} from './report-globals.js';
|
|
import {ReportUtils} from './report-utils.js';
|
|
|
|
const URL_PREFIXES = ['http://', 'https://', 'data:'];
|
|
const SUMMABLE_VALUETYPES = ['bytes', 'numeric', 'ms', 'timespanMs'];
|
|
|
|
export class DetailsRenderer {
|
|
/**
|
|
* @param {DOM} dom
|
|
* @param {{fullPageScreenshot?: LH.Result.FullPageScreenshot, entities?: LH.Result.Entities}} [options]
|
|
*/
|
|
constructor(dom, options = {}) {
|
|
this._dom = dom;
|
|
this._fullPageScreenshot = options.fullPageScreenshot;
|
|
this._entities = options.entities;
|
|
}
|
|
|
|
/**
|
|
* @param {AuditDetails} details
|
|
* @return {Element|null}
|
|
*/
|
|
render(details) {
|
|
switch (details.type) {
|
|
case 'filmstrip':
|
|
return this._renderFilmstrip(details);
|
|
case 'list':
|
|
return this._renderList(details);
|
|
case 'checklist':
|
|
return this._renderChecklist(details);
|
|
case 'table':
|
|
case 'opportunity':
|
|
return this._renderTable(details);
|
|
case 'network-tree':
|
|
case 'criticalrequestchain':
|
|
return CriticalRequestChainRenderer.render(this._dom, details, this);
|
|
|
|
// Internal-only details, not for rendering.
|
|
case 'screenshot':
|
|
case 'debugdata':
|
|
case 'treemap-data':
|
|
return null;
|
|
|
|
default: {
|
|
// @ts-expect-error - all detail types need to be handled above so tsc thinks this is unreachable.
|
|
// Call _renderUnknown() to be forward compatible with new, unexpected detail types.
|
|
return this._renderUnknown(details.type, details);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{value: number, granularity?: number}} details
|
|
* @return {Element}
|
|
*/
|
|
_renderBytes(details) {
|
|
// TODO: handle displayUnit once we have something other than 'KiB'
|
|
const value = Globals.i18n.formatBytesToKiB(details.value, details.granularity || 0.1);
|
|
const textEl = this._renderText(value);
|
|
textEl.title = Globals.i18n.formatBytes(details.value);
|
|
return textEl;
|
|
}
|
|
|
|
/**
|
|
* @param {{value: number, granularity?: number, displayUnit?: string}} details
|
|
* @return {Element}
|
|
*/
|
|
_renderMilliseconds(details) {
|
|
let value;
|
|
if (details.displayUnit === 'duration') {
|
|
value = Globals.i18n.formatDuration(details.value);
|
|
} else {
|
|
value = Globals.i18n.formatMilliseconds(details.value, details.granularity || 10);
|
|
}
|
|
|
|
return this._renderText(value);
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @return {HTMLElement}
|
|
*/
|
|
renderTextURL(text) {
|
|
const url = text;
|
|
|
|
let displayedPath;
|
|
let displayedHost;
|
|
let title;
|
|
try {
|
|
const parsed = Util.parseURL(url);
|
|
displayedPath = parsed.file === '/' ? parsed.origin : parsed.file;
|
|
displayedHost = parsed.file === '/' || parsed.hostname === '' ? '' : `(${parsed.hostname})`;
|
|
title = url;
|
|
} catch (e) {
|
|
displayedPath = url;
|
|
}
|
|
|
|
const element = this._dom.createElement('div', 'lh-text__url');
|
|
element.append(this._renderLink({text: displayedPath, url}));
|
|
|
|
if (displayedHost) {
|
|
const hostElem = this._renderText(displayedHost);
|
|
hostElem.classList.add('lh-text__url-host');
|
|
element.append(hostElem);
|
|
}
|
|
|
|
if (title) {
|
|
element.title = url;
|
|
// set the url on the element's dataset which we use to check 3rd party origins
|
|
element.dataset.url = url;
|
|
}
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @param {{text: string, url: string}} details
|
|
* @return {HTMLElement}
|
|
*/
|
|
_renderLink(details) {
|
|
const a = this._dom.createElement('a');
|
|
this._dom.safelySetHref(a, details.url);
|
|
|
|
if (!a.href) {
|
|
// Fall back to just the link text if invalid or protocol not allowed.
|
|
const element = this._renderText(details.text);
|
|
element.classList.add('lh-link');
|
|
return element;
|
|
}
|
|
|
|
a.rel = 'noopener';
|
|
a.target = '_blank';
|
|
a.textContent = details.text;
|
|
a.classList.add('lh-link');
|
|
return a;
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @return {HTMLDivElement}
|
|
*/
|
|
_renderText(text) {
|
|
const element = this._dom.createElement('div', 'lh-text');
|
|
element.textContent = text;
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @param {{value: number, granularity?: number}} details
|
|
* @return {Element}
|
|
*/
|
|
_renderNumeric(details) {
|
|
const value = Globals.i18n.formatNumber(details.value, details.granularity || 0.1);
|
|
const element = this._dom.createElement('div', 'lh-numeric');
|
|
element.textContent = value;
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Create small thumbnail with scaled down image asset.
|
|
* @param {string} details
|
|
* @return {Element}
|
|
*/
|
|
_renderThumbnail(details) {
|
|
const element = this._dom.createElement('img', 'lh-thumbnail');
|
|
const strValue = details;
|
|
element.src = strValue;
|
|
element.title = strValue;
|
|
element.alt = '';
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @param {string} type
|
|
* @param {*} value
|
|
*/
|
|
_renderUnknown(type, value) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`Unknown details type: ${type}`, value);
|
|
const element = this._dom.createElement('details', 'lh-unknown');
|
|
this._dom.createChildOf(element, 'summary').textContent =
|
|
`We don't know how to render audit details of type \`${type}\`. ` +
|
|
'The Lighthouse version that collected this data is likely newer than the Lighthouse ' +
|
|
'version of the report renderer. Expand for the raw JSON.';
|
|
this._dom.createChildOf(element, 'pre').textContent = JSON.stringify(value, null, 2);
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Render a details item value for embedding in a table. Renders the value
|
|
* based on the heading's valueType, unless the value itself has a `type`
|
|
* property to override it.
|
|
* @param {TableItemValue} value
|
|
* @param {LH.Audit.Details.TableColumnHeading} heading
|
|
* @return {Element|null}
|
|
*/
|
|
_renderTableValue(value, heading) {
|
|
if (value === undefined || value === null) {
|
|
return null;
|
|
}
|
|
|
|
// First deal with the possible object forms of value.
|
|
if (typeof value === 'object') {
|
|
// The value's type overrides the heading's for this column.
|
|
switch (value.type) {
|
|
case 'code': {
|
|
return this._renderCode(value.value);
|
|
}
|
|
case 'link': {
|
|
return this._renderLink(value);
|
|
}
|
|
case 'node': {
|
|
return this.renderNode(value);
|
|
}
|
|
case 'numeric': {
|
|
return this._renderNumeric(value);
|
|
}
|
|
case 'text': {
|
|
return this._renderText(value.value);
|
|
}
|
|
case 'source-location': {
|
|
return this.renderSourceLocation(value);
|
|
}
|
|
case 'url': {
|
|
return this.renderTextURL(value.value);
|
|
}
|
|
default: {
|
|
return this._renderUnknown(value.type, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Next, deal with primitives.
|
|
switch (heading.valueType) {
|
|
case 'bytes': {
|
|
const numValue = Number(value);
|
|
return this._renderBytes({value: numValue, granularity: heading.granularity});
|
|
}
|
|
case 'code': {
|
|
const strValue = String(value);
|
|
return this._renderCode(strValue);
|
|
}
|
|
case 'ms': {
|
|
const msValue = {
|
|
value: Number(value),
|
|
granularity: heading.granularity,
|
|
displayUnit: heading.displayUnit,
|
|
};
|
|
return this._renderMilliseconds(msValue);
|
|
}
|
|
case 'numeric': {
|
|
const numValue = Number(value);
|
|
return this._renderNumeric({value: numValue, granularity: heading.granularity});
|
|
}
|
|
case 'text': {
|
|
const strValue = String(value);
|
|
return this._renderText(strValue);
|
|
}
|
|
case 'thumbnail': {
|
|
const strValue = String(value);
|
|
return this._renderThumbnail(strValue);
|
|
}
|
|
case 'timespanMs': {
|
|
const numValue = Number(value);
|
|
return this._renderMilliseconds({value: numValue});
|
|
}
|
|
case 'url': {
|
|
const strValue = String(value);
|
|
if (URL_PREFIXES.some(prefix => strValue.startsWith(prefix))) {
|
|
return this.renderTextURL(strValue);
|
|
} else {
|
|
// Fall back to <pre> rendering if not actually a URL.
|
|
return this._renderCode(strValue);
|
|
}
|
|
}
|
|
default: {
|
|
return this._renderUnknown(heading.valueType, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a new heading where the values are defined first by `heading.subItemsHeading`,
|
|
* and secondly by `heading`. If there is no subItemsHeading, returns null, which will
|
|
* be rendered as an empty column.
|
|
* @param {LH.Audit.Details.TableColumnHeading} heading
|
|
* @return {LH.Audit.Details.TableColumnHeading | null}
|
|
*/
|
|
_getDerivedSubItemsHeading(heading) {
|
|
if (!heading.subItemsHeading) return null;
|
|
return {
|
|
key: heading.subItemsHeading.key || '',
|
|
valueType: heading.subItemsHeading.valueType || heading.valueType,
|
|
granularity: heading.subItemsHeading.granularity || heading.granularity,
|
|
displayUnit: heading.subItemsHeading.displayUnit || heading.displayUnit,
|
|
label: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {TableItem} item
|
|
* @param {(LH.Audit.Details.TableColumnHeading | null)[]} headings
|
|
*/
|
|
_renderTableRow(item, headings) {
|
|
const rowElem = this._dom.createElement('tr');
|
|
|
|
for (const heading of headings) {
|
|
// Empty cell if no heading or heading key for this column.
|
|
if (!heading || !heading.key) {
|
|
this._dom.createChildOf(rowElem, 'td', 'lh-table-column--empty');
|
|
continue;
|
|
}
|
|
|
|
const value = item[heading.key];
|
|
let valueElement;
|
|
if (value !== undefined && value !== null) {
|
|
valueElement = this._renderTableValue(value, heading);
|
|
}
|
|
|
|
if (valueElement) {
|
|
const classes = `lh-table-column--${heading.valueType}`;
|
|
this._dom.createChildOf(rowElem, 'td', classes).append(valueElement);
|
|
} else {
|
|
// Empty cell is rendered for a column if:
|
|
// - the pair is null
|
|
// - the heading key is null
|
|
// - the value is undefined/null
|
|
this._dom.createChildOf(rowElem, 'td', 'lh-table-column--empty');
|
|
}
|
|
}
|
|
|
|
return rowElem;
|
|
}
|
|
|
|
/**
|
|
* Renders one or more rows from a details table item. A single table item can
|
|
* expand into multiple rows, if there is a subItemsHeading.
|
|
* @param {TableItem} item
|
|
* @param {LH.Audit.Details.TableColumnHeading[]} headings
|
|
*/
|
|
_renderTableRowsFromItem(item, headings) {
|
|
const fragment = this._dom.createFragment();
|
|
fragment.append(this._renderTableRow(item, headings));
|
|
|
|
if (!item.subItems) return fragment;
|
|
|
|
const subItemsHeadings = headings.map(this._getDerivedSubItemsHeading);
|
|
if (!subItemsHeadings.some(Boolean)) return fragment;
|
|
|
|
for (const subItem of item.subItems.items) {
|
|
const rowEl = this._renderTableRow(subItem, subItemsHeadings);
|
|
rowEl.classList.add('lh-sub-item-row');
|
|
fragment.append(rowEl);
|
|
}
|
|
|
|
return fragment;
|
|
}
|
|
|
|
/**
|
|
* Adorn a table row element with entity chips based on [data-entity] attribute.
|
|
* @param {HTMLTableRowElement} rowEl
|
|
*/
|
|
_adornEntityGroupRow(rowEl) {
|
|
const entityName = rowEl.dataset.entity;
|
|
if (!entityName) return;
|
|
const matchedEntity = this._entities?.find(e => e.name === entityName);
|
|
if (!matchedEntity) return;
|
|
|
|
const firstTdEl = this._dom.find('td', rowEl);
|
|
|
|
if (matchedEntity.category) {
|
|
const categoryChipEl = this._dom.createElement('span');
|
|
categoryChipEl.classList.add('lh-audit__adorn');
|
|
categoryChipEl.textContent = matchedEntity.category;
|
|
firstTdEl.append(' ', categoryChipEl);
|
|
}
|
|
|
|
if (matchedEntity.isFirstParty) {
|
|
const firstPartyChipEl = this._dom.createElement('span');
|
|
firstPartyChipEl.classList.add('lh-audit__adorn', 'lh-audit__adorn1p');
|
|
firstPartyChipEl.textContent = Globals.strings.firstPartyChipLabel;
|
|
firstTdEl.append(' ', firstPartyChipEl);
|
|
}
|
|
|
|
if (matchedEntity.homepage) {
|
|
const entityLinkEl = this._dom.createElement('a');
|
|
entityLinkEl.href = matchedEntity.homepage;
|
|
entityLinkEl.target = '_blank';
|
|
entityLinkEl.title = Globals.strings.openInANewTabTooltip;
|
|
entityLinkEl.classList.add('lh-report-icon--external');
|
|
firstTdEl.append(' ', entityLinkEl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders an entity-grouped row.
|
|
* @param {TableItem} item
|
|
* @param {LH.Audit.Details.TableColumnHeading[]} headings
|
|
*/
|
|
_renderEntityGroupRow(item, headings) {
|
|
const entityColumnHeading = {...headings[0]};
|
|
// In subitem-situations (unused-javascript), ensure Entity name is not rendered as code, etc.
|
|
entityColumnHeading.valueType = 'text';
|
|
const groupedRowHeadings = [entityColumnHeading, ...headings.slice(1)];
|
|
const fragment = this._dom.createFragment();
|
|
fragment.append(this._renderTableRow(item, groupedRowHeadings));
|
|
this._dom.find('tr', fragment).classList.add('lh-row--group');
|
|
return fragment;
|
|
}
|
|
|
|
/**
|
|
* Returns an array of entity-grouped TableItems to use as the top-level rows in
|
|
* an grouped table. Each table item returned represents a unique entity, with every
|
|
* applicable key that can be grouped as a property. Optionally, supported columns are
|
|
* summed by entity, and sorted by specified keys.
|
|
* @param {TableLike} details
|
|
* @return {TableItem[]}
|
|
*/
|
|
_getEntityGroupItems(details) {
|
|
const {items, headings, sortedBy} = details;
|
|
// Exclude entity-grouped audits and results without entity classification.
|
|
// Eg. Third-party Summary comes entity-grouped.
|
|
if (!items.length || details.isEntityGrouped || !items.some(item => item.entity)) {
|
|
return [];
|
|
}
|
|
|
|
const skippedColumns = new Set(details.skipSumming || []);
|
|
/** @type {string[]} */
|
|
const summableColumns = [];
|
|
for (const heading of headings) {
|
|
if (!heading.key || skippedColumns.has(heading.key)) continue;
|
|
if (SUMMABLE_VALUETYPES.includes(heading.valueType)) {
|
|
summableColumns.push(heading.key);
|
|
}
|
|
}
|
|
|
|
// Grab the first column's key to group by entity
|
|
const firstColumnKey = headings[0].key;
|
|
if (!firstColumnKey) return [];
|
|
|
|
/** @type {Map<string | undefined, TableItem>} */
|
|
const byEntity = new Map();
|
|
for (const item of items) {
|
|
const entityName = typeof item.entity === 'string' ? item.entity : undefined;
|
|
const groupedItem = byEntity.get(entityName) || {
|
|
[firstColumnKey]: entityName || Globals.strings.unattributable,
|
|
entity: entityName,
|
|
};
|
|
for (const key of summableColumns) {
|
|
groupedItem[key] = Number(groupedItem[key] || 0) + Number(item[key] || 0);
|
|
}
|
|
byEntity.set(entityName, groupedItem);
|
|
}
|
|
|
|
const result = [...byEntity.values()];
|
|
if (sortedBy) {
|
|
result.sort(ReportUtils.getTableItemSortComparator(sortedBy));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {TableLike} details
|
|
* @return {Element}
|
|
*/
|
|
_renderTable(details) {
|
|
if (!details.items.length) return this._dom.createElement('span');
|
|
|
|
const tableElem = this._dom.createElement('table', 'lh-table');
|
|
const theadElem = this._dom.createChildOf(tableElem, 'thead');
|
|
const theadTrElem = this._dom.createChildOf(theadElem, 'tr');
|
|
|
|
for (const heading of details.headings) {
|
|
const valueType = heading.valueType || 'text';
|
|
const classes = `lh-table-column--${valueType}`;
|
|
const labelEl = this._dom.createElement('div', 'lh-text');
|
|
labelEl.textContent = heading.label;
|
|
this._dom.createChildOf(theadTrElem, 'th', classes).append(labelEl);
|
|
}
|
|
|
|
const entityItems = this._getEntityGroupItems(details);
|
|
const tbodyElem = this._dom.createChildOf(tableElem, 'tbody');
|
|
if (entityItems.length) {
|
|
for (const entityItem of entityItems) {
|
|
const entityName = typeof entityItem.entity === 'string' ? entityItem.entity : undefined;
|
|
const entityGroupFragment = this._renderEntityGroupRow(entityItem, details.headings);
|
|
// Render all the items that match the heading row
|
|
for (const item of details.items.filter((item) => item.entity === entityName)) {
|
|
entityGroupFragment.append(this._renderTableRowsFromItem(item, details.headings));
|
|
}
|
|
const rowEls = this._dom.findAll('tr', entityGroupFragment);
|
|
if (entityName && rowEls.length) {
|
|
rowEls.forEach(row => row.dataset.entity = entityName);
|
|
this._adornEntityGroupRow(rowEls[0]);
|
|
}
|
|
tbodyElem.append(entityGroupFragment);
|
|
}
|
|
} else {
|
|
let even = true;
|
|
for (const item of details.items) {
|
|
const rowsFragment = this._renderTableRowsFromItem(item, details.headings);
|
|
const rowEls = this._dom.findAll('tr', rowsFragment);
|
|
const firstRowEl = rowEls[0];
|
|
if (typeof item.entity === 'string') {
|
|
firstRowEl.dataset.entity = item.entity;
|
|
}
|
|
if (details.isEntityGrouped && item.entity) {
|
|
// If the audit is already grouped, consider first row as a heading row.
|
|
firstRowEl.classList.add('lh-row--group');
|
|
this._adornEntityGroupRow(firstRowEl);
|
|
} else {
|
|
for (const rowEl of rowEls) {
|
|
// For zebra styling (same shade for a row and its sub-rows).
|
|
rowEl.classList.add(even ? 'lh-row--even' : 'lh-row--odd');
|
|
}
|
|
}
|
|
even = !even;
|
|
tbodyElem.append(rowsFragment);
|
|
}
|
|
}
|
|
|
|
return tableElem;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.FormattedIcu<LH.Audit.Details.ListableDetail>} item
|
|
* @return {Element | null}
|
|
*/
|
|
_renderListValue(item) {
|
|
if (item.type === 'node') {
|
|
return this.renderNode(item);
|
|
}
|
|
|
|
if (item.type === 'text') {
|
|
return this._renderText(item.value);
|
|
}
|
|
|
|
return this.render(item);
|
|
}
|
|
|
|
/**
|
|
* @param {LH.FormattedIcu<LH.Audit.Details.List>} details
|
|
* @return {Element}
|
|
*/
|
|
_renderList(details) {
|
|
const listContainer = this._dom.createElement('div', 'lh-list');
|
|
|
|
details.items.forEach(item => {
|
|
if (item.type === 'list-section') {
|
|
const sectionEl = this._dom.createElement('div', 'lh-list-section');
|
|
|
|
if (item.title) {
|
|
const titleEl = this._dom.createChildOf(sectionEl, 'div', 'lh-list-section__title');
|
|
titleEl.append(this._dom.convertMarkdownLinkSnippets(item.title));
|
|
}
|
|
|
|
if (item.description) {
|
|
const descEl = this._dom.createChildOf(sectionEl, 'div', 'lh-list-section__description');
|
|
descEl.append(this._dom.convertMarkdownLinkSnippets(item.description));
|
|
}
|
|
|
|
const listItem = this._renderListValue(item.value);
|
|
if (listItem) sectionEl.append(listItem);
|
|
|
|
listContainer.append(sectionEl);
|
|
return;
|
|
}
|
|
|
|
const listItem = this._renderListValue(item);
|
|
if (!listItem) return;
|
|
|
|
listContainer.append(listItem);
|
|
});
|
|
|
|
return listContainer;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.FormattedIcu<LH.Audit.Details.Checklist>} details
|
|
* @return {Element}
|
|
*/
|
|
_renderChecklist(details) {
|
|
const container = this._dom.createElement('ul', 'lh-checklist');
|
|
|
|
Object.values(details.items).forEach(item => {
|
|
const element = this._dom.createChildOf(container, 'li', 'lh-checklist-item');
|
|
const iconClass = item.value ?
|
|
'lh-report-plain-icon--checklist-pass' :
|
|
'lh-report-plain-icon--checklist-fail';
|
|
this._dom.createChildOf(element, 'span', `lh-report-plain-icon ${iconClass}`)
|
|
.textContent = item.label;
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.Audit.Details.NodeValue} item
|
|
* @return {Element}
|
|
*/
|
|
renderNode(item) {
|
|
const element = this._dom.createElement('span', 'lh-node');
|
|
if (item.nodeLabel) {
|
|
const nodeLabelEl = this._dom.createElement('div');
|
|
nodeLabelEl.textContent = item.nodeLabel;
|
|
element.append(nodeLabelEl);
|
|
}
|
|
if (item.snippet) {
|
|
const snippetEl = this._dom.createElement('div');
|
|
snippetEl.classList.add('lh-node__snippet');
|
|
snippetEl.textContent = item.snippet;
|
|
element.append(snippetEl);
|
|
}
|
|
if (item.selector) {
|
|
element.title = item.selector;
|
|
}
|
|
if (item.path) element.setAttribute('data-path', item.path);
|
|
if (item.selector) element.setAttribute('data-selector', item.selector);
|
|
if (item.snippet) element.setAttribute('data-snippet', item.snippet);
|
|
|
|
if (!this._fullPageScreenshot) return element;
|
|
|
|
const rect = item.lhId && this._fullPageScreenshot.nodes[item.lhId];
|
|
if (!rect || rect.width === 0 || rect.height === 0) return element;
|
|
|
|
const maxThumbnailSize = {width: 147, height: 100};
|
|
const elementScreenshot = ElementScreenshotRenderer.render(
|
|
this._dom,
|
|
this._fullPageScreenshot.screenshot,
|
|
rect,
|
|
maxThumbnailSize
|
|
);
|
|
if (elementScreenshot) element.prepend(elementScreenshot);
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.Audit.Details.SourceLocationValue} item
|
|
* @return {Element|null}
|
|
* @protected
|
|
*/
|
|
renderSourceLocation(item) {
|
|
if (!item.url) {
|
|
return null;
|
|
}
|
|
|
|
// Lines are shown as one-indexed.
|
|
const generatedLocation = `${item.url}:${item.line + 1}:${item.column}`;
|
|
let sourceMappedOriginalLocation;
|
|
if (item.original) {
|
|
const file = item.original.file || '<unmapped>';
|
|
sourceMappedOriginalLocation = `${file}:${item.original.line + 1}:${item.original.column}`;
|
|
}
|
|
|
|
// We render slightly differently based on presence of source map and provenance of URL.
|
|
let element;
|
|
if (item.urlProvider === 'network' && sourceMappedOriginalLocation) {
|
|
element = this._renderLink({
|
|
url: item.url,
|
|
text: sourceMappedOriginalLocation,
|
|
});
|
|
element.title = `maps to generated location ${generatedLocation}`;
|
|
} else if (item.urlProvider === 'network' && !sourceMappedOriginalLocation) {
|
|
element = this.renderTextURL(item.url);
|
|
this._dom.find('.lh-link', element).textContent += `:${item.line + 1}:${item.column}`;
|
|
} else if (item.urlProvider === 'comment' && sourceMappedOriginalLocation) {
|
|
element = this._renderText(`${sourceMappedOriginalLocation} (from source map)`);
|
|
element.title = `${generatedLocation} (from sourceURL)`;
|
|
} else if (item.urlProvider === 'comment' && !sourceMappedOriginalLocation) {
|
|
element = this._renderText(`${generatedLocation} (from sourceURL)`);
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
element.classList.add('lh-source-location');
|
|
element.setAttribute('data-source-url', item.url);
|
|
// DevTools expects zero-indexed lines.
|
|
element.setAttribute('data-source-line', String(item.line));
|
|
element.setAttribute('data-source-column', String(item.column));
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.Audit.Details.Filmstrip} details
|
|
* @return {Element}
|
|
*/
|
|
_renderFilmstrip(details) {
|
|
const filmstripEl = this._dom.createElement('div', 'lh-filmstrip');
|
|
|
|
for (const thumbnail of details.items) {
|
|
const frameEl = this._dom.createChildOf(filmstripEl, 'div', 'lh-filmstrip__frame');
|
|
const imgEl = this._dom.createChildOf(frameEl, 'img', 'lh-filmstrip__thumbnail');
|
|
imgEl.src = thumbnail.data;
|
|
imgEl.alt = `Screenshot`;
|
|
}
|
|
return filmstripEl;
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @return {Element}
|
|
*/
|
|
_renderCode(text) {
|
|
const pre = this._dom.createElement('pre', 'lh-code');
|
|
pre.textContent = text;
|
|
return pre;
|
|
}
|
|
}
|