mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-09-28 13:13:37 +08:00
This renames these two files to better reflect the current content: theme_colors -> design_system_tokens application_color_tokens -> application_tokens Bug: 390330089 Change-Id: Iceac9fc91fb774912236715643f831caad035ed2 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6205768 Reviewed-by: Benedikt Meurer <bmeurer@chromium.org> Commit-Queue: Kim-Anh Tran <kimanh@chromium.org>
595 lines
19 KiB
JavaScript
595 lines
19 KiB
JavaScript
// Copyright 2021 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 tracesMode = argv.traces || false;
|
|
const serverPort = parseInt(process.env.PORT, 10) || (tracesMode ? 11010 : 8090);
|
|
|
|
function getTestRunnerConfig() {
|
|
try {
|
|
return JSON.parse(process.env.TEST_RUNNER_JSON_CONFIG);
|
|
} catch {
|
|
// Return an empty object so any lookups return undefined
|
|
return {};
|
|
}
|
|
}
|
|
function getTestRunnerConfigSetting(settingKey, fallbackValue) {
|
|
const config = getTestRunnerConfig();
|
|
return config[settingKey] === undefined ? fallbackValue : config[settingKey];
|
|
}
|
|
|
|
/**
|
|
* 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 [target, isRunningInGen] = (() => {
|
|
const regex = new RegExp(`out\\${path.sep}(.*)\\${path.sep}gen`);
|
|
const match = regex.exec(__dirname);
|
|
if (match) {
|
|
return [match[1], true];
|
|
}
|
|
return [argv.target || process.env.TARGET || 'Default', false];
|
|
})();
|
|
|
|
/**
|
|
* 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 || getTestRunnerConfigSetting('component-server-shared-resources-path', '/');
|
|
|
|
/**
|
|
* The server assumes that examples live in
|
|
* devtoolsRoot/out/Target/gen/front_end/ui/components/docs, but if you need to add a
|
|
* prefix you can pass this argument. Passing `foo` will redirect the server to
|
|
* look in devtoolsRoot/out/Target/gen/foo/front_end/ui/components/docs.
|
|
*/
|
|
const componentDocsBaseArg = argv.componentDocsBase || process.env.COMPONENT_DOCS_BASE ||
|
|
getTestRunnerConfigSetting('component-server-base-path', '');
|
|
|
|
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, '..');
|
|
}
|
|
|
|
/* If we are not running in out/Default, we'll assume the script is running from the repo root, and navigate to {CWD}/out/Target */
|
|
const pathToBuiltOutTargetDirectory =
|
|
isRunningInGen ? pathToOutTargetDir : path.resolve(path.join(process.cwd(), 'out', target));
|
|
|
|
let devtoolsRootFolder = path.resolve(
|
|
path.join(pathToBuiltOutTargetDirectory, 'gen'),
|
|
);
|
|
const fullCheckoutDevtoolsRootFolder = path.join(
|
|
devtoolsRootFolder,
|
|
'third_party',
|
|
'devtools-frontend',
|
|
'src',
|
|
);
|
|
if (__dirname.startsWith(fullCheckoutDevtoolsRootFolder)) {
|
|
devtoolsRootFolder = fullCheckoutDevtoolsRootFolder;
|
|
}
|
|
|
|
const componentDocsBaseFolder = path.join(
|
|
devtoolsRootFolder,
|
|
componentDocsBaseArg,
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
process.on('uncaughtException', error => {
|
|
console.error('uncaughtException', error);
|
|
});
|
|
process.on('unhandledRejection', error => {
|
|
console.error('unhandledRejection', error);
|
|
});
|
|
|
|
const server = http.createServer(
|
|
(req, res) => requestHandler(req, res).catch(err => send500(res, err)),
|
|
);
|
|
server.listen(serverPort, 'localhost');
|
|
server.once('listening', () => {
|
|
if (process.send) {
|
|
process.send(serverPort);
|
|
}
|
|
console.log(`Started components server at http://localhost:${serverPort}\n`);
|
|
console.log(
|
|
`ui/components/docs location: ${
|
|
path.relative(
|
|
process.cwd(),
|
|
path.join(
|
|
componentDocsBaseFolder,
|
|
'front_end',
|
|
'ui',
|
|
'components',
|
|
'docs',
|
|
),
|
|
)}`,
|
|
);
|
|
});
|
|
|
|
server.once('error', error => {
|
|
if (process.send) {
|
|
process.send('ERROR');
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
// All paths that are injected globally into real DevTools, so we do the same
|
|
// to avoid styles being broken in the component server.
|
|
const styleSheetPaths = [
|
|
'front_end/design_system_tokens.css',
|
|
'front_end/application_tokens.css',
|
|
'front_end/ui/components/buttons/textButton.css',
|
|
'front_end/ui/legacy/inspectorCommon.css',
|
|
'front_end/ui/components/docs/component_docs_styles.css',
|
|
];
|
|
|
|
function createComponentIndexFile(componentPath, componentExamples) {
|
|
const componentName = componentPath.replace('/front_end/ui/components/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) {
|
|
const linksToStyleSheets = styleSheetPaths
|
|
.map(
|
|
link => `<link rel="stylesheet" href="${sharedResourcesBase}${
|
|
path.join(
|
|
...link.split('/'),
|
|
)}" />`,
|
|
)
|
|
.join('\n');
|
|
|
|
// clang-format off
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width" />
|
|
<title>DevTools components</title>
|
|
${linksToStyleSheets}
|
|
</head>
|
|
<body id="index-page">
|
|
<h1>DevTools components</h1>
|
|
<ul class="components-list">
|
|
${componentNames
|
|
.map(name => {
|
|
const niceName = name.replace(/_/g, ' ');
|
|
return `<li><a href='/front_end/ui/components/docs/${name}'>${niceName}</a></li>`;
|
|
})
|
|
.join('\n')}
|
|
</ul>
|
|
</body>
|
|
</html>`;
|
|
// clang-format on
|
|
}
|
|
|
|
async function getExamplesForPath(filePath) {
|
|
const componentDirectory = path.join(componentDocsBaseFolder, filePath);
|
|
if (!(await checkFileExists(componentDirectory))) {
|
|
return null;
|
|
}
|
|
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();
|
|
}
|
|
|
|
function send500(response, error) {
|
|
response.writeHead(500);
|
|
response.write(error.toString(), 'utf8');
|
|
response.end();
|
|
}
|
|
|
|
async function checkFileExists(filePath) {
|
|
try {
|
|
const errorsAccessingFile = await fs.promises.access(
|
|
filePath,
|
|
fs.constants.R_OK,
|
|
);
|
|
return !errorsAccessingFile;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {http.IncomingMessage} request
|
|
* @param {http.ServerResponse} response
|
|
*/
|
|
async function requestHandler(request, response) {
|
|
const filePath = parseURL(request.url).pathname;
|
|
if (filePath === '/favicon.ico') {
|
|
send404(response, '404, no favicon');
|
|
return;
|
|
}
|
|
if (['/', '/index.html'].includes(filePath) && tracesMode === false) {
|
|
const components = await fs.promises.readdir(
|
|
path.join(
|
|
componentDocsBaseFolder,
|
|
'front_end',
|
|
'ui',
|
|
'components',
|
|
'docs',
|
|
),
|
|
);
|
|
const html = createServerIndexFile(
|
|
components.filter(filePath => {
|
|
const stats = fs.lstatSync(
|
|
path.join(
|
|
componentDocsBaseFolder,
|
|
'front_end',
|
|
'ui',
|
|
'components',
|
|
'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/ui/components/docs') && path.extname(filePath) === '') {
|
|
// This means it's a component path like /breadcrumbs.
|
|
const componentHtml = await getExamplesForPath(filePath);
|
|
if (componentHtml !== null) {
|
|
respondWithHtml(response, componentHtml);
|
|
} else {
|
|
send404(response, '404, not a valid component');
|
|
}
|
|
return;
|
|
} else if (tracesMode) {
|
|
return handleTracesModeRequest(request, response, filePath);
|
|
} else if (/ui\/components\/docs\/(.+)\/(.+)\.html/.test(filePath)) {
|
|
/** This conditional checks if we are viewing an individual example's HTML
|
|
* file. e.g. localhost:8090/front_end/ui/components/docs/data_grid/basic.html For each
|
|
* example we inject themeColors.css into the page so all CSS variables
|
|
* that components use are available.
|
|
*/
|
|
|
|
/**
|
|
* We also let the user provide a different base path for any shared
|
|
* resources that we load. But if this is provided along with the
|
|
* componentDocsBaseArg, and the two are the same, we don't want to use the
|
|
* shared resources base, as it's part of the componentDocsBaseArg and
|
|
* therefore the URL is already correct.
|
|
*
|
|
* If we didn't get a componentDocsBaseArg or we did and it's different to
|
|
* the sharedResourcesBase, we use sharedResourcesBase.
|
|
*/
|
|
const baseUrlForSharedResource =
|
|
componentDocsBaseArg && componentDocsBaseArg.endsWith(sharedResourcesBase) ? '/' : `/${sharedResourcesBase}`;
|
|
const fullPath = path.join(componentDocsBaseFolder, filePath);
|
|
if (!(await checkFileExists(fullPath))) {
|
|
send404(response, '404, File not found');
|
|
return;
|
|
}
|
|
const fileContents = await fs.promises.readFile(fullPath, {
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
const linksToStyleSheets = styleSheetPaths
|
|
.map(
|
|
link => `<link rel="stylesheet" href="${
|
|
path.join(
|
|
baseUrlForSharedResource,
|
|
...link.split('/'),
|
|
)}" type="text/css" />`,
|
|
)
|
|
.join('\n');
|
|
|
|
const toggleDarkModeScript = `<script type="module" src="${
|
|
path.join(
|
|
baseUrlForSharedResource,
|
|
'front_end',
|
|
'ui',
|
|
'components',
|
|
'docs',
|
|
'component_docs.js',
|
|
)}"></script>`;
|
|
const newFileContents = fileContents.replace('</head>', `${linksToStyleSheets}</head>`)
|
|
.replace('</body>', toggleDarkModeScript + '\n</body>');
|
|
respondWithHtml(response, newFileContents);
|
|
} else {
|
|
// This means it's an asset like a JS file or an image.
|
|
let fullPath = path.join(componentDocsBaseFolder, filePath);
|
|
if (fullPath.endsWith(path.join('locales', 'en-US.json')) &&
|
|
!componentDocsBaseFolder.includes(sharedResourcesBase)) {
|
|
/**
|
|
* If the path is for locales/en-US.json we special case the loading of that to fix the path so it works properly in the server.
|
|
* We also make sure that we take into account the shared resources base;
|
|
* but if the base folder already contains the shared resources base, we don't
|
|
* add it to the path, because otherwise that would cause the shared resources
|
|
* base to be duplicated in the fullPath.
|
|
*/
|
|
// Rewrite this path so we can load up the locale in the component-docs
|
|
let prefix = componentDocsBaseFolder;
|
|
if (sharedResourcesBase && !componentDocsBaseFolder.includes(sharedResourcesBase)) {
|
|
prefix = path.join(componentDocsBaseFolder, sharedResourcesBase);
|
|
}
|
|
fullPath = path.join(
|
|
prefix,
|
|
'front_end',
|
|
'core',
|
|
'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('.js') || fullPath.endsWith('.mjs')) {
|
|
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';
|
|
} else if (fullPath.endsWith('.gz')) {
|
|
response.setHeader('Content-Type', 'application/gzip');
|
|
encoding = 'binary';
|
|
}
|
|
|
|
const fileContents = await fs.promises.readFile(fullPath, encoding);
|
|
|
|
response.writeHead(200);
|
|
response.write(fileContents, encoding);
|
|
response.end();
|
|
}
|
|
}
|
|
|
|
function createTracesIndexFile(traceFilenames) {
|
|
function pageFunction() {
|
|
const origin = new URL(location.href).origin;
|
|
|
|
document.body.addEventListener('click', async e => {
|
|
if (!e.target.matches('button')) {
|
|
return;
|
|
}
|
|
const filename = e.target.textContent;
|
|
const traceUrl = `${origin}/t/${filename}`;
|
|
const devtoolsLoadingTraceUrl = `devtools://devtools/bundled/devtools_app.html?loadTimelineFromURL=${traceUrl}`;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(devtoolsLoadingTraceUrl);
|
|
e.target.classList.add('clicked');
|
|
setTimeout(() => e.target.classList.remove('clicked'), 1000);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// clang-format off
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width" />
|
|
<title>Traces</title>
|
|
<style>
|
|
button {
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
font-size: 18px;
|
|
padding: 5px;
|
|
text-align: left;
|
|
}
|
|
button:hover {
|
|
background: aliceblue;
|
|
cursor: pointer;
|
|
}
|
|
button:active {cursor: copy;}
|
|
button.clicked {animation: 600ms cubic-bezier(0.65, 0.05, 0.36, 1) bam;}
|
|
button.clicked::after {
|
|
content: "Copied URL";
|
|
background-color: #e0e0e0;
|
|
margin-left: 6px;
|
|
font-size: 70%;
|
|
padding: 1px 3px;
|
|
animation: 500ms fadeOut ease 700ms forwards;
|
|
}
|
|
form {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 5px;
|
|
}
|
|
@keyframes bam {
|
|
from {background-color: #66bb6a;}
|
|
to {background-color: aliceblue;}
|
|
}
|
|
@keyframes fadeOut {
|
|
from {opacity: 1;}
|
|
to {opacity: 0;}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body id="traces-page">
|
|
<h1>First</h1>
|
|
<p><textarea cols=100>devtools://devtools/bundled/devtools_app.html</textarea>
|
|
<h1>Load OPP with fixture traces:</h1>
|
|
|
|
<form>
|
|
${traceFilenames
|
|
.map(filename => {
|
|
return `<button type=button>${filename}</button>`;
|
|
})
|
|
.join('\n')}
|
|
</form>
|
|
|
|
<script>
|
|
(${pageFunction.toString()})();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
// clang-format on
|
|
}
|
|
|
|
/**
|
|
* @param {http.IncomingMessage} request
|
|
* @param {http.ServerResponse} response
|
|
* @param {string|null} filePath
|
|
*/
|
|
async function handleTracesModeRequest(request, response, filePath) {
|
|
const traceFolder = path.resolve(
|
|
path.join(process.cwd(), 'front_end/panels/timeline/fixtures/traces/'),
|
|
);
|
|
if (filePath === '/') {
|
|
const traceFilenames = fs.readdirSync(traceFolder).filter(f => f.includes('json'));
|
|
const html = createTracesIndexFile(traceFilenames);
|
|
respondWithHtml(response, html);
|
|
} else if (filePath.startsWith('/t/')) {
|
|
const fileName = filePath.replace('/t/', '');
|
|
const fullPath = path.resolve(path.join(traceFolder, fileName));
|
|
|
|
if (!fullPath.startsWith(traceFolder)) {
|
|
console.error(`Path ${fullPath} is outside trace fixtures folder.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const fileExists = await checkFileExists(fullPath);
|
|
if (!fileExists) {
|
|
return send404(response, '404, File not found');
|
|
}
|
|
|
|
let encoding = 'utf8';
|
|
if (fullPath.endsWith('.json')) {
|
|
response.setHeader('Content-Type', 'application/json');
|
|
} else if (fullPath.endsWith('.gz')) {
|
|
response.setHeader('Content-Type', 'application/gzip');
|
|
encoding = 'binary';
|
|
}
|
|
// Traces need CORS to be loaded by devtools:// origin
|
|
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
const fileContents = await fs.promises.readFile(fullPath, encoding);
|
|
response.writeHead(200);
|
|
response.write(fileContents, encoding);
|
|
response.end();
|
|
} else {
|
|
console.error(`Unhandled traces mode request: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
}
|