-
Notifications
You must be signed in to change notification settings - Fork 9.5k
report: autogenerate components.js from templates.html #12803
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc277e3
3018199
4ace55d
8944fdd
ec87013
4d1f12c
da5ac10
f3cc791
3f473cf
de88af4
2df95e3
189c120
f9649c8
f3e1069
ae4551e
0afbe2b
00ccf4f
f9fd13d
f3b4495
7a56d4a
024a55a
daccad9
7ffdfaa
3a2566d
bac811b
25aefd5
9c1a41b
8afdcff
f2653ca
b91c79e
e4c1c1d
4e941cf
a6aff91
3399419
2efb03f
0baf3dd
4f4e31b
38eb541
7d00ae9
374d066
5b14c17
a12de0f
2123847
82c0f5a
227124e
35ecad4
ca1211a
31fbea0
50faaa6
7099549
ae0f3d4
85a46a8
7241e8d
fe66b3b
127b554
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
/** | ||
* @license Copyright 2021 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. | ||
*/ | ||
'use strict'; | ||
|
||
/** | ||
* @typedef CompiledComponent | ||
* @property {HTMLTemplateElement} tmpEl | ||
* @property {string} componentName | ||
* @property {string} functionName | ||
* @property {string} functionCode | ||
*/ | ||
|
||
const fs = require('fs'); | ||
const jsdom = require('jsdom'); | ||
const {LH_ROOT} = require('../root.js'); | ||
|
||
const html = fs.readFileSync(LH_ROOT + '/report/assets/templates.html', 'utf-8'); | ||
const {window} = new jsdom.JSDOM(html); | ||
const tmplEls = window.document.querySelectorAll('template'); | ||
|
||
/** | ||
* @param {string} str | ||
*/ | ||
function upperFirst(str) { | ||
return str.charAt(0).toUpperCase() + str.substr(1); | ||
} | ||
|
||
/** | ||
* @param {string} functionName | ||
* @param {string[]} bodyLines | ||
* @param {string[]} parameterNames | ||
*/ | ||
function createFunctionCode(functionName, bodyLines, parameterNames = []) { | ||
const body = bodyLines.map(l => ` ${l}`).join('\n'); | ||
const functionCode = `function ${functionName}(${parameterNames.join(', ')}) {\n${body}\n}`; | ||
return functionCode; | ||
} | ||
|
||
/** | ||
* @param {ChildNode} childNode | ||
* @return {string|undefined} | ||
*/ | ||
function normalizeTextNodeText(childNode) { | ||
// Just for typescript. | ||
if (!childNode.parentElement) return; | ||
// Just for typescript. If a text node has no text, it's trivially not significant. | ||
if (!childNode.textContent) return; | ||
|
||
let textContent = childNode.textContent || ''; | ||
// Consecutive whitespace is redundant, unless in certain elements. | ||
if (!['PRE', 'STYLE'].includes(childNode.parentElement.tagName)) { | ||
textContent = textContent.replace(/\s+/g, ' '); | ||
} | ||
|
||
return textContent; | ||
} | ||
|
||
/** | ||
* @param {HTMLTemplateElement} tmpEl | ||
* @return {CompiledComponent} | ||
*/ | ||
function compileTemplate(tmpEl) { | ||
const elemToVarNames = new Map(); | ||
const lines = []; | ||
|
||
/** | ||
* @param {Element} el | ||
* @return {string} | ||
*/ | ||
function makeOrGetVarName(el) { | ||
const varName = elemToVarNames.get(el) || ('el' + elemToVarNames.size); | ||
elemToVarNames.set(el, varName); | ||
return varName; | ||
} | ||
|
||
/** | ||
* @param {Element} el | ||
*/ | ||
function process(el) { | ||
const isSvg = el.namespaceURI && el.namespaceURI.endsWith('/svg'); | ||
const namespaceURI = isSvg ? el.namespaceURI : ''; | ||
const tagName = el.localName; | ||
const className = el.classList.toString(); | ||
|
||
let createElementFnName = 'createElement'; | ||
const args = [tagName]; | ||
if (className) { | ||
args.push(className); | ||
} | ||
if (namespaceURI) { | ||
createElementFnName = 'createElementNS'; | ||
args.unshift(namespaceURI); | ||
} | ||
|
||
const varName = makeOrGetVarName(el); | ||
const argsSerialzed = | ||
args.map(arg => arg === undefined ? 'undefined' : JSON.stringify(arg)).join(', '); | ||
lines.push(`const ${varName} = dom.${createElementFnName}(${argsSerialzed});`); | ||
|
||
if (el.getAttributeNames) { | ||
for (const attr of el.getAttributeNames() || []) { | ||
if (attr === 'class') continue; | ||
|
||
lines.push(`${varName}.setAttribute('${attr}', '${el.getAttribute(attr)}');`); | ||
} | ||
} | ||
|
||
/** @type {string[]} */ | ||
const childNodesToAppend = []; | ||
for (const childNode of el.childNodes) { | ||
if (childNode.nodeType === window.Node.COMMENT_NODE) continue; | ||
|
||
if (childNode.nodeType === window.Node.TEXT_NODE) { | ||
if (!childNode.parentElement) continue; | ||
|
||
const textContent = normalizeTextNodeText(childNode); | ||
if (!textContent) continue; | ||
|
||
// Escaped string value for JS. | ||
childNodesToAppend.push(JSON.stringify(textContent)); | ||
continue; | ||
} | ||
|
||
if (!(childNode instanceof /** @type {typeof Element} */ (window.Element))) { | ||
throw new Error(`Expected ${childNode} to be an element`); | ||
} | ||
process(childNode); | ||
|
||
const childVarName = elemToVarNames.get(childNode); | ||
if (childVarName) childNodesToAppend.push(childVarName); | ||
} | ||
|
||
if (childNodesToAppend.length) { | ||
lines.push(`${varName}.append(${childNodesToAppend.join(',')});`); | ||
} | ||
} | ||
|
||
const fragmentVarName = makeOrGetVarName(tmpEl); | ||
lines.push(`const ${fragmentVarName} = dom.document().createDocumentFragment();`); | ||
|
||
for (const topLevelEl of tmpEl.content.children) { | ||
process(topLevelEl); | ||
lines.push(`${fragmentVarName}.append(${makeOrGetVarName(topLevelEl)});`); | ||
} | ||
|
||
lines.push(`return ${fragmentVarName};`); | ||
|
||
const componentName = tmpEl.id; | ||
const functionName = `create${upperFirst(componentName)}Component`; | ||
const jsdoc = ` | ||
/** | ||
* @param {DOM_} dom | ||
*/`; | ||
const functionCode = jsdoc + '\n' + createFunctionCode(functionName, lines, ['dom']); | ||
return {tmpEl, componentName, functionName, functionCode}; | ||
} | ||
|
||
/** | ||
* @param {CompiledComponent[]} compiledTemplates | ||
* @return {string} | ||
*/ | ||
function makeGenericCreateComponentFunctionCode(compiledTemplates) { | ||
const lines = []; | ||
|
||
lines.push('switch (componentName) {'); | ||
for (const {componentName, functionName} of compiledTemplates) { | ||
lines.push(` case '${componentName}': return ${functionName}(dom);`); | ||
} | ||
lines.push('}'); | ||
lines.push('throw new Error(\'unexpected component: \' + componentName)'); | ||
|
||
const paramType = compiledTemplates.map(t => `'${t.componentName}'`).join('|'); | ||
const jsdoc = ` | ||
/** @typedef {${paramType}} ComponentName */ | ||
/** | ||
* @param {DOM_} dom | ||
* @param {ComponentName} componentName | ||
* @return {DocumentFragment} | ||
*/`; | ||
return jsdoc + '\nexport ' + | ||
createFunctionCode('createComponent', lines, ['dom', 'componentName']); | ||
} | ||
|
||
async function main() { | ||
const compiledTemplates = [...tmplEls].map(compileTemplate); | ||
compiledTemplates.sort((a, b) => a.componentName.localeCompare(b.componentName)); | ||
const code = ` | ||
'use strict'; | ||
|
||
// auto-generated by build/build-report-components.js | ||
|
||
// must import as DOM_ to avoid redeclaring 'DOM' export in bundle.d.ts, otherwise | ||
// yarn test-devtools will fail. | ||
/** @typedef {import('./dom.js').DOM} DOM_ */ | ||
|
||
/* eslint-disable max-len */ | ||
|
||
${compiledTemplates.map(t => t.functionCode).join('\n')} | ||
|
||
${makeGenericCreateComponentFunctionCode(compiledTemplates)} | ||
`.trim(); | ||
fs.writeFileSync(LH_ROOT + '/report/renderer/components.js', code); | ||
} | ||
|
||
if (require.main === module) { | ||
main(); | ||
} | ||
|
||
module.exports = {normalizeTextNodeText}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -231,7 +231,6 @@ class LighthouseReportViewer { | |
features.initFeatures(json); | ||
} catch (e) { | ||
logger.error(`Error rendering report: ${e.message}`); | ||
dom.resetTemplates(); // TODO(bckenny): hack | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does the cache persist across loading different reports? That was the original need for this hack :/ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We make a new DOM for every new report render. |
||
container.textContent = ''; | ||
throw e; | ||
} finally { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,7 +23,7 @@ | |
"build-smokehouse-bundle": "node ./build/build-smokehouse-bundle.js", | ||
"build-lr": "yarn reset-link && node ./build/build-lightrider-bundles.js", | ||
"build-pack": "bash build/build-pack.sh", | ||
"build-report": "node build/build-report.js", | ||
"build-report": "node build/build-report-components.js && yarn eslint --fix report/renderer/components.js && node build/build-report.js", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. so now every reminder me again why a watch is terrible? 😅 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The watch question is a good one (as someone who generally dislikes watch commands :) and we should definitely look at what we're doing in all our build steps and the efficiency of them, but this seems ok until we have #12689 mostly settled and know more of the lay of the land? Personally I've been spoiled by esbuild and would love to get building everything down to like < 1s total, but we'll see what's possible :) |
||
"build-treemap": "node ./build/build-treemap.js", | ||
"build-viewer": "node ./build/build-viewer.js", | ||
"reset-link": "(yarn unlink || true) && yarn link && yarn link lighthouse", | ||
|
@@ -105,6 +105,7 @@ | |
"@types/google.analytics": "0.0.39", | ||
"@types/jest": "^24.0.9", | ||
"@types/jpeg-js": "^0.3.0", | ||
"@types/jsdom": "^16.2.13", | ||
"@types/lodash.clonedeep": "^4.5.6", | ||
"@types/lodash.get": "^4.4.6", | ||
"@types/lodash.isequal": "^4.5.2", | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.