blob: 07150ddfbe49b9fa024bd51e757387e8566cd3c4 [file] [log] [blame]
// 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 || process.env.TARGET || 'Default';
/**
* This configures the base of the URLs that are injected into each component
* doc example to load. By default it's /, so that we load /front_end/..., but
* this can be configured if you have a different file structure.
*/
const sharedResourcesBase = argv.sharedResourcesBase || '/';
/**
* 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('out', path.sep, target));
let pathToOutTargetDir = __dirname;
/**
* If we are in the gen directory, we need to find the out/Default folder to use
* as our base to find files from. We could do this with path.join(x, '..',
* '..') until we get the right folder, but that's brittle. It's better to
* search up for out/Default to be robust to any folder structures.
*/
while (isRunningInGen && !pathToOutTargetDir.endsWith(`out${path.sep}${target}`)) {
pathToOutTargetDir = path.resolve(pathToOutTargetDir, '..');
}
const pathToBuiltOutTargetDirectory =
isRunningInGen ? pathToOutTargetDir : path.resolve(path.join(__dirname, '..', '..', 'out', target));
const devtoolsRootFolder = path.resolve(path.join(pathToBuiltOutTargetDirectory, 'gen'));
if (!fs.existsSync(devtoolsRootFolder)) {
console.error(`ERROR: Generated front_end folder (${devtoolsRootFolder}) 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('/front_end/component_docs/', '').replace(/_/g, ' ').replace('/', '');
// 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;
}
a:link,
a:visited {
color: blue;
text-decoration: underline;
}
a:hover {
text-decoration: none;
}
.example summary {
font-size: 20px;
}
.back-link {
padding-left: 5px;
font-size: 16px;
font-style: italic;
}
iframe { display: block; width: 100%; height: 400px; }
</style>
</head>
<body>
<h1>
${componentName}
<a class="back-link" href="/">Back to index</a>
</h1>
${componentExamples.map(example => {
const fullPath = path.join(componentPath, example);
return `<details class="example">
<summary><a href="${fullPath}">${example.replace('.html', '').replace(/-|_/g, ' ')}</a></summary>
<iframe src="${fullPath}"></iframe>
</details>`;
}).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='/front_end/component_docs/${name}'>${niceName}</a></li>`;
}).join('\n')}
</ul>
</body>
</html>`;
// clang-format on
}
async function getExamplesForPath(filePath) {
const componentDirectory = path.join(devtoolsRootFolder, 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('front_end', '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(devtoolsRootFolder, 'front_end', 'component_docs'));
const html = createServerIndexFile(components.filter(filePath => {
const stats = fs.lstatSync(path.join(devtoolsRootFolder, 'front_end', '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 (filePath.startsWith('/front_end/component_docs') && 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/front_end/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.
*/
// server --base=.
// server --base=devtools-frontend
const fileContents = await fs.promises.readFile(path.join(devtoolsRootFolder, filePath), {encoding: 'utf8'});
const themeColoursLink = `<link rel="stylesheet" href="${
path.join(sharedResourcesBase, 'front_end', 'ui', 'themeColors.css')}" type="text/css" />`;
const inspectorStyleLink = `<link rel="stylesheet" href="${
path.join(sharedResourcesBase, 'front_end', 'ui', 'inspectorStyle.css')}" type="text/css" />`;
const toggleDarkModeScript = `<script type="module" src="${
path.join(sharedResourcesBase, 'front_end', 'component_docs', 'component_docs.js')}"></script>`;
const newFileContents = fileContents.replace('<style>', `${themeColoursLink}\n${inspectorStyleLink}\n<style>`)
.replace('<script', toggleDarkModeScript + '\n<script');
respondWithHtml(response, newFileContents);
} else {
// This means it's an asset like a JS file or an image.
const normalizedPath = normalizeImagePathIfRequired(filePath);
let fullPath = path.join(devtoolsRootFolder, 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(devtoolsRootFolder, 'front_end', 'i18n', 'locales', 'en-US.json');
}
if (!fullPath.startsWith(devtoolsRootFolder) && !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') ||
fullPath.endsWith('.avif')) {
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';
} else if (fullPath.endsWith('.avif')) {
response.setHeader('Content-Type', 'image/avif');
encoding = 'binary';
}
response.writeHead(200);
response.write(fileContents, encoding);
response.end();
}
}