Fork the frontend to build towards cli2 compat (#1853)
The cli2 ("instance") system has a foundationally different assumption about how the frontend works: rather than having a unified frontend that abstracts over many separate SourceCred projects, we'll have a single frontend entry per instance. This means we no longer need (for example) to make project IDs available at build time. Our frontend setup and server side rendering is pretty complex, so rather than rebuild it from scratch, I'm going to fork it into an independent copy and then change it to suit our needs. To start here, I've duplicated the `src/homepage` directory into `src/homepage2`, duplicated the webpack config to `config/webpack.config.web2.js`, and duplicated the paths and package.json scripts. Test plan: Run `yarn start2` and it will start an identical frontend, using the duplicated directory. Run `yarn build2` and it will build a viable frontend into the `build2` directory. `build2` is gitignored.
This commit is contained in:
parent
53d3bd6766
commit
f25bc795c6
|
@ -8,6 +8,7 @@
|
|||
|
||||
# production
|
||||
/build
|
||||
/build2
|
||||
|
||||
# backend
|
||||
/bin
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/bin
|
||||
/build
|
||||
/build2
|
||||
/coverage
|
||||
/dist
|
||||
/flow-typed
|
||||
|
|
|
@ -13,8 +13,11 @@ module.exports = {
|
|||
dotenv: resolveApp(".env"),
|
||||
favicon: resolveApp("src/assets/logo/rasterized/logo_32.png"),
|
||||
appBuild: resolveApp("build"),
|
||||
appBuild2: resolveApp("build2"),
|
||||
appIndexJs: resolveApp("src/homepage/index.js"),
|
||||
appIndexJs2: resolveApp("src/homepage2/index.js"),
|
||||
appServerSideRenderingIndexJs: resolveApp("src/homepage/server.js"),
|
||||
appServerSideRenderingIndexJs2: resolveApp("src/homepage2/server.js"),
|
||||
appPackageJson: resolveApp("package.json"),
|
||||
appSrc: resolveApp("src"),
|
||||
yarnLockFile: resolveApp("yarn.lock"),
|
||||
|
|
|
@ -0,0 +1,255 @@
|
|||
// @flow
|
||||
const express = require("express");
|
||||
/*::
|
||||
import type {
|
||||
$Application as ExpressApp,
|
||||
$Response as ExpressResponse,
|
||||
} from "express";
|
||||
*/
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const RemoveBuildDirectoryPlugin = require("./RemoveBuildDirectoryPlugin");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const ManifestPlugin = require("webpack-manifest-plugin");
|
||||
const StaticSiteGeneratorPlugin = require("static-site-generator-webpack-plugin");
|
||||
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
|
||||
const paths = require("./paths");
|
||||
const getClientEnvironment = require("./env");
|
||||
const _getProjectIds = require("../src/core/_getProjectIds");
|
||||
|
||||
// Source maps are resource heavy and can cause out of memory issue for large source files.
|
||||
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
|
||||
|
||||
function loadProjectIds() /*: Promise<$ReadOnlyArray<string>> */ {
|
||||
const env = process.env.SOURCECRED_DIRECTORY;
|
||||
// TODO(#945): de-duplicate finding the directory with src/cli/common.js
|
||||
const defaultDirectory = path.join(os.tmpdir(), "sourcecred");
|
||||
const scDirectory = env != null ? env : defaultDirectory;
|
||||
return _getProjectIds(scDirectory);
|
||||
}
|
||||
|
||||
async function makeConfig(
|
||||
mode /*: "production" | "development" */
|
||||
) /*: Promise<mixed> */ {
|
||||
return {
|
||||
// Don't attempt to continue if there are any errors.
|
||||
bail: true,
|
||||
// We generate sourcemaps in production. This is slow but gives good results.
|
||||
// You can exclude the *.map files from the build during deployment.
|
||||
devtool: shouldUseSourceMap ? "source-map" : false,
|
||||
// In production, we only want to load the polyfills and the app code.
|
||||
entry: {
|
||||
main: [require.resolve("./polyfills"), paths.appIndexJs2],
|
||||
ssr: [
|
||||
require.resolve("./polyfills"),
|
||||
paths.appServerSideRenderingIndexJs2,
|
||||
],
|
||||
},
|
||||
devServer: {
|
||||
inline: false,
|
||||
before: (app /*: ExpressApp */) => {
|
||||
const apiRoot = "/api/v1/data";
|
||||
const rejectCache = (_unused_req, res /*: ExpressResponse */) => {
|
||||
res.status(400).send("Bad Request: Cache unavailable at runtime\n");
|
||||
};
|
||||
app.get(`${apiRoot}/cache`, rejectCache);
|
||||
app.get(`${apiRoot}/cache/*`, rejectCache);
|
||||
app.use(
|
||||
apiRoot,
|
||||
express.static(
|
||||
process.env.SOURCECRED_DIRECTORY ||
|
||||
path.join(os.tmpdir(), "sourcecred")
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
output: {
|
||||
// The build folder.
|
||||
path: paths.appBuild2,
|
||||
// Generated JS file names (with nested folders).
|
||||
// There will be one main bundle, and one file per asynchronous chunk.
|
||||
// We don't currently advertise code splitting but Webpack supports it.
|
||||
filename: "static/js/[name].[chunkhash:8].js",
|
||||
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
|
||||
// Point sourcemap entries to original disk location (format as URL on Windows)
|
||||
devtoolModuleFilenameTemplate: (
|
||||
info /*:
|
||||
{|
|
||||
// https://webpack.js.org/configuration/output/#output-devtoolmodulefilenametemplate
|
||||
+absoluteResourcePath: string,
|
||||
+allLoaders: string,
|
||||
+hash: string,
|
||||
+id: string,
|
||||
+loaders: string,
|
||||
+resource: string,
|
||||
+resourcePath: string,
|
||||
+namespace: string,
|
||||
|}
|
||||
*/
|
||||
) =>
|
||||
path
|
||||
.relative(paths.appSrc, info.absoluteResourcePath)
|
||||
.replace(/\\/g, "/"),
|
||||
// We need to use a UMD module to build the static site.
|
||||
libraryTarget: "umd",
|
||||
globalObject: "this",
|
||||
},
|
||||
resolve: {
|
||||
// This allows you to set a fallback for where Webpack should look for modules.
|
||||
// We placed these paths second because we want `node_modules` to "win"
|
||||
// if there are any conflicts. This matches Node resolution mechanism.
|
||||
// https://github.com/facebookincubator/create-react-app/issues/253
|
||||
modules: [
|
||||
"node_modules",
|
||||
paths.appNodeModules,
|
||||
...(process.env.NODE_PATH || "").split(path.delimiter).filter(Boolean),
|
||||
],
|
||||
// These are the reasonable defaults supported by the Node ecosystem.
|
||||
// We also include JSX as a common component filename extension to support
|
||||
// some tools, although we do not recommend using it, see:
|
||||
// https://github.com/facebookincubator/create-react-app/issues/290
|
||||
// `web` extension prefixes have been added for better support
|
||||
// for React Native Web.
|
||||
extensions: [".web.js", ".mjs", ".js", ".json", ".web.jsx", ".jsx"],
|
||||
alias: {
|
||||
// Support React Native Web
|
||||
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
|
||||
"react-native": "react-native-web",
|
||||
},
|
||||
plugins: [
|
||||
// Prevents users from importing files from outside of src/ (or node_modules/).
|
||||
// This often causes confusion because we only process files within src/ with babel.
|
||||
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
|
||||
// please link the files into your node_modules/ and let module-resolution kick in.
|
||||
// Make sure your source files are compiled, as they will not be processed in any way.
|
||||
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
|
||||
],
|
||||
},
|
||||
module: {
|
||||
strictExportPresence: true,
|
||||
rules: [
|
||||
// TODO: Disable require.ensure as it's not a standard language feature.
|
||||
// We are waiting for https://github.com/facebookincubator/create-react-app/issues/2176.
|
||||
// { parser: { requireEnsure: false } },
|
||||
{
|
||||
// "oneOf" will traverse all following loaders until one will
|
||||
// match the requirements. When no loader matches it will fall
|
||||
// back to the "file" loader at the end of the loader list.
|
||||
oneOf: [
|
||||
// "url" loader works just like "file" loader but it also embeds
|
||||
// assets smaller than specified size as data URLs to avoid requests.
|
||||
{
|
||||
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
|
||||
loader: require.resolve("url-loader"),
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: "static/media/[name].[hash:8].[ext]",
|
||||
},
|
||||
},
|
||||
// Process JS with Babel.
|
||||
{
|
||||
test: /\.(js|jsx|mjs)$/,
|
||||
include: paths.appSrc,
|
||||
loader: require.resolve("babel-loader"),
|
||||
options: {
|
||||
compact: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
loader: "css-loader", // TODO(@wchargin): add csso-loader
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
exclude: /node_modules/,
|
||||
loader: "svg-react-loader",
|
||||
},
|
||||
// "file" loader makes sure assets end up in the `build` folder.
|
||||
// When you `import` an asset, you get its filename.
|
||||
// This loader doesn't use a "test" so it will catch all modules
|
||||
// that fall through the other loaders.
|
||||
{
|
||||
loader: require.resolve("file-loader"),
|
||||
// Exclude `js` files to keep "css" loader working as it injects
|
||||
// it's runtime that would otherwise processed through "file" loader.
|
||||
// Also exclude `html` and `json` extensions so they get processed
|
||||
// by webpacks internal loaders.
|
||||
exclude: [/\.(js|jsx|mjs)$/, /\.html$/, /\.json$/],
|
||||
options: {
|
||||
name: "static/media/[name].[hash:8].[ext]",
|
||||
},
|
||||
},
|
||||
// ** STOP ** Are you adding a new loader?
|
||||
// Make sure to add the new loader(s) before the "file" loader.
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: await plugins(mode),
|
||||
// Some libraries import Node modules but don't use them in the browser.
|
||||
// Tell Webpack to provide empty mocks for them so importing them works.
|
||||
node: {
|
||||
dgram: "empty",
|
||||
fs: "empty",
|
||||
net: "empty",
|
||||
tls: "empty",
|
||||
child_process: "empty",
|
||||
},
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
async function plugins(mode /*: "development" | "production" */) {
|
||||
const projectIds = await loadProjectIds();
|
||||
const env = getClientEnvironment(projectIds);
|
||||
const basePlugins = [
|
||||
new StaticSiteGeneratorPlugin({
|
||||
entry: "ssr",
|
||||
paths: require("../src/homepage2/routeData")
|
||||
.makeRouteData(projectIds)
|
||||
.map(({path}) => path),
|
||||
locals: {},
|
||||
}),
|
||||
new CopyPlugin([{from: paths.favicon, to: "favicon.png"}]),
|
||||
// Makes some environment variables available to the JS code, for example:
|
||||
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
|
||||
// It is absolutely essential that NODE_ENV was set to production here.
|
||||
// Otherwise React will be compiled in the very slow development mode.
|
||||
new webpack.DefinePlugin(env.stringified),
|
||||
// Generate a manifest file which contains a mapping of all asset filenames
|
||||
// to their corresponding output file so that tools can pick it up without
|
||||
// having to parse `index.html`.
|
||||
new ManifestPlugin({
|
||||
fileName: "asset-manifest.json",
|
||||
}),
|
||||
// Moment.js is an extremely popular library that bundles large locale files
|
||||
// by default due to how Webpack interprets its code. This is a practical
|
||||
// solution that requires the user to opt into importing specific locales.
|
||||
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
|
||||
// You can remove this if you don't use Moment.js:
|
||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
||||
];
|
||||
const prodOnlyPlugins = [
|
||||
// Remove the output directory before starting the build.
|
||||
new RemoveBuildDirectoryPlugin(),
|
||||
];
|
||||
switch (mode) {
|
||||
case "development":
|
||||
return basePlugins;
|
||||
case "production":
|
||||
return basePlugins.concat(prodOnlyPlugins);
|
||||
default:
|
||||
throw new Error(/*:: (*/ mode /*: empty) */);
|
||||
}
|
||||
}
|
||||
|
||||
function getMode() {
|
||||
const mode = process.env.NODE_ENV;
|
||||
if (mode !== "production" && mode !== "development") {
|
||||
throw new Error("unknown mode: " + String(mode));
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
module.exports = makeConfig(getMode());
|
|
@ -92,7 +92,9 @@
|
|||
"prettify": "prettier --write '**/*.js'",
|
||||
"check-pretty": "prettier --list-different '**/*.js'",
|
||||
"start": "NODE_ENV=development webpack-dev-server --config config/webpack.config.web.js",
|
||||
"start2": "NODE_ENV=development webpack-dev-server --config config/webpack.config.web2.js",
|
||||
"build": "NODE_ENV=production webpack --config config/webpack.config.web.js",
|
||||
"build2": "NODE_ENV=production webpack --config config/webpack.config.web2.js",
|
||||
"backend": "NODE_ENV=development webpack --config config/webpack.config.backend.js",
|
||||
"api": "webpack --config config/webpack.config.api.js",
|
||||
"test": "node ./config/test.js",
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import {Router} from "react-router";
|
||||
import type {History /* actually `any` */} from "history";
|
||||
|
||||
import {createRoutes} from "./createRoutes";
|
||||
import {type RouteData, resolveTitleFromPath} from "./routeData";
|
||||
|
||||
export default class App extends React.Component<{|
|
||||
+routeData: RouteData,
|
||||
+history: History,
|
||||
|}> {
|
||||
render() {
|
||||
const {routeData, history} = this.props;
|
||||
return (
|
||||
<Router
|
||||
history={history}
|
||||
routes={createRoutes(routeData)}
|
||||
onUpdate={function () {
|
||||
const router = this;
|
||||
const path: string = router.state.location.pathname;
|
||||
document.title = resolveTitleFromPath(routeData, path);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
// @flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {|
|
||||
+className: string,
|
||||
+altText: string,
|
||||
|};
|
||||
|
||||
export default function DiscordLogo(props: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 245 240"
|
||||
aria-label={props.altText}
|
||||
role="img"
|
||||
className={props.className}
|
||||
>
|
||||
<path d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z" />
|
||||
<path d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// @flow
|
||||
|
||||
import React from "react";
|
||||
|
||||
import Link from "../webutil/Link";
|
||||
|
||||
export default class ExternalRedirect extends React.Component<{|
|
||||
+redirectTo: string,
|
||||
|}> {
|
||||
render() {
|
||||
return (
|
||||
<div style={{maxWidth: 900, margin: "0 auto"}}>
|
||||
<h1>Redirecting…</h1>
|
||||
<p>
|
||||
Redirecting to:{" "}
|
||||
<Link href={this.props.redirectTo}>{this.props.redirectTo}</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// The server-rendered copy of this page will have a meta-refresh
|
||||
// tag, but someone could still plausibly navigate to this page with
|
||||
// the client-side renderer. In that case, we should redirect them.
|
||||
window.location.href = this.props.redirectTo;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
// @flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {|
|
||||
+className: string,
|
||||
+altText: string,
|
||||
|};
|
||||
|
||||
export default function GithubLogo(props: Props) {
|
||||
return (
|
||||
<svg
|
||||
aria-label={props.altText}
|
||||
role="img"
|
||||
className={props.className}
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>{props.altText}</title>
|
||||
<path d="M512 0C229.25 0 0 229.25 0 512c0 226.25 146.688 418.125 350.156 485.812 25.594 4.688 34.938-11.125 34.938-24.625 0-12.188-0.469-52.562-0.719-95.312C242 908.812 211.906 817.5 211.906 817.5c-23.312-59.125-56.844-74.875-56.844-74.875-46.531-31.75 3.53-31.125 3.53-31.125 51.406 3.562 78.47 52.75 78.47 52.75 45.688 78.25 119.875 55.625 149 42.5 4.654-33 17.904-55.625 32.5-68.375C304.906 725.438 185.344 681.5 185.344 485.312c0-55.938 19.969-101.562 52.656-137.406-5.219-13-22.844-65.094 5.062-135.562 0 0 42.938-13.75 140.812 52.5 40.812-11.406 84.594-17.031 128.125-17.219 43.5 0.188 87.312 5.875 128.188 17.281 97.688-66.312 140.688-52.5 140.688-52.5 28 70.531 10.375 122.562 5.125 135.5 32.812 35.844 52.625 81.469 52.625 137.406 0 196.688-119.75 240-233.812 252.688 18.438 15.875 34.75 47 34.75 94.75 0 68.438-0.688 123.625-0.688 140.5 0 13.625 9.312 29.562 35.25 24.562C877.438 930 1024 738.125 1024 512 1024 229.25 794.75 0 512 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
// @flow
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type {Assets} from "../webutil/assets";
|
||||
import Link from "../webutil/Link";
|
||||
import {StyleSheet, css} from "aphrodite/no-important";
|
||||
|
||||
export default class HomePage extends React.Component<{|+assets: Assets|}> {
|
||||
render() {
|
||||
const urls = {
|
||||
numpyFunding:
|
||||
"https://numfocus.org/blog/numpy-receives-first-ever-funding-thanks-to-moore-foundation",
|
||||
opensslFunding:
|
||||
"https://arstechnica.com/information-technology/2014/04/tech-giants-chastened-by-heartbleed-finally-agree-to-fund-openssl/",
|
||||
graph: "https://en.wikipedia.org/wiki/Graph_(discrete_mathematics)",
|
||||
pagerank: "https://en.wikipedia.org/wiki/PageRank",
|
||||
ast: "https://en.wikipedia.org/wiki/Abstract_syntax_tree",
|
||||
protocolLabs: "https://protocol.ai/",
|
||||
discord: "https://discord.gg/tsBTgc9",
|
||||
github: "https://github.com/sourcecred/sourcecred",
|
||||
contributionsWelcome:
|
||||
"https://github.com/sourcecred/sourcecred/issues?q=is%3Aissue+is%3Aopen+label%3A%22contributions+welcome%22",
|
||||
readme: "https://github.com/sourcecred/sourcecred/blob/master/README.md",
|
||||
};
|
||||
return (
|
||||
<div className={css(styles.container)}>
|
||||
<h1>SourceCred vision</h1>
|
||||
<p>
|
||||
<strong>The open-source movement is amazing. </strong>
|
||||
It’s inspiring that some of our best technology is developed in the
|
||||
open and available to everyone.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Despite all the value provided by open-source projects, many are
|
||||
chronically underfunded. For example, NumPy{" "}
|
||||
<Link href={urls.numpyFunding}>
|
||||
received no funding at all until 2017
|
||||
</Link>
|
||||
, and{" "}
|
||||
<Link href={urls.opensslFunding}>
|
||||
a world where OpenSSL was funded might have been a world without
|
||||
Heartbleed
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
These projects also impose a heavy burden on maintainers. Popular
|
||||
projects have hundreds or thousands of open issues, with many new ones
|
||||
being created every day, and only a few overworked volunteers trying
|
||||
to triage and respond to them. Burnout is inevitable.
|
||||
</p>
|
||||
|
||||
<p>SourceCred is our attempt to help.</p>
|
||||
|
||||
<h2>Mission</h2>
|
||||
<p>
|
||||
SourceCred aims to empower open-source developers and communities by
|
||||
creating a project-specific reputation metric called <em>cred</em>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
A project’s contributors earn cred for helping out. For example, a
|
||||
project might reward:
|
||||
</p>
|
||||
<ul style={{marginTop: "-1.5ex"}}>
|
||||
<li>Triaging issues</li>
|
||||
<li>Maintaining the build</li>
|
||||
<li>Fixing bugs</li>
|
||||
<li>Writing documentation</li>
|
||||
<li>Refactoring code</li>
|
||||
<li>Adding features</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
SourceCred will build social capital within communities, recognize
|
||||
their hardworking contributors, and encourage more people to help
|
||||
maintain and develop open-source projects.
|
||||
</p>
|
||||
|
||||
<p>We’re designing SourceCred around the following four principles:</p>
|
||||
|
||||
<dl>
|
||||
<Dt>Transparency</Dt>
|
||||
<Dd>
|
||||
It should be easy to see why cred is attributed as it is, and link a
|
||||
person’s cred directly to contributions they’ve made.
|
||||
</Dd>
|
||||
<Dt>Extensibility</Dt>
|
||||
<Dd>
|
||||
SourceCred is designed around a plugin architecture, so you can add
|
||||
support for new data sources, new algorithms, or even entirely new
|
||||
kinds of work.
|
||||
</Dd>
|
||||
<Dt>Community control</Dt>
|
||||
<Dd>
|
||||
Each community has the final say on that community’s cred. When the
|
||||
algorithm and the community disagree, the community wins.
|
||||
</Dd>
|
||||
<Dt>Decentralization</Dt>
|
||||
<Dd>
|
||||
Projects own their own data, and control their own cred. The
|
||||
SourceCred project provides tools, but has no control.
|
||||
</Dd>
|
||||
</dl>
|
||||
|
||||
<h2>How cred works</h2>
|
||||
<p>
|
||||
Cred is computed by first creating a contribution{" "}
|
||||
<Link href={urls.graph}>graph</Link>, which contains every
|
||||
contribution to the project and the relations among them. For example,
|
||||
GitHub issues, Git commits, and individual files and functions can be
|
||||
included in the graph. Then, SourceCred runs a modified version of{" "}
|
||||
<Link href={urls.pagerank}>PageRank</Link> on that graph to produce a
|
||||
cred attribution. The attribution is highly configurable; project
|
||||
maintainers can add new heuristics and adjust weights.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This approach satisfies our four principles. It’s transparent: you can
|
||||
always see how a node’s weight dervies from its neighbors. It’s
|
||||
extensible: plugins can embed new types of nodes and edges into the
|
||||
graph. It’s community-controlled: the weights, heuristics, and
|
||||
algorithms are all configured by the project. Finally, it’s
|
||||
decentralized: every project can run its own instance.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Naturally, there will be attempts to game the system. We’ll provide
|
||||
tools that make it obvious when people are gaming their cred, and
|
||||
empower maintainers to moderate and correct the attribution when
|
||||
needed. In case of deeply contentious disagreements, cred can be
|
||||
forked alongside the project.
|
||||
</p>
|
||||
|
||||
<h2>Roadmap</h2>
|
||||
<p>
|
||||
SourceCred is under active development.{" "}
|
||||
<Link to="/prototype/">We have a prototype</Link> that ingests data
|
||||
from Git and GitHub, computes cred, and allows the user to explore and
|
||||
experiment on the results. We have a long way to go to realize
|
||||
SourceCred’s full vision, but the prototype can already surface some
|
||||
interesting insights!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
In the near term, we want to help with issue triage and
|
||||
prioritization. Open-source projects are drowning in issues; many
|
||||
people file them, but few are motivated to triage them. We want to
|
||||
recognize the people who show up to do that work, and reward them by
|
||||
giving them more influence over issue prioritization.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
In the longer term, we will continue to add signal to cred
|
||||
attribution. For example, we plan to parse the{" "}
|
||||
<Link href={urls.ast}>AST</Link> of a project’s code so that we can
|
||||
attribute cred at the level of individual functions, and create a
|
||||
“spotlight” mechanic that will let contributors flow more cred to
|
||||
their peers’ important contributions. As SourceCred improves, we have
|
||||
plans for how to use it to help open-source projects become
|
||||
financially sustainable.
|
||||
</p>
|
||||
|
||||
<h2>About</h2>
|
||||
<p>
|
||||
SourceCred is an open-source project, and is committed to being
|
||||
decentralized. We don’t think communities should have to give their
|
||||
data to us, or entrust us with control over their cred. The lead
|
||||
developers are grateful to be supported by{" "}
|
||||
<Link href={urls.protocolLabs}>Protocol Labs</Link>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you think this vision is exciting, we’d love for you to get
|
||||
involved! You can join our <Link href={urls.discord}>Discord</Link>{" "}
|
||||
and check out our <Link href={urls.github}>GitHub</Link>—many of our
|
||||
issues are marked{" "}
|
||||
<Link href={urls.contributionsWelcome}>contributions welcome</Link>.
|
||||
If you want to try running SourceCred on open-source projects you care
|
||||
about, check out <Link href={urls.readme}>our README</Link>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Dt(props) {
|
||||
return <dt style={{fontWeight: "bold"}}>{props.children}</dt>;
|
||||
}
|
||||
|
||||
function Dd(props) {
|
||||
return <dd style={{marginBottom: 15}}>{props.children}</dd>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
marginBottom: 200,
|
||||
padding: "0 10px",
|
||||
lineHeight: 1.5,
|
||||
fontSize: 20,
|
||||
},
|
||||
});
|
|
@ -0,0 +1,162 @@
|
|||
// @flow
|
||||
|
||||
import React, {type Node} from "react";
|
||||
import {StyleSheet, css} from "aphrodite/no-important";
|
||||
|
||||
import type {Assets} from "../webutil/assets";
|
||||
import Colors from "../webutil/Colors";
|
||||
import Link from "../webutil/Link";
|
||||
import GithubLogo from "./GithubLogo";
|
||||
import TwitterLogo from "./TwitterLogo";
|
||||
import DiscordLogo from "./DiscordLogo";
|
||||
import type {RouteData} from "./routeData";
|
||||
import * as NullUtil from "../util/null";
|
||||
import {VERSION_SHORT, VERSION_FULL} from "../core/version";
|
||||
|
||||
export default class Page extends React.Component<{|
|
||||
+assets: Assets,
|
||||
+routeData: RouteData,
|
||||
+children: Node,
|
||||
|}> {
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className={css(style.nonFooter)}>
|
||||
<header>
|
||||
<nav className={css(style.nav)}>
|
||||
<ul className={css(style.navList)}>
|
||||
<li className={css(style.navItem, style.navItemLeft)}>
|
||||
<Link to="/" styles={[style.navLink, style.navLinkTitle]}>
|
||||
SourceCred
|
||||
</Link>
|
||||
</li>
|
||||
{this.props.routeData.map(({navTitle, path}) =>
|
||||
NullUtil.map(navTitle, (navTitle) => (
|
||||
<li
|
||||
key={path}
|
||||
className={css(style.navItem, style.navItemRight)}
|
||||
>
|
||||
<Link to={path} styles={[style.navLink]}>
|
||||
{navTitle}
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
<li className={css(style.navItem, style.navItemRight)}>
|
||||
<Link
|
||||
styles={[style.navLink]}
|
||||
href="https://github.com/sourcecred/sourcecred"
|
||||
>
|
||||
<GithubLogo
|
||||
altText="SourceCred Github"
|
||||
className={css(style.navLogoSmall)}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={css(style.navItem, style.navItemRight)}>
|
||||
<Link
|
||||
styles={[style.navLink]}
|
||||
href="https://twitter.com/sourcecred"
|
||||
>
|
||||
<TwitterLogo
|
||||
altText="SourceCred Twitter"
|
||||
className={css(style.navLogoSmall)}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={css(style.navItem, style.navItemRightSmall)}>
|
||||
<Link
|
||||
styles={[style.navLink]}
|
||||
href="https://discordapp.com/invite/tsBTgc9"
|
||||
>
|
||||
<DiscordLogo
|
||||
altText="Join the SourceCred Discord"
|
||||
className={css(style.navLogoMedium)}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>{this.props.children}</main>
|
||||
</div>
|
||||
<footer className={css(style.footer)}>
|
||||
<div className={css(style.footerWrapper)}>
|
||||
<span className={css(style.footerText)}>
|
||||
({VERSION_FULL}) <strong>{VERSION_SHORT}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const footerHeight = 30;
|
||||
const style = StyleSheet.create({
|
||||
footer: {
|
||||
color: "#666",
|
||||
height: footerHeight,
|
||||
fontSize: 14,
|
||||
position: "relative",
|
||||
},
|
||||
footerWrapper: {
|
||||
textAlign: "right",
|
||||
position: "absolute",
|
||||
bottom: 5,
|
||||
width: "100%",
|
||||
},
|
||||
footerText: {
|
||||
marginRight: 5,
|
||||
},
|
||||
nonFooter: {
|
||||
minHeight: `calc(100vh - ${footerHeight}px)`,
|
||||
},
|
||||
nav: {
|
||||
padding: "20px 50px 0 50px",
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
},
|
||||
navLinkTitle: {
|
||||
fontSize: 24,
|
||||
},
|
||||
navItem: {
|
||||
display: "inline-block",
|
||||
},
|
||||
navList: {
|
||||
listStyle: "none",
|
||||
paddingLeft: 0,
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
},
|
||||
navLink: {
|
||||
fontFamily: "Roboto Condensed",
|
||||
fontSize: 18,
|
||||
textDecoration: "none",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
":visited:not(:active)": {
|
||||
color: Colors.brand.medium,
|
||||
fill: Colors.brand.medium, // for SVG icons
|
||||
},
|
||||
},
|
||||
navItemLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
navItemRight: {
|
||||
marginLeft: 20,
|
||||
},
|
||||
navItemRightSmall: {
|
||||
marginLeft: 15,
|
||||
},
|
||||
navLogoSmall: {
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
navLogoMedium: {
|
||||
height: 25,
|
||||
width: 25,
|
||||
transform: "translateY(-1px)",
|
||||
},
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
// @flow
|
||||
|
||||
import React, {type ComponentType} from "react";
|
||||
|
||||
import type {Assets} from "../webutil/assets";
|
||||
import HomepageExplorer from "./homepageExplorer";
|
||||
|
||||
export default function makeProjectPage(
|
||||
projectId: string
|
||||
): ComponentType<{|+assets: Assets|}> {
|
||||
return class ProjectPage extends React.Component<{|+assets: Assets|}> {
|
||||
render() {
|
||||
return (
|
||||
<HomepageExplorer assets={this.props.assets} projectId={projectId} />
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
// @flow
|
||||
|
||||
import React, {type ComponentType} from "react";
|
||||
|
||||
import Link from "../webutil/Link";
|
||||
import type {Assets} from "../webutil/assets";
|
||||
|
||||
export default function makePrototypesPage(
|
||||
projectIds: $ReadOnlyArray<string>
|
||||
): ComponentType<{|+assets: Assets|}> {
|
||||
return class PrototypesPage extends React.Component<{|+assets: Assets|}> {
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
padding: "0 10px",
|
||||
lineHeight: 1.5,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<p>Select a project:</p>
|
||||
<ul>
|
||||
{projectIds.map((projectId) => (
|
||||
<li key={projectId}>
|
||||
<Link to={`/timeline/${projectId}/`}>{`${projectId}`}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
// @flow
|
||||
|
||||
import React, {type ComponentType} from "react";
|
||||
|
||||
import type {Assets} from "../webutil/assets";
|
||||
import HomepageTimeline from "./homepageTimeline";
|
||||
|
||||
export default function makeTimelinePage(
|
||||
projectId: string
|
||||
): ComponentType<{|+assets: Assets|}> {
|
||||
return class TimelinePage extends React.Component<{|+assets: Assets|}> {
|
||||
render() {
|
||||
return (
|
||||
<HomepageTimeline assets={this.props.assets} projectId={projectId} />
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
// @flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {|
|
||||
+className: string,
|
||||
+altText: string,
|
||||
|};
|
||||
|
||||
export default function TwitterLogo(props: Props) {
|
||||
return (
|
||||
<svg
|
||||
aria-label={props.altText}
|
||||
role="img"
|
||||
className={props.className}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<title>{props.altText}</title>
|
||||
<path
|
||||
d="M512,97.209c-18.838,8.354-39.082,14.001-60.329,16.54c21.687-13,38.343-33.585,46.187-58.114
|
||||
c-20.299,12.038-42.778,20.779-66.705,25.489c-19.16-20.415-46.461-33.17-76.674-33.17c-58.012,0-105.043,47.029-105.043,105.039
|
||||
c0,8.233,0.929,16.25,2.72,23.939c-87.3-4.382-164.701-46.2-216.509-109.753c-9.042,15.514-14.224,33.558-14.224,52.809
|
||||
c0,36.444,18.544,68.596,46.73,87.433c-17.219-0.546-33.416-5.271-47.577-13.139c-0.01,0.438-0.01,0.878-0.01,1.321
|
||||
c0,50.894,36.209,93.348,84.261,103c-8.813,2.398-18.094,3.686-27.674,3.686c-6.77,0-13.349-0.66-19.764-1.887
|
||||
c13.367,41.73,52.159,72.104,98.126,72.949c-35.95,28.175-81.243,44.967-130.458,44.967c-8.479,0-16.841-0.497-25.059-1.471
|
||||
c46.486,29.806,101.701,47.197,161.021,47.197c193.211,0,298.868-160.063,298.868-298.873c0-4.554-0.104-9.084-0.305-13.59
|
||||
C480.11,136.773,497.918,118.273,512,97.209z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// @flow
|
||||
|
||||
import {type RouteData, makeRouteData} from "./routeData";
|
||||
|
||||
export default function createRouteDataFromEnvironment(): RouteData {
|
||||
const raw = process.env.PROJECT_IDS;
|
||||
if (raw == null) {
|
||||
throw new Error("fatal: project IDs unset");
|
||||
}
|
||||
const ids: $ReadOnlyArray<string> = JSON.parse(raw);
|
||||
return makeRouteData(ids);
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
// @flow
|
||||
|
||||
import React from "react";
|
||||
import {IndexRoute, Route} from "react-router";
|
||||
|
||||
import withAssets from "../webutil/withAssets";
|
||||
import ExternalRedirect from "./ExternalRedirect";
|
||||
import Page from "./Page";
|
||||
import type {RouteData} from "./routeData";
|
||||
|
||||
export function createRoutes(routeData: RouteData) {
|
||||
const PageWithAssets = withAssets(Page);
|
||||
const PageWithRoutes = (props) => (
|
||||
<PageWithAssets routeData={routeData} {...props} />
|
||||
);
|
||||
return (
|
||||
<Route path="/" component={PageWithRoutes}>
|
||||
{routeData.map(({path, contents}) => {
|
||||
switch (contents.type) {
|
||||
case "PAGE":
|
||||
if (path === "/") {
|
||||
return (
|
||||
<IndexRoute
|
||||
key={path}
|
||||
component={withAssets(contents.component())}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
component={withAssets(contents.component())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "EXTERNAL_REDIRECT":
|
||||
return (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
component={() => (
|
||||
<ExternalRedirect redirectTo={contents.redirectTo} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error((contents.type: empty));
|
||||
}
|
||||
})}
|
||||
</Route>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
// @flow
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type {Assets} from "../webutil/assets";
|
||||
import {AppPage} from "../explorer/legacy/App";
|
||||
|
||||
export default class HomepageExplorer extends React.Component<{|
|
||||
+assets: Assets,
|
||||
+projectId: string,
|
||||
|}> {
|
||||
render() {
|
||||
return (
|
||||
<AppPage assets={this.props.assets} projectId={this.props.projectId} />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
// @flow
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type {Assets} from "../webutil/assets";
|
||||
import {TimelineApp, defaultLoader} from "../explorer/TimelineApp";
|
||||
|
||||
export default class TimelineExplorer extends React.Component<{|
|
||||
+assets: Assets,
|
||||
+projectId: string,
|
||||
|}> {
|
||||
render() {
|
||||
return (
|
||||
<TimelineApp
|
||||
assets={this.props.assets}
|
||||
projectId={this.props.projectId}
|
||||
loader={defaultLoader}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
// @flow
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import createBrowserHistory from "history/lib/createBrowserHistory";
|
||||
|
||||
import normalize from "../util/pathNormalize";
|
||||
import createRelativeHistory from "../webutil/createRelativeHistory";
|
||||
import App from "./App";
|
||||
import createRouteDataFromEnvironment from "./createRouteDataFromEnvironment";
|
||||
|
||||
const target = document.getElementById("root");
|
||||
if (target == null) {
|
||||
throw new Error("Unable to find root element!");
|
||||
}
|
||||
|
||||
let initialRoot: string = target.dataset.initialRoot;
|
||||
if (initialRoot == null) {
|
||||
console.error(
|
||||
`Initial root unset (${initialRoot}): this should not happen! ` +
|
||||
'Falling back to ".".'
|
||||
);
|
||||
initialRoot = ".";
|
||||
}
|
||||
const basename = normalize(`${window.location.pathname}/${initialRoot}/`);
|
||||
const history = createRelativeHistory(createBrowserHistory(), basename);
|
||||
|
||||
const routeData = createRouteDataFromEnvironment();
|
||||
ReactDOM.hydrate(<App routeData={routeData} history={history} />, target);
|
||||
|
||||
// In Chrome, relative favicon URLs are recomputed at every pushState,
|
||||
// although other assets (like the `src` of an `img`) are not. We don't
|
||||
// want to have to keep the shortcut icon's path up to date as we
|
||||
// transition; it's simpler to make it absolute at page load.
|
||||
for (const el of document.querySelectorAll('link[rel="shortcut icon"]')) {
|
||||
const link: HTMLLinkElement = (el: any);
|
||||
// (Appearances aside, this is not a no-op.)
|
||||
link.href = link.href; // eslint-disable-line no-self-assign
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
// @flow
|
||||
|
||||
// NOTE: This module must be written in vanilla ECMAScript that can be
|
||||
// run by Node without a preprocessor. That means that we use `exports`
|
||||
// and `require` instead of ECMAScript module keywords, we lazy-load all
|
||||
// dependent modules, and we use the Flow comment syntax instead of the
|
||||
// inline syntax.
|
||||
|
||||
/*::
|
||||
import type {Assets} from "../webutil/assets";
|
||||
|
||||
type RouteDatum = {|
|
||||
+path: string,
|
||||
+contents:
|
||||
| {|
|
||||
+type: "PAGE",
|
||||
+component: () => React$ComponentType<{|+assets: Assets|}>,
|
||||
|}
|
||||
| {|
|
||||
+type: "EXTERNAL_REDIRECT",
|
||||
+redirectTo: string,
|
||||
|},
|
||||
+title: string,
|
||||
+navTitle: ?string,
|
||||
|};
|
||||
export type RouteData = $ReadOnlyArray<RouteDatum>;
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds an 'Inspection Test', which is a standalone React component
|
||||
* which allows us to manually inspect some frontend behavior.
|
||||
*
|
||||
* Writing inspection tests is especially convenient for cases where it's
|
||||
* easy to verify that a component is working properly by manually interacting
|
||||
* with it, but hard/expensive to test automatically.
|
||||
*
|
||||
* An example is a FileUploader component which uploads a file from the user,
|
||||
* goes through the FileReader API, etc.
|
||||
*
|
||||
* TODO([#1148]): Improve the inspection testing system (e.g. so we can access
|
||||
* a list of all tests from the frontend), and separate it from serving the
|
||||
* homepage.
|
||||
*
|
||||
* [#1148]: https://github.com/sourcecred/sourcecred/issues/1148
|
||||
*/
|
||||
function inspectionTestFor(name, component) /*: RouteDatum */ {
|
||||
return {
|
||||
path: "/test/" + name + "/",
|
||||
contents: {
|
||||
type: "PAGE",
|
||||
component: component,
|
||||
},
|
||||
title: "Inspection test for: " + name,
|
||||
navTitle: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRouteData(
|
||||
projectIds /*: $ReadOnlyArray<string> */
|
||||
) /*: RouteData */ {
|
||||
return [
|
||||
{
|
||||
path: "/",
|
||||
contents: {
|
||||
type: "PAGE",
|
||||
component: () => require("./HomePage").default,
|
||||
},
|
||||
title: "SourceCred",
|
||||
navTitle: "Home",
|
||||
},
|
||||
{
|
||||
path: "/prototype/",
|
||||
contents: {
|
||||
type: "PAGE",
|
||||
component: () => require("./PrototypesPage").default(projectIds),
|
||||
},
|
||||
title: "SourceCred prototype",
|
||||
navTitle: "Prototype",
|
||||
},
|
||||
...projectIds.map((id) => ({
|
||||
path: `/prototype/${id}/`,
|
||||
contents: {
|
||||
type: "PAGE",
|
||||
component: () => require("./ProjectPage").default(id),
|
||||
},
|
||||
title: `${id} • SourceCred`,
|
||||
navTitle: null,
|
||||
})),
|
||||
...projectIds.map((id) => ({
|
||||
path: `/timeline/${id}/`,
|
||||
contents: {
|
||||
type: "PAGE",
|
||||
component: () => require("./TimelinePage").default(id),
|
||||
},
|
||||
title: `${id} • Timeline`,
|
||||
navTitle: null,
|
||||
})),
|
||||
{
|
||||
path: "/discord-invite/",
|
||||
contents: {
|
||||
type: "EXTERNAL_REDIRECT",
|
||||
redirectTo: "https://discord.gg/tsBTgc9",
|
||||
},
|
||||
title: "SourceCred Discord invite",
|
||||
navTitle: null,
|
||||
},
|
||||
// Inspection Tests Below //
|
||||
inspectionTestFor(
|
||||
"FileUploader",
|
||||
() => require("../util/FileUploaderInspectionTest").default
|
||||
),
|
||||
inspectionTestFor(
|
||||
"TimelineCredView",
|
||||
() => require("../explorer/TimelineCredViewInspectionTest").default
|
||||
),
|
||||
];
|
||||
}
|
||||
exports.makeRouteData = makeRouteData;
|
||||
|
||||
function resolveRouteFromPath(
|
||||
routeData /*: RouteData */,
|
||||
path /*: string */
|
||||
) /*: ?RouteDatum */ {
|
||||
const matches = (candidateRoute) => {
|
||||
const candidatePath = candidateRoute.path;
|
||||
const start = path.substring(0, candidatePath.length);
|
||||
const end = path.substring(candidatePath.length);
|
||||
return start === candidatePath && (end.length === 0 || end === "/");
|
||||
};
|
||||
return routeData.filter(matches)[0] || null;
|
||||
}
|
||||
exports.resolveRouteFromPath = resolveRouteFromPath;
|
||||
|
||||
function resolveTitleFromPath(
|
||||
routeData /*: RouteData */,
|
||||
path /*: string */
|
||||
) /*: string */ {
|
||||
const route = resolveRouteFromPath(routeData, path);
|
||||
const fallback = "SourceCred";
|
||||
return route ? route.title : fallback;
|
||||
}
|
||||
exports.resolveTitleFromPath = resolveTitleFromPath;
|
|
@ -0,0 +1,51 @@
|
|||
// @flow
|
||||
|
||||
import {makeRouteData} from "./routeData";
|
||||
|
||||
describe("homepage2/routeData", () => {
|
||||
function routeData() {
|
||||
return makeRouteData([
|
||||
"sourcecred-test/example-github",
|
||||
"sourcecred/sourcecred",
|
||||
]);
|
||||
}
|
||||
|
||||
/*
|
||||
* React Router doesn't support relative paths. I'm not sure exactly
|
||||
* what a path without a leading slash would do; it's asking for
|
||||
* trouble. If we need them, we can reconsider this test.
|
||||
*/
|
||||
it("every path has a leading slash", () => {
|
||||
for (const route of routeData()) {
|
||||
if (!route.path.startsWith("/")) {
|
||||
expect(route.path).toEqual("/" + route.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* A route representing a page should have a trailing slash so that
|
||||
* relative links work in the expected way. For instance, a route
|
||||
* "/about/team/" may reference "/about/logo.png" via "../logo.png".
|
||||
* But for the route "/about/team", "../logo.png" refers instead to
|
||||
* "/logo.png", which is not the intended semantics. Therefore, we
|
||||
* should consistently either include or omit trailing slashes to
|
||||
* avoid confusion.
|
||||
*
|
||||
* The choice is made for us by the fact that many web servers
|
||||
* (prominently, GitHub Pages and Python's SimpleHTTPServer) redirect
|
||||
* "/foo" to "/foo/" when serving "/foo/index.html".
|
||||
*
|
||||
* In theory, we might have some file routes like "/about/data.csv"
|
||||
* that we actually want to appear without a trailing slash. But those
|
||||
* are outside the scope of our React application, and should be
|
||||
* handled by a different pipeline (e.g., `copy-webpack-plugin`).
|
||||
*/
|
||||
it("every path has a trailing slash", () => {
|
||||
for (const route of routeData()) {
|
||||
if (!route.path.endsWith("/")) {
|
||||
expect(route.path).toEqual(route.path + "/");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,110 @@
|
|||
// @flow
|
||||
|
||||
import {StyleSheetServer} from "aphrodite/no-important";
|
||||
import createMemoryHistory from "history/lib/createMemoryHistory";
|
||||
import React from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import {match, RouterContext} from "react-router";
|
||||
|
||||
import dedent from "../util/dedent";
|
||||
import {Assets, rootFromPath} from "../webutil/assets";
|
||||
import createRelativeHistory from "../webutil/createRelativeHistory";
|
||||
import ExternalRedirect from "./ExternalRedirect";
|
||||
import Page from "./Page";
|
||||
import createRouteDataFromEnvironment from "./createRouteDataFromEnvironment";
|
||||
import {createRoutes} from "./createRoutes";
|
||||
import {resolveRouteFromPath, resolveTitleFromPath} from "./routeData";
|
||||
|
||||
// Side effect for testing purposes
|
||||
console.log(`PROJECT_IDS: ${process.env.PROJECT_IDS || "bad"}`);
|
||||
|
||||
// NOTE: This is a side-effect at module load time.
|
||||
const routeData = createRouteDataFromEnvironment();
|
||||
|
||||
export default function render(
|
||||
locals: {+path: string, +assets: {[string]: string}},
|
||||
callback: (error: ?mixed, result?: string) => void
|
||||
): void {
|
||||
const path = locals.path;
|
||||
const root = rootFromPath(path);
|
||||
const assets = new Assets(root);
|
||||
const history = createRelativeHistory(createMemoryHistory(path), "/");
|
||||
{
|
||||
const route = resolveRouteFromPath(routeData, path);
|
||||
if (route && route.contents.type === "EXTERNAL_REDIRECT") {
|
||||
return renderRedirect(route.contents.redirectTo);
|
||||
} else {
|
||||
return renderStandardRoute();
|
||||
}
|
||||
}
|
||||
|
||||
function renderStandardRoute() {
|
||||
const bundlePath = locals.assets["main"];
|
||||
const routes = createRoutes(routeData);
|
||||
match({history, routes}, (error, redirectLocation, renderProps) => {
|
||||
if (error) {
|
||||
callback(error);
|
||||
} else if (renderProps) {
|
||||
const component = <RouterContext {...renderProps} />;
|
||||
const {html, css} = StyleSheetServer.renderStatic(() =>
|
||||
ReactDOMServer.renderToString(component)
|
||||
);
|
||||
const page = dedent`\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="shortcut icon" href="${assets.resolve("/favicon.png")}" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed" rel="stylesheet">
|
||||
<title>${resolveTitleFromPath(routeData, path)}</title>
|
||||
<style>${require("./index.css")}</style>
|
||||
<style data-aphrodite>${css.content}</style>
|
||||
</head>
|
||||
<body style="overflow-y:scroll">
|
||||
<div id="root" data-initial-root="${root}">${html}</div>
|
||||
<script src="${assets.resolve(bundlePath)}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
callback(null, page);
|
||||
} else {
|
||||
// This shouldn't happen because we should only be visiting
|
||||
// the right routes.
|
||||
throw new Error(`unexpected 404 from ${path}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRedirect(redirectTo: string) {
|
||||
const component = (
|
||||
<Page routeData={routeData} assets={assets}>
|
||||
<ExternalRedirect redirectTo={redirectTo} />
|
||||
</Page>
|
||||
);
|
||||
const {html, css} = StyleSheetServer.renderStatic(() =>
|
||||
ReactDOMServer.renderToStaticMarkup(component)
|
||||
);
|
||||
const page = dedent`\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="0;url=${redirectTo}" />
|
||||
<link rel="shortcut icon" href="${assets.resolve("favicon.png")}" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed" rel="stylesheet">
|
||||
<title>${resolveTitleFromPath(routeData, path)}</title>
|
||||
<style>${require("./index.css")}</style>
|
||||
<style data-aphrodite>${css.content}</style>
|
||||
</head>
|
||||
<body style="overflow-y:scroll">
|
||||
<div id="root">${html}</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
callback(null, page);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue