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:
Dandelion Mané 2020-06-17 18:44:34 -07:00 committed by GitHub
parent 53d3bd6766
commit f25bc795c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1285 additions and 0 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@
# production
/build
/build2
# backend
/bin

View File

@ -1,5 +1,6 @@
/bin
/build
/build2
/coverage
/dist
/flow-typed

View File

@ -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"),

View File

@ -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());

View File

@ -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",

28
src/homepage2/App.js Normal file
View File

@ -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);
}}
/>
);
}
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

207
src/homepage2/HomePage.js Normal file
View File

@ -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>
Its 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 projects 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>Were 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
persons cred directly to contributions theyve 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 communitys 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. Its transparent: you can
always see how a nodes weight dervies from its neighbors. Its
extensible: plugins can embed new types of nodes and edges into the
graph. Its community-controlled: the weights, heuristics, and
algorithms are all configured by the project. Finally, its
decentralized: every project can run its own instance.
</p>
<p>
Naturally, there will be attempts to game the system. Well 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
SourceCreds 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 projects 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 dont 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, wed 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,
},
});

162
src/homepage2/Page.js Normal file
View File

@ -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)",
},
});

View File

@ -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} />
);
}
};
}

View File

@ -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>
);
}
};
}

View File

@ -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} />
);
}
};
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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} />
);
}
}

View File

@ -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}
/>
);
}
}

5
src/homepage2/index.css Normal file
View File

@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}

38
src/homepage2/index.js Normal file
View File

@ -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
}

142
src/homepage2/routeData.js Normal file
View File

@ -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;

View File

@ -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 + "/");
}
}
});
});

110
src/homepage2/server.js Normal file
View File

@ -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);
}
}