lighthouse/report/renderer/dom.js

310 строки
8.7 KiB
JavaScript

/**
* @license
* Copyright 2017 The Lighthouse Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-env browser */
/** @typedef {HTMLElementTagNameMap & {[id: string]: HTMLElement}} HTMLElementByTagName */
/** @template {string} T @typedef {import('typed-query-selector/parser').ParseSelector<T, Element>} ParseSelector */
import {Util} from './util.js';
import {createComponent} from './components.js';
export class DOM {
/**
* @param {Document} document
* @param {HTMLElement} rootEl
*/
constructor(document, rootEl) {
/** @type {Document} */
this._document = document;
/** @type {string} */
this._lighthouseChannel = 'unknown';
/** @type {Map<string, DocumentFragment>} */
this._componentCache = new Map();
/** @type {HTMLElement} */
// For legacy Report API users, this'll be undefined, but set in renderReport
this.rootEl = rootEl;
}
/**
* @template {string} T
* @param {T} name
* @param {string=} className
* @return {HTMLElementByTagName[T]}
*/
createElement(name, className) {
const element = this._document.createElement(name);
if (className) {
for (const token of className.split(/\s+/)) {
if (token) element.classList.add(token);
}
}
return element;
}
/**
* @param {string} namespaceURI
* @param {string} name
* @param {string=} className
* @return {Element}
*/
createElementNS(namespaceURI, name, className) {
const element = this._document.createElementNS(namespaceURI, name);
if (className) {
for (const token of className.split(/\s+/)) {
if (token) element.classList.add(token);
}
}
return element;
}
/**
* @return {!DocumentFragment}
*/
createFragment() {
return this._document.createDocumentFragment();
}
/**
* @param {string} data
* @return {!Node}
*/
createTextNode(data) {
return this._document.createTextNode(data);
}
/**
* @template {string} T
* @param {Element} parentElem
* @param {T} elementName
* @param {string=} className
* @return {HTMLElementByTagName[T]}
*/
createChildOf(parentElem, elementName, className) {
const element = this.createElement(elementName, className);
parentElem.append(element);
return element;
}
/**
* @param {import('./components.js').ComponentName} componentName
* @return {!DocumentFragment} A clone of the cached component.
*/
createComponent(componentName) {
let component = this._componentCache.get(componentName);
if (component) {
const cloned = /** @type {DocumentFragment} */ (component.cloneNode(true));
// Prevent duplicate styles in the DOM. After a template has been stamped
// for the first time, remove the clone's styles so they're not re-added.
this.findAll('style', cloned).forEach(style => style.remove());
return cloned;
}
component = createComponent(this, componentName);
this._componentCache.set(componentName, component);
const cloned = /** @type {DocumentFragment} */ (component.cloneNode(true));
return cloned;
}
clearComponentCache() {
this._componentCache.clear();
}
/**
* @param {string} text
* @return {Element}
*/
convertMarkdownLinkSnippets(text) {
const element = this.createElement('span');
for (const segment of Util.splitMarkdownLink(text)) {
const processedSegment = segment.text.includes('`') ?
this.convertMarkdownCodeSnippets(segment.text) :
segment.text;
if (!segment.isLink) {
// Plain text segment.
element.append(processedSegment);
continue;
}
// Otherwise, append any links found.
const url = new URL(segment.linkHref);
const DOCS_ORIGINS = ['https://developers.google.com', 'https://web.dev', 'https://developer.chrome.com'];
if (DOCS_ORIGINS.includes(url.origin)) {
url.searchParams.set('utm_source', 'lighthouse');
url.searchParams.set('utm_medium', this._lighthouseChannel);
}
const a = this.createElement('a');
a.rel = 'noopener';
a.target = '_blank';
a.append(processedSegment);
this.safelySetHref(a, url.href);
element.append(a);
}
return element;
}
/**
* Set link href, but safely, preventing `javascript:` protocol, etc.
* @see https://github.com/google/safevalues/
* @param {HTMLAnchorElement} elem
* @param {string} url
*/
safelySetHref(elem, url) {
// Defaults to '' to fix proto roundtrip issue. See https://github.com/GoogleChrome/lighthouse/issues/12868
url = url || '';
// In-page anchor links are safe.
if (url.startsWith('#')) {
elem.href = url;
return;
}
const allowedProtocols = ['https:', 'http:'];
let parsed;
try {
parsed = new URL(url);
} catch (_) {}
if (parsed && allowedProtocols.includes(parsed.protocol)) {
elem.href = parsed.href;
}
}
/**
* Only create blob URLs for JSON & HTML
* @param {HTMLAnchorElement} elem
* @param {Blob} blob
*/
safelySetBlobHref(elem, blob) {
if (blob.type !== 'text/html' && blob.type !== 'application/json') {
throw new Error('Unsupported blob type');
}
const href = URL.createObjectURL(blob);
elem.href = href;
}
/**
* @param {string} markdownText
* @return {Element}
*/
convertMarkdownCodeSnippets(markdownText) {
const element = this.createElement('span');
for (const segment of Util.splitMarkdownCodeSpans(markdownText)) {
if (segment.isCode) {
const pre = this.createElement('code');
pre.textContent = segment.text;
element.append(pre);
} else {
element.append(this._document.createTextNode(segment.text));
}
}
return element;
}
/**
* The channel to use for UTM data when rendering links to the documentation.
* @param {string} lighthouseChannel
*/
setLighthouseChannel(lighthouseChannel) {
this._lighthouseChannel = lighthouseChannel;
}
/**
* ONLY use if `dom.rootEl` isn't sufficient for your needs. `dom.rootEl` is preferred
* for all scoping, because a document can have multiple reports within it.
* @return {Document}
*/
document() {
return this._document;
}
/**
* TODO(paulirish): import and conditionally apply the DevTools frontend subclasses instead of this
* @return {boolean}
*/
isDevTools() {
return !!this._document.querySelector('.lh-devtools');
}
/**
* Guaranteed context.querySelector. Always returns an element or throws if
* nothing matches query.
* @template {string} T
* @param {T} query
* @param {ParentNode} context
* @return {ParseSelector<T>}
*/
find(query, context) {
const result = context.querySelector(query);
if (result === null) {
throw new Error(`query ${query} not found`);
}
// Because we control the report layout and templates, use the simpler
// `typed-query-selector` types that don't require differentiating between
// e.g. HTMLAnchorElement and SVGAElement. See https://github.com/GoogleChrome/lighthouse/issues/12011
return /** @type {ParseSelector<T>} */ (result);
}
/**
* Helper for context.querySelectorAll. Returns an Array instead of a NodeList.
* @template {string} T
* @param {T} query
* @param {ParentNode} context
*/
findAll(query, context) {
const elements = Array.from(context.querySelectorAll(query));
return elements;
}
/**
* Fires a custom DOM event on target.
* @param {string} name Name of the event.
* @param {Node=} target DOM node to fire the event on.
* @param {*=} detail Custom data to include.
*/
fireEventOn(name, target = this._document, detail) {
const event = new CustomEvent(name, detail ? {detail} : undefined);
target.dispatchEvent(event);
}
/**
* Downloads a file (blob) using a[download].
* @param {Blob|File} blob The file to save.
* @param {string} filename
*/
saveFile(blob, filename) {
const a = this.createElement('a');
a.download = filename;
this.safelySetBlobHref(a, blob);
this._document.body.append(a); // Firefox requires anchor to be in the DOM.
a.click();
// cleanup.
this._document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 500);
}
}