UNPKG

lighthouse

Version:

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

356 lines (315 loc) 11.7 kB
/** * @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; } }