From f25bc795c6a1ef237153ac84f7ff28d7effa27a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dandelion=20Man=C3=A9?= Date: Wed, 17 Jun 2020 18:44:34 -0700 Subject: [PATCH] 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. --- .gitignore | 1 + .prettierignore | 1 + config/paths.js | 3 + config/webpack.config.web2.js | 255 ++++++++++++++++++ package.json | 2 + src/homepage2/App.js | 28 ++ src/homepage2/DiscordLogo.js | 22 ++ src/homepage2/ExternalRedirect.js | 28 ++ src/homepage2/GithubLogo.js | 22 ++ src/homepage2/HomePage.js | 207 ++++++++++++++ src/homepage2/Page.js | 162 +++++++++++ src/homepage2/ProjectPage.js | 18 ++ src/homepage2/PrototypesPage.js | 35 +++ src/homepage2/TimelinePage.js | 18 ++ src/homepage2/TwitterLogo.js | 34 +++ .../createRouteDataFromEnvironment.js | 12 + src/homepage2/createRoutes.js | 53 ++++ src/homepage2/homepageExplorer.js | 17 ++ src/homepage2/homepageTimeline.js | 21 ++ src/homepage2/index.css | 5 + src/homepage2/index.js | 38 +++ src/homepage2/routeData.js | 142 ++++++++++ src/homepage2/routeData.test.js | 51 ++++ src/homepage2/server.js | 110 ++++++++ 24 files changed, 1285 insertions(+) create mode 100644 config/webpack.config.web2.js create mode 100644 src/homepage2/App.js create mode 100644 src/homepage2/DiscordLogo.js create mode 100644 src/homepage2/ExternalRedirect.js create mode 100644 src/homepage2/GithubLogo.js create mode 100644 src/homepage2/HomePage.js create mode 100644 src/homepage2/Page.js create mode 100644 src/homepage2/ProjectPage.js create mode 100644 src/homepage2/PrototypesPage.js create mode 100644 src/homepage2/TimelinePage.js create mode 100644 src/homepage2/TwitterLogo.js create mode 100644 src/homepage2/createRouteDataFromEnvironment.js create mode 100644 src/homepage2/createRoutes.js create mode 100644 src/homepage2/homepageExplorer.js create mode 100644 src/homepage2/homepageTimeline.js create mode 100644 src/homepage2/index.css create mode 100644 src/homepage2/index.js create mode 100644 src/homepage2/routeData.js create mode 100644 src/homepage2/routeData.test.js create mode 100644 src/homepage2/server.js diff --git a/.gitignore b/.gitignore index 5f2ff28..ebf1eee 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # production /build +/build2 # backend /bin diff --git a/.prettierignore b/.prettierignore index ddf5f2c..8db97fd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ /bin /build +/build2 /coverage /dist /flow-typed diff --git a/config/paths.js b/config/paths.js index edf89b9..3342e45 100644 --- a/config/paths.js +++ b/config/paths.js @@ -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"), diff --git a/config/webpack.config.web2.js b/config/webpack.config.web2.js new file mode 100644 index 0000000..4f4c7e9 --- /dev/null +++ b/config/webpack.config.web2.js @@ -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> */ { + 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 */ { + 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()); diff --git a/package.json b/package.json index e608f89..c758902 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/homepage2/App.js b/src/homepage2/App.js new file mode 100644 index 0000000..ae0e34a --- /dev/null +++ b/src/homepage2/App.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 ( + + ); + } +} diff --git a/src/homepage2/DiscordLogo.js b/src/homepage2/DiscordLogo.js new file mode 100644 index 0000000..3aa908a --- /dev/null +++ b/src/homepage2/DiscordLogo.js @@ -0,0 +1,22 @@ +// @flow +import React from "react"; + +type Props = {| + +className: string, + +altText: string, +|}; + +export default function DiscordLogo(props: Props) { + return ( + + + + + ); +} diff --git a/src/homepage2/ExternalRedirect.js b/src/homepage2/ExternalRedirect.js new file mode 100644 index 0000000..0981e17 --- /dev/null +++ b/src/homepage2/ExternalRedirect.js @@ -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 ( +
+

Redirecting…

+

+ Redirecting to:{" "} + {this.props.redirectTo} +

+
+ ); + } + + 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; + } +} diff --git a/src/homepage2/GithubLogo.js b/src/homepage2/GithubLogo.js new file mode 100644 index 0000000..7d95c91 --- /dev/null +++ b/src/homepage2/GithubLogo.js @@ -0,0 +1,22 @@ +// @flow +import React from "react"; + +type Props = {| + +className: string, + +altText: string, +|}; + +export default function GithubLogo(props: Props) { + return ( + + {props.altText} + + + ); +} diff --git a/src/homepage2/HomePage.js b/src/homepage2/HomePage.js new file mode 100644 index 0000000..204aecf --- /dev/null +++ b/src/homepage2/HomePage.js @@ -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 ( +
+

SourceCred vision

+

+ The open-source movement is amazing. + It’s inspiring that some of our best technology is developed in the + open and available to everyone. +

+ +

+ Despite all the value provided by open-source projects, many are + chronically underfunded. For example, NumPy{" "} + + received no funding at all until 2017 + + , and{" "} + + a world where OpenSSL was funded might have been a world without + Heartbleed + + . +

+ +

+ 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. +

+ +

SourceCred is our attempt to help.

+ +

Mission

+

+ SourceCred aims to empower open-source developers and communities by + creating a project-specific reputation metric called cred. +

+ +

+ A project’s contributors earn cred for helping out. For example, a + project might reward: +

+
    +
  • Triaging issues
  • +
  • Maintaining the build
  • +
  • Fixing bugs
  • +
  • Writing documentation
  • +
  • Refactoring code
  • +
  • Adding features
  • +
+ +

+ SourceCred will build social capital within communities, recognize + their hardworking contributors, and encourage more people to help + maintain and develop open-source projects. +

+ +

We’re designing SourceCred around the following four principles:

+ +
+
Transparency
+
+ 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. +
+
Extensibility
+
+ 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. +
+
Community control
+
+ Each community has the final say on that community’s cred. When the + algorithm and the community disagree, the community wins. +
+
Decentralization
+
+ Projects own their own data, and control their own cred. The + SourceCred project provides tools, but has no control. +
+
+ +

How cred works

+

+ Cred is computed by first creating a contribution{" "} + graph, 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{" "} + PageRank on that graph to produce a + cred attribution. The attribution is highly configurable; project + maintainers can add new heuristics and adjust weights. +

+ +

+ 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. +

+ +

+ 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. +

+ +

Roadmap

+

+ SourceCred is under active development.{" "} + We have a prototype 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! +

+ +

+ 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. +

+ +

+ In the longer term, we will continue to add signal to cred + attribution. For example, we plan to parse the{" "} + AST 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. +

+ +

About

+

+ 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{" "} + Protocol Labs. +

+ +

+ If you think this vision is exciting, we’d love for you to get + involved! You can join our Discord{" "} + and check out our GitHub—many of our + issues are marked{" "} + contributions welcome. + If you want to try running SourceCred on open-source projects you care + about, check out our README. +

+
+ ); + } +} + +function Dt(props) { + return
{props.children}
; +} + +function Dd(props) { + return
{props.children}
; +} + +const styles = StyleSheet.create({ + container: { + maxWidth: 900, + margin: "0 auto", + marginBottom: 200, + padding: "0 10px", + lineHeight: 1.5, + fontSize: 20, + }, +}); diff --git a/src/homepage2/Page.js b/src/homepage2/Page.js new file mode 100644 index 0000000..adef4aa --- /dev/null +++ b/src/homepage2/Page.js @@ -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 ( + +
+
+ +
+
{this.props.children}
+
+
+
+ + ({VERSION_FULL}) {VERSION_SHORT} + +
+
+
+ ); + } +} + +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)", + }, +}); diff --git a/src/homepage2/ProjectPage.js b/src/homepage2/ProjectPage.js new file mode 100644 index 0000000..f394683 --- /dev/null +++ b/src/homepage2/ProjectPage.js @@ -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 ( + + ); + } + }; +} diff --git a/src/homepage2/PrototypesPage.js b/src/homepage2/PrototypesPage.js new file mode 100644 index 0000000..c54be87 --- /dev/null +++ b/src/homepage2/PrototypesPage.js @@ -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 +): ComponentType<{|+assets: Assets|}> { + return class PrototypesPage extends React.Component<{|+assets: Assets|}> { + render() { + return ( +
+

Select a project:

+
    + {projectIds.map((projectId) => ( +
  • + {`${projectId}`} +
  • + ))} +
+
+ ); + } + }; +} diff --git a/src/homepage2/TimelinePage.js b/src/homepage2/TimelinePage.js new file mode 100644 index 0000000..9c957cf --- /dev/null +++ b/src/homepage2/TimelinePage.js @@ -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 ( + + ); + } + }; +} diff --git a/src/homepage2/TwitterLogo.js b/src/homepage2/TwitterLogo.js new file mode 100644 index 0000000..3875585 --- /dev/null +++ b/src/homepage2/TwitterLogo.js @@ -0,0 +1,34 @@ +// @flow +import React from "react"; + +type Props = {| + +className: string, + +altText: string, +|}; + +export default function TwitterLogo(props: Props) { + return ( + + {props.altText} + + + ); +} diff --git a/src/homepage2/createRouteDataFromEnvironment.js b/src/homepage2/createRouteDataFromEnvironment.js new file mode 100644 index 0000000..5d79d07 --- /dev/null +++ b/src/homepage2/createRouteDataFromEnvironment.js @@ -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 = JSON.parse(raw); + return makeRouteData(ids); +} diff --git a/src/homepage2/createRoutes.js b/src/homepage2/createRoutes.js new file mode 100644 index 0000000..7e43115 --- /dev/null +++ b/src/homepage2/createRoutes.js @@ -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) => ( + + ); + return ( + + {routeData.map(({path, contents}) => { + switch (contents.type) { + case "PAGE": + if (path === "/") { + return ( + + ); + } else { + return ( + + ); + } + case "EXTERNAL_REDIRECT": + return ( + ( + + )} + /> + ); + default: + throw new Error((contents.type: empty)); + } + })} + + ); +} diff --git a/src/homepage2/homepageExplorer.js b/src/homepage2/homepageExplorer.js new file mode 100644 index 0000000..c5a17d8 --- /dev/null +++ b/src/homepage2/homepageExplorer.js @@ -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 ( + + ); + } +} diff --git a/src/homepage2/homepageTimeline.js b/src/homepage2/homepageTimeline.js new file mode 100644 index 0000000..df1107a --- /dev/null +++ b/src/homepage2/homepageTimeline.js @@ -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 ( + + ); + } +} diff --git a/src/homepage2/index.css b/src/homepage2/index.css new file mode 100644 index 0000000..c01944b --- /dev/null +++ b/src/homepage2/index.css @@ -0,0 +1,5 @@ +body { + margin: 0; + padding: 0; + font-family: 'Roboto', sans-serif; +} diff --git a/src/homepage2/index.js b/src/homepage2/index.js new file mode 100644 index 0000000..4ca0476 --- /dev/null +++ b/src/homepage2/index.js @@ -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(, 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 +} diff --git a/src/homepage2/routeData.js b/src/homepage2/routeData.js new file mode 100644 index 0000000..b98d899 --- /dev/null +++ b/src/homepage2/routeData.js @@ -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; +*/ + +/** + * 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 */ +) /*: 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; diff --git a/src/homepage2/routeData.test.js b/src/homepage2/routeData.test.js new file mode 100644 index 0000000..acaff20 --- /dev/null +++ b/src/homepage2/routeData.test.js @@ -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 + "/"); + } + } + }); +}); diff --git a/src/homepage2/server.js b/src/homepage2/server.js new file mode 100644 index 0000000..46c8984 --- /dev/null +++ b/src/homepage2/server.js @@ -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 = ; + const {html, css} = StyleSheetServer.renderStatic(() => + ReactDOMServer.renderToString(component) + ); + const page = dedent`\ + + + + + + + + + ${resolveTitleFromPath(routeData, path)} + + + + +
${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 = ( + + + + ); + const {html, css} = StyleSheetServer.renderStatic(() => + ReactDOMServer.renderToStaticMarkup(component) + ); + const page = dedent`\ + + + + + + + + + + ${resolveTitleFromPath(routeData, path)} + + + + +
${html}
+ + + `; + callback(null, page); + } +}