| // Copyright (c) 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| const fs = require('fs'); |
| const http = require('http'); |
| const path = require('path'); |
| const parseURL = require('url').parse; |
| const {argv} = require('yargs'); |
| |
| const serverPort = parseInt(process.env.PORT, 10) || 8090; |
| |
| const target = argv.target || 'Default'; |
| |
| /** |
| * When you run npm run components-server we run the script as is from scripts/, |
| * but when this server is run as part of a test suite it's run from |
| * out/Default/gen/scripts, so we have to do a bit of path mangling to figure |
| * out where we are. |
| */ |
| const isRunningInGen = __dirname.includes(path.join(path.sep, 'gen', path.sep, 'scripts')); |
| |
| const pathToBuiltOutTargetDirectory = isRunningInGen ? path.resolve(path.join(__dirname), '..', '..', '..') : |
| path.resolve(path.join(__dirname, '..', '..', 'out', target)); |
| const devtoolsFrontendFolder = path.resolve(path.join(pathToBuiltOutTargetDirectory, 'gen', 'front_end')); |
| |
| if (!fs.existsSync(devtoolsFrontendFolder)) { |
| console.error(`ERROR: Generated front_end folder (${devtoolsFrontendFolder}) does not exist.`); |
| console.log( |
| 'The components server works from the built Ninja output; you may need to run Ninja to update your built DevTools.'); |
| console.log('If you build to a target other than default, you need to pass --target=X as an argument'); |
| process.exit(1); |
| } |
| |
| const server = http.createServer(requestHandler); |
| server.listen(serverPort); |
| server.once('listening', () => { |
| if (process.send) { |
| process.send(serverPort); |
| } |
| console.log(`Started components server at http://localhost:${serverPort}\n`); |
| }); |
| |
| server.once('error', error => { |
| if (process.send) { |
| process.send('ERROR'); |
| } |
| throw error; |
| }); |
| |
| function createComponentIndexFile(componentPath, componentExamples) { |
| const componentName = componentPath.replace('/', '').replace(/_/g, ' '); |
| // clang-format off |
| return `<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width" /> |
| <title>DevTools component: ${componentName}</title> |
| <style> |
| h1 { text-transform: capitalize; } |
| |
| .example { |
| padding: 5px; |
| margin: 10px; |
| } |
| iframe { display: block; width: 100%; } |
| </style> |
| </head> |
| <body> |
| <h1>${componentName}</h1> |
| ${componentExamples.map(example => { |
| const fullPath = path.join('component_docs', componentPath, example); |
| return `<div class="example"> |
| <h3><a href="${fullPath}">${example}</a></h3> |
| <iframe src="${fullPath}"></iframe> |
| </div>`; |
| }).join('\n')} |
| </body> |
| </html>`; |
| // clang-format on |
| } |
| |
| function createServerIndexFile(componentNames) { |
| // clang-format off |
| return `<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width" /> |
| <title>DevTools components</title> |
| <style> |
| a:link, a:visited { |
| color: blue; |
| text-transform: capitalize; |
| text-decoration: none; |
| } |
| a:hover { |
| text-decoration: underline; |
| } |
| </style> |
| </head> |
| <body> |
| <h1>DevTools components</h1> |
| <ul> |
| ${componentNames.map(name => { |
| const niceName = name.replace(/_/g, ' '); |
| return `<li><a href='/${name}'>${niceName}</a></li>`; |
| }).join('\n')} |
| </ul> |
| </body> |
| </html>`; |
| // clang-format on |
| } |
| |
| async function getExamplesForPath(filePath) { |
| const componentDirectory = path.join(devtoolsFrontendFolder, 'component_docs', filePath); |
| const allFiles = await fs.promises.readdir(componentDirectory); |
| const htmlExampleFiles = allFiles.filter(file => { |
| return path.extname(file) === '.html'; |
| }); |
| |
| return createComponentIndexFile(filePath, htmlExampleFiles); |
| } |
| |
| function respondWithHtml(response, html) { |
| response.setHeader('Content-Type', 'text/html; charset=utf-8'); |
| response.writeHead(200); |
| response.write(html, 'utf8'); |
| response.end(); |
| } |
| |
| function send404(response, message) { |
| response.writeHead(404); |
| response.write(message, 'utf8'); |
| response.end(); |
| } |
| |
| async function checkFileExists(filePath) { |
| try { |
| const errorsAccessingFile = await fs.promises.access(filePath, fs.constants.R_OK); |
| return !errorsAccessingFile; |
| } catch (e) { |
| return false; |
| } |
| } |
| |
| /** |
| * In Devtools-Frontend we load images without a leading slash, e.g. |
| * url(Images/checker.png). This works within devtools, but breaks this component |
| * server as the path ends up as /component_docs/my_component/Images/checker.png. |
| * So we check if the path ends in Images/*.* and if so, remove anything before |
| * it. Then it will be resolved correctly. |
| */ |
| function normalizeImagePathIfRequired(filePath) { |
| const imagePathRegex = /\/Images\/(\S+)\.(\w{3})/; |
| const match = imagePathRegex.exec(filePath); |
| if (!match) { |
| return filePath; |
| } |
| |
| const [, imageName, imageExt] = match; |
| const normalizedPath = path.join('Images', `${imageName}.${imageExt}`); |
| return normalizedPath; |
| } |
| |
| async function requestHandler(request, response) { |
| const filePath = parseURL(request.url).pathname; |
| if (filePath === '/favicon.ico') { |
| send404(response, '404, no favicon'); |
| return; |
| } |
| |
| if (filePath === '/' || filePath === '/index.html') { |
| const components = await fs.promises.readdir(path.join(devtoolsFrontendFolder, 'component_docs')); |
| const html = createServerIndexFile(components.filter(filePath => { |
| const stats = fs.lstatSync(path.join(devtoolsFrontendFolder, 'component_docs', filePath)); |
| // Filter out some build config files (tsconfig, d.ts, etc), and just list the directories. |
| return stats.isDirectory(); |
| })); |
| respondWithHtml(response, html); |
| } else if (path.extname(filePath) === '') { |
| // This means it's a component path like /breadcrumbs. |
| const componentHtml = await getExamplesForPath(filePath); |
| respondWithHtml(response, componentHtml); |
| return; |
| } else if (/component_docs\/(.+)\/(.+)\.html/.test(filePath)) { |
| /** This conditional checks if we are viewing an individual example's HTML |
| * file. e.g. localhost:8090/component_docs/data_grid/basic.html For each |
| * example we inject themeColors.css into the page so all CSS variables |
| * that components use are available. |
| */ |
| const fileContents = await fs.promises.readFile(path.join(devtoolsFrontendFolder, filePath), {encoding: 'utf8'}); |
| const themeColoursLink = '<link rel="stylesheet" href="/ui/themeColors.css" type="text/css" />'; |
| const toggleDarkModeScript = '<script type="module" src="/component_docs/component_docs.js"></script>'; |
| const newFileContents = fileContents.replace('<style>', themeColoursLink + '\n<style>') |
| .replace('<script', toggleDarkModeScript + '\n<script'); |
| respondWithHtml(response, newFileContents); |
| |
| } else { |
| if (filePath.startsWith('/front_end')) { |
| /** |
| * We load files from the test directory whose paths will often start with |
| * /front_end if they load in frontend code. However we also get requests |
| * from within the front_end directory which do not start with /front_end. |
| * This means we might get two requests for the same file: |
| * |
| * 1) /front_end/ui/ui.js |
| * 2) /ui/ui.js |
| * |
| * If we serve them both it will mean we load ui/ui.js twice. So instead |
| * we redirect permanently to the non-/front_end prefixed URL so that the |
| * browser only loads each module once. |
| */ |
| response.writeHead(301, { |
| Location: filePath.replace('/front_end', ''), |
| }); |
| response.end(); |
| return; |
| } |
| // This means it's an asset like a JS file or an image. |
| const normalizedPath = normalizeImagePathIfRequired(filePath); |
| |
| let fullPath = path.join(devtoolsFrontendFolder, normalizedPath); |
| if (fullPath.endsWith(path.join('locales', 'en-US.json'))) { |
| // Rewrite this path so we can load up the locale in the component-docs |
| fullPath = path.join(devtoolsFrontendFolder, 'i18n', 'locales', 'en-US.json'); |
| } |
| /** |
| * Component docs can also load files from the test directory, so we ensure |
| * that we allow that here, and also then deal with the relative imports |
| * that come from the test directory, which will contain the front_end |
| * folder already. In which case we take the devToolsFrontendFolder, and go |
| * up one directory with '..' to make sure we import the right file from the |
| * right place. |
| */ |
| const fileIsInTestFolder = normalizedPath.startsWith('/test/'); |
| if (fileIsInTestFolder) { |
| fullPath = path.join(devtoolsFrontendFolder, '..', normalizedPath); |
| } |
| |
| if (!fullPath.startsWith(devtoolsFrontendFolder) && !fileIsInTestFolder) { |
| console.error(`Path ${fullPath} is outside the DevTools Frontend root dir.`); |
| process.exit(1); |
| } |
| |
| const fileExists = await checkFileExists(fullPath); |
| |
| if (!fileExists) { |
| send404(response, '404, File not found'); |
| return; |
| } |
| |
| let encoding = 'utf8'; |
| if (fullPath.endsWith('.wasm') || fullPath.endsWith('.png') || fullPath.endsWith('.jpg')) { |
| encoding = 'binary'; |
| } |
| |
| const fileContents = await fs.promises.readFile(fullPath, encoding); |
| |
| encoding = 'utf8'; |
| if (fullPath.endsWith('.js')) { |
| response.setHeader('Content-Type', 'text/javascript; charset=utf-8'); |
| } else if (fullPath.endsWith('.css')) { |
| response.setHeader('Content-Type', 'text/css; charset=utf-8'); |
| } else if (fullPath.endsWith('.wasm')) { |
| response.setHeader('Content-Type', 'application/wasm'); |
| encoding = 'binary'; |
| } else if (fullPath.endsWith('.svg')) { |
| response.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); |
| } else if (fullPath.endsWith('.png')) { |
| response.setHeader('Content-Type', 'image/png'); |
| encoding = 'binary'; |
| } else if (fullPath.endsWith('.jpg')) { |
| response.setHeader('Content-Type', 'image/jpg'); |
| encoding = 'binary'; |
| } |
| |
| response.writeHead(200); |
| response.write(fileContents, encoding); |
| response.end(); |
| } |
| } |