lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
356 lines (315 loc) • 11.7 kB
JavaScript
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @typedef {import('./details-renderer').DetailsRenderer} DetailsRenderer */
/** @typedef {import('./dom').DOM} DOM */
import {Util} from '../../shared/util.js';
import {Globals} from './report-globals.js';
/** @enum {number} */
const LineVisibility = {
/** Show regardless of whether the snippet is collapsed or expanded */
ALWAYS: 0,
WHEN_COLLAPSED: 1,
WHEN_EXPANDED: 2,
};
/** @enum {number} */
const LineContentType = {
/** A line of content */
CONTENT_NORMAL: 0,
/** A line of content that's emphasized by setting the CSS background color */
CONTENT_HIGHLIGHTED: 1,
/** Use when some lines are hidden, shows the "..." placeholder */
PLACEHOLDER: 2,
/** A message about a line of content or the snippet in general */
MESSAGE: 3,
};
/** @typedef {{
content: string;
lineNumber: string | number;
contentType: LineContentType;
truncated?: boolean;
visibility?: LineVisibility;
}} LineDetails */
const classNamesByContentType = {
[LineContentType.CONTENT_NORMAL]: ['lh-snippet__line--content'],
[LineContentType.CONTENT_HIGHLIGHTED]: [
'lh-snippet__line--content',
'lh-snippet__line--content-highlighted',
],
[LineContentType.PLACEHOLDER]: ['lh-snippet__line--placeholder'],
[LineContentType.MESSAGE]: ['lh-snippet__line--message'],
};
/**
* @param {LH.Audit.Details.SnippetValue['lines']} lines
* @param {number} lineNumber
* @return {{line?: LH.Audit.Details.SnippetValue['lines'][0], previousLine?: LH.Audit.Details.SnippetValue['lines'][0]}}
*/
function getLineAndPreviousLine(lines, lineNumber) {
return {
line: lines.find(l => l.lineNumber === lineNumber),
previousLine: lines.find(l => l.lineNumber === lineNumber - 1),
};
}
/**
* @param {LH.Audit.Details.SnippetValue["lineMessages"]} messages
* @param {number} lineNumber
*/
function getMessagesForLineNumber(messages, lineNumber) {
return messages.filter(h => h.lineNumber === lineNumber);
}
/**
* @param {LH.Audit.Details.SnippetValue} details
* @return {LH.Audit.Details.SnippetValue['lines']}
*/
function getLinesWhenCollapsed(details) {
const SURROUNDING_LINES_TO_SHOW_WHEN_COLLAPSED = 2;
return Util.filterRelevantLines(
details.lines,
details.lineMessages,
SURROUNDING_LINES_TO_SHOW_WHEN_COLLAPSED
);
}
/**
* Render snippet of text with line numbers and annotations.
* By default we only show a few lines around each annotation and the user
* can click "Expand snippet" to show more.
* Content lines with annotations are highlighted.
*/
export class SnippetRenderer {
/**
* @param {DOM} dom
* @param {LH.Audit.Details.SnippetValue} details
* @param {DetailsRenderer} detailsRenderer
* @param {function} toggleExpandedFn
* @return {DocumentFragment}
*/
static renderHeader(dom, details, detailsRenderer, toggleExpandedFn) {
const linesWhenCollapsed = getLinesWhenCollapsed(details);
const canExpand = linesWhenCollapsed.length < details.lines.length;
const header = dom.createComponent('snippetHeader');
dom.find('.lh-snippet__title', header).textContent = details.title;
const {
snippetCollapseButtonLabel,
snippetExpandButtonLabel,
} = Globals.strings;
dom.find(
'.lh-snippet__btn-label-collapse',
header
).textContent = snippetCollapseButtonLabel;
dom.find(
'.lh-snippet__btn-label-expand',
header
).textContent = snippetExpandButtonLabel;
const toggleExpandButton = dom.find('.lh-snippet__toggle-expand', header);
// If we're already showing all the available lines of the snippet, we don't need an
// expand/collapse button and can remove it from the DOM.
// If we leave the button in though, wire up the click listener to toggle visibility!
if (!canExpand) {
toggleExpandButton.remove();
} else {
toggleExpandButton.addEventListener('click', () => toggleExpandedFn());
}
// We only show the source node of the snippet in DevTools because then the user can
// access the full element detail. Just being able to see the outer HTML isn't very useful.
if (details.node && dom.isDevTools()) {
const nodeContainer = dom.find('.lh-snippet__node', header);
nodeContainer.append(detailsRenderer.renderNode(details.node));
}
return header;
}
/**
* Renders a line (text content, message, or placeholder) as a DOM element.
* @param {DOM} dom
* @param {DocumentFragment} tmpl
* @param {LineDetails} lineDetails
* @return {Element}
*/
static renderSnippetLine(
dom,
tmpl,
{content, lineNumber, truncated, contentType, visibility}
) {
const clonedTemplate = dom.createComponent('snippetLine');
const contentLine = dom.find('.lh-snippet__line', clonedTemplate);
const {classList} = contentLine;
classNamesByContentType[contentType].forEach(typeClass =>
classList.add(typeClass)
);
if (visibility === LineVisibility.WHEN_COLLAPSED) {
classList.add('lh-snippet__show-if-collapsed');
} else if (visibility === LineVisibility.WHEN_EXPANDED) {
classList.add('lh-snippet__show-if-expanded');
}
const lineContent = content + (truncated ? '…' : '');
const lineContentEl = dom.find('.lh-snippet__line code', contentLine);
if (contentType === LineContentType.MESSAGE) {
lineContentEl.append(dom.convertMarkdownLinkSnippets(lineContent));
} else {
lineContentEl.textContent = lineContent;
}
dom.find(
'.lh-snippet__line-number',
contentLine
).textContent = lineNumber.toString();
return contentLine;
}
/**
* @param {DOM} dom
* @param {DocumentFragment} tmpl
* @param {{message: string}} message
* @return {Element}
*/
static renderMessage(dom, tmpl, message) {
return SnippetRenderer.renderSnippetLine(dom, tmpl, {
lineNumber: ' ',
content: message.message,
contentType: LineContentType.MESSAGE,
});
}
/**
* @param {DOM} dom
* @param {DocumentFragment} tmpl
* @param {LineVisibility} visibility
* @return {Element}
*/
static renderOmittedLinesPlaceholder(dom, tmpl, visibility) {
return SnippetRenderer.renderSnippetLine(dom, tmpl, {
lineNumber: '…',
content: '',
visibility,
contentType: LineContentType.PLACEHOLDER,
});
}
/**
* @param {DOM} dom
* @param {DocumentFragment} tmpl
* @param {LH.Audit.Details.SnippetValue} details
* @return {DocumentFragment}
*/
static renderSnippetContent(dom, tmpl, details) {
const template = dom.createComponent('snippetContent');
const snippetEl = dom.find('.lh-snippet__snippet-inner', template);
// First render messages that don't belong to specific lines
details.generalMessages.forEach(m =>
snippetEl.append(SnippetRenderer.renderMessage(dom, tmpl, m))
);
// Then render the lines and their messages, as well as placeholders where lines are omitted
snippetEl.append(SnippetRenderer.renderSnippetLines(dom, tmpl, details));
return template;
}
/**
* @param {DOM} dom
* @param {DocumentFragment} tmpl
* @param {LH.Audit.Details.SnippetValue} details
* @return {DocumentFragment}
*/
static renderSnippetLines(dom, tmpl, details) {
const {lineMessages, generalMessages, lineCount, lines} = details;
const linesWhenCollapsed = getLinesWhenCollapsed(details);
const hasOnlyGeneralMessages =
generalMessages.length > 0 && lineMessages.length === 0;
const lineContainer = dom.createFragment();
// When a line is not shown in the collapsed state we try to see if we also need an
// omitted lines placeholder for the expanded state, rather than rendering two separate
// placeholders.
let hasPendingOmittedLinesPlaceholderForCollapsedState = false;
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
const {line, previousLine} = getLineAndPreviousLine(lines, lineNumber);
const {
line: lineWhenCollapsed,
previousLine: previousLineWhenCollapsed,
} = getLineAndPreviousLine(linesWhenCollapsed, lineNumber);
const showLineWhenCollapsed = !!lineWhenCollapsed;
const showPreviousLineWhenCollapsed = !!previousLineWhenCollapsed;
// If we went from showing lines in the collapsed state to not showing them
// we need to render a placeholder
if (showPreviousLineWhenCollapsed && !showLineWhenCollapsed) {
hasPendingOmittedLinesPlaceholderForCollapsedState = true;
}
// If we are back to lines being visible in the collapsed and the placeholder
// hasn't been rendered yet then render it now
if (
showLineWhenCollapsed &&
hasPendingOmittedLinesPlaceholderForCollapsedState
) {
lineContainer.append(
SnippetRenderer.renderOmittedLinesPlaceholder(
dom,
tmpl,
LineVisibility.WHEN_COLLAPSED
)
);
hasPendingOmittedLinesPlaceholderForCollapsedState = false;
}
// Render omitted lines placeholder if we have not already rendered one for this gap
const isFirstOmittedLineWhenExpanded = !line && !!previousLine;
const isFirstLineOverallAndIsOmittedWhenExpanded =
!line && lineNumber === 1;
if (
isFirstOmittedLineWhenExpanded ||
isFirstLineOverallAndIsOmittedWhenExpanded
) {
// In the collapsed state we don't show omitted lines placeholders around
// the edges of the snippet
const hasRenderedAllLinesVisibleWhenCollapsed = !linesWhenCollapsed.some(
l => l.lineNumber > lineNumber
);
const onlyShowWhenExpanded =
hasRenderedAllLinesVisibleWhenCollapsed || lineNumber === 1;
lineContainer.append(
SnippetRenderer.renderOmittedLinesPlaceholder(
dom,
tmpl,
onlyShowWhenExpanded
? LineVisibility.WHEN_EXPANDED
: LineVisibility.ALWAYS
)
);
hasPendingOmittedLinesPlaceholderForCollapsedState = false;
}
if (!line) {
// Can't render the line if we don't know its content (instead we've rendered a placeholder)
continue;
}
// Now render the line and any messages
const messages = getMessagesForLineNumber(lineMessages, lineNumber);
const highlightLine = messages.length > 0 || hasOnlyGeneralMessages;
const contentLineDetails = Object.assign({}, line, {
contentType: highlightLine
? LineContentType.CONTENT_HIGHLIGHTED
: LineContentType.CONTENT_NORMAL,
visibility: lineWhenCollapsed
? LineVisibility.ALWAYS
: LineVisibility.WHEN_EXPANDED,
});
lineContainer.append(
SnippetRenderer.renderSnippetLine(dom, tmpl, contentLineDetails)
);
messages.forEach(message => {
lineContainer.append(SnippetRenderer.renderMessage(dom, tmpl, message));
});
}
return lineContainer;
}
/**
* @param {DOM} dom
* @param {LH.Audit.Details.SnippetValue} details
* @param {DetailsRenderer} detailsRenderer
* @return {!Element}
*/
static render(dom, details, detailsRenderer) {
const tmpl = dom.createComponent('snippet');
const snippetEl = dom.find('.lh-snippet', tmpl);
const header = SnippetRenderer.renderHeader(
dom,
details,
detailsRenderer,
() => snippetEl.classList.toggle('lh-snippet--expanded')
);
const content = SnippetRenderer.renderSnippetContent(dom, tmpl, details);
snippetEl.append(header, content);
return snippetEl;
}
}