// Copyright (C) 2021 The Android Open Source Project // // 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'; // This script builds the perfetto.dev docs website. const argparse = require('argparse'); const child_process = require('child_process'); const fs = require('fs'); const http = require('http'); const path = require('path'); const fswatch = require('node-watch'); // Like fs.watch(), but works on Linux. const pjoin = path.join; const ROOT_DIR = path.dirname(path.dirname(__dirname)); // The repo root. const cfg = { watch: false, verbose: false, startHttpServer: false, outDir: pjoin(ROOT_DIR, 'out/perfetto.dev'), }; const RULES = [ {r: /infra\/perfetto.dev\/src\/assets\/((.*)\.png)/, f: copyAssets}, {r: /infra\/perfetto.dev\/src\/assets\/((.*)\.js)/, f: copyAssets}, {r: /infra\/perfetto.dev\/node_modules\/.*\/(.*\.css|.*\.js)/, f: copyAssets}, {r: /infra\/perfetto.dev\/src\/assets\/.+\.scss/, f: compileScss}, { r: /protos\/perfetto\/config\/trace_config\.proto/, f: s => genProtoReference(s, 'perfetto.protos.TraceConfig') }, { r: /protos\/perfetto\/trace\/trace_packet\.proto/, f: s => genProtoReference(s, 'perfetto.protos.TracePacket') }, {r: /src\/trace_processor\/storage\/stats\.h/, f: genSqlStatsReference}, {r: /src\/trace_processor\/tables\/.*\.h/, f: s => sqlTables.add(s)}, {r: /docs\/toc[.]md/, f: genNav}, {r: /docs\/.*[.]md/, f: renderDoc}, ]; let sqlTables = new Set(); let tasks = []; let tasksTot = 0, tasksRan = 0; let tStart = Date.now(); function main() { const parser = new argparse.ArgumentParser(); parser.add_argument('--out', {help: 'Output directory'}); parser.add_argument('--watch', '-w', {action: 'store_true'}); parser.add_argument('--serve', '-s', {action: 'store_true'}); parser.add_argument('--verbose', '-v', {action: 'store_true'}); const args = parser.parse_args(); cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir, /*clean=*/ true)); cfg.watch = !!args.watch; cfg.verbose = !!args.verbose; cfg.startHttpServer = args.serve; // Check that deps are current before starting. const installBuildDeps = pjoin(ROOT_DIR, 'tools/install-build-deps'); // --filter=nodejs is to match what cloud_build_entrypoint.sh passes to // install-build-deps. It doesn't bother installing the full toolchains // because, unlike the Perfetto UI, it doesn't need Wasm. const depsArgs = ['--check-only=/dev/null', '--ui', '--filter=nodejs']; exec(installBuildDeps, depsArgs); console.log('Entering', cfg.outDir); process.chdir(cfg.outDir); scanDir('infra/perfetto.dev/src/assets'); scanFile( 'infra/perfetto.dev/node_modules/highlight.js/styles/tomorrow-night.css'); scanFile('infra/perfetto.dev/node_modules/mermaid/dist/mermaid.min.js'); scanFile('docs/toc.md'); genIndex(); scanFile('src/trace_processor/storage/stats.h'); scanDir('src/trace_processor/tables'); scanDir('protos'); genSqlTableReference(); scanDir('docs'); if (args.serve) { addTask(startServer); } } // ----------- // Build rules // ----------- function copyAssets(src, dst) { addTask(cp, [src, pjoin(cfg.outDir, 'assets', dst)]); } function compileScss() { const src = pjoin(__dirname, 'src/assets/style.scss'); const dst = pjoin(cfg.outDir, 'assets/style.css'); // In watch mode, don't exit(1) if scss fails. It can easily happen by // having a typo in the css. It will still print an errror. const noErrCheck = !!cfg.watch; addTask( execNode, ['node_modules/.bin/node-sass', ['--quiet', src, dst], {noErrCheck}]); } function md2html(src, dst, template) { const script = pjoin(__dirname, 'src/markdown_render.js'); const args = ['-i', src, '--odir', cfg.outDir, '-o', dst]; ensureDir(path.dirname(dst)); if (template) args.push('-t', pjoin(__dirname, 'src', template)); execNode(script, args); } function proto2md(src, dst, protoRootType) { const script = pjoin(__dirname, 'src/gen_proto_reference.js'); const args = ['-i', src, '-p', protoRootType, '-o', dst]; ensureDir(path.dirname(dst)); execNode(script, args); } function genNav(src) { const dst = pjoin(cfg.outDir, 'docs', '_nav.html'); addTask(md2html, [src, dst]); } function genIndex() { const dst = pjoin(cfg.outDir, 'index.html'); addTask(md2html, ['/dev/null', dst, 'template_index.html']); } function renderDoc(src) { let dstRel = path.relative(ROOT_DIR, src); dstRel = dstRel.replace('.md', '').replace(/\bREADME$/, 'index.html'); const dst = pjoin(cfg.outDir, dstRel); addTask(md2html, [src, dst, 'template_markdown.html']); } function genProtoReference(src, protoRootType) { const fname = path.basename(src); const dstFname = fname.replace(/[._]/g, '-'); const dstHtml = pjoin(cfg.outDir, 'docs/reference', dstFname); const dstMd = dstHtml + '.md'; addTask(proto2md, [src, dstMd, protoRootType]); addTask(md2html, [dstMd, dstHtml, 'template_markdown.html']); addTask(exec, ['rm', [dstMd]]); } function genSqlStatsReference(src) { const dstDir = ensureDir(pjoin(cfg.outDir, 'docs/analysis')); const dstHtml = pjoin(dstDir, 'sql-stats'); const dstMd = dstHtml + '.md'; const script = pjoin(__dirname, 'src/gen_stats_reference.js'); const args = ['-i', src, '-o', dstMd]; addTask(execNode, [script, args]); addTask(md2html, [dstMd, dstHtml, 'template_markdown.html']); addTask(exec, ['rm', [dstMd]]); } function genSqlTableReference() { const dstDir = ensureDir(pjoin(cfg.outDir, 'docs/analysis')); const dstHtml = pjoin(dstDir, 'sql-tables'); const dstMd = dstHtml + '.md'; const script = pjoin(__dirname, 'src/gen_sql_tables_reference.js'); const args = ['-o', dstMd]; sqlTables.forEach(f => args.push('-i', f)); addTask(execNode, [script, args]); addTask(md2html, [dstMd, dstHtml, 'template_markdown.html']); addTask(exec, ['rm', [dstMd]]); } function startServer() { const port = 8082; console.log(`Starting HTTP server on http://localhost:${port}`) http.createServer(function(req, res) { console.debug(req.method, req.url); let uri = req.url.split('?', 1)[0]; uri += uri.endsWith('/') ? 'index.html' : ''; const absPath = path.normalize(path.join(cfg.outDir, uri)); fs.readFile(absPath, function(err, data) { if (err) { res.writeHead(404); res.end(JSON.stringify(err)); return; } const mimeMap = { 'css': 'text/css', 'svg': 'image/svg+xml', 'js': 'application/javascript', }; const contentType = mimeMap[uri.split('.').pop()] || 'text/html'; const head = { 'Content-Type': contentType, 'Content-Length': data.length, 'Cache-Control': 'no-cache', }; res.writeHead(200, head); res.end(data); }); }) .listen(port); } // ----------------------- // Task chaining functions // ----------------------- function addTask(func, args) { const task = new Task(func, args); for (const t of tasks) { if (t.identity === task.identity) { return; } } tasks.push(task); setTimeout(runTasks, 0); } function runTasks() { const snapTasks = tasks.splice(0); // snap = std::move(tasks). tasksTot += snapTasks.length; for (const task of snapTasks) { const DIM = '\u001b[2m'; const BRT = '\u001b[37m'; const RST = '\u001b[0m'; const ms = (new Date(Date.now() - tStart)).toISOString().slice(17, -1); const ts = `[${DIM}${ms}${RST}]`; const descr = task.description.substr(0, 80); console.log(`${ts} ${BRT}${++tasksRan}/${tasksTot}${RST}\t${descr}`); task.func.apply(/*this=*/ undefined, task.args); } } // Executes the first rule in RULES that match the given |absPath|. function scanFile(file) { const absPath = path.isAbsolute(file) ? file : pjoin(ROOT_DIR, file); console.assert(fs.existsSync(absPath)); const normPath = path.relative(ROOT_DIR, absPath); for (const rule of RULES) { const match = rule.r.exec(normPath); if (!match || match[0] !== normPath) continue; const captureGroup = match.length > 1 ? match[1] : undefined; rule.f(absPath, captureGroup); return; } } // Walks the passed |dir| recursively and, for each file, invokes the matching // RULES. If --watch is used, it also installs a fswatch() and re-triggers the // matching RULES on each file change. function scanDir(dir, regex) { const filterFn = regex ? absPath => regex.test(absPath) : () => true; const absDir = path.isAbsolute(dir) ? dir : pjoin(ROOT_DIR, dir); // Add a fs watch if in watch mode. if (cfg.watch) { fswatch(absDir, {recursive: true}, (_eventType, filePath) => { if (!filterFn(filePath)) return; if (cfg.verbose) { console.log('File change detected', _eventType, filePath); } if (fs.existsSync(filePath)) { scanFile(filePath, filterFn); } }); } walk(absDir, f => { if (filterFn(f)) scanFile(f); }); } function exec(cmd, args, opts) { opts = opts || {}; opts.stdout = opts.stdout || 'inherit'; if (cfg.verbose) console.log(`${cmd} ${args.join(' ')}\n`); const spwOpts = {cwd: cfg.outDir, stdio: ['ignore', opts.stdout, 'inherit']}; const checkExitCode = (code, signal) => { if (signal === 'SIGINT' || signal === 'SIGTERM') return; if (code !== 0 && !opts.noErrCheck) { console.error(`${cmd} ${args.join(' ')} failed with code ${code}`); process.exit(1); } }; const spawnRes = child_process.spawnSync(cmd, args, spwOpts); checkExitCode(spawnRes.status, spawnRes.signal); return spawnRes; } function execNode(script, args, opts) { const modPath = path.isAbsolute(script) ? script : pjoin(__dirname, script); const nodeBin = pjoin(ROOT_DIR, 'tools/node'); args = [modPath].concat(args || []); return exec(nodeBin, args, opts); } // ------------------------------------------ // File system & subprocess utility functions // ------------------------------------------ class Task { constructor(func, args) { this.func = func; this.args = args || []; // |identity| is used to dedupe identical tasks in the queue. this.identity = JSON.stringify([this.func.name, this.args]); } get description() { const ret = this.func.name.startsWith('exec') ? [] : [this.func.name]; const flattenedArgs = [].concat.apply([], this.args); for (const arg of flattenedArgs) { const argStr = `${arg}`; if (argStr.startsWith('/')) { ret.push(path.relative(cfg.outDir, arg)); } else { ret.push(argStr); } } return ret.join(' '); } } function walk(dir, callback, skipRegex) { for (const child of fs.readdirSync(dir)) { const childPath = pjoin(dir, child); const stat = fs.lstatSync(childPath); if (skipRegex !== undefined && skipRegex.test(child)) continue; if (stat.isDirectory()) { walk(childPath, callback, skipRegex); } else if (!stat.isSymbolicLink()) { callback(childPath); } } } function ensureDir(dirPath, clean) { const exists = fs.existsSync(dirPath); if (exists && clean) { if (cfg.verbose) console.log('rm', dirPath); fs.rmSync(dirPath, {recursive: true}); } if (!exists || clean) fs.mkdirSync(dirPath, {recursive: true}); return dirPath; } function cp(src, dst) { ensureDir(path.dirname(dst)); if (cfg.verbose) { console.log( 'cp', path.relative(ROOT_DIR, src), '->', path.relative(ROOT_DIR, dst)); } fs.copyFileSync(src, dst); } main();