├── .babelrc ├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .firebaserc.example ├── .flowconfig ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── dockerfile ├── emoji-dump.html ├── firebase.json.example ├── flow-typed ├── dev.js ├── next.js.flow └── npm │ └── unified_v6.x.x.js ├── fly.toml ├── index.js ├── netlify-functions └── index.js ├── netlify.toml ├── next.config.js ├── package.json ├── public ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── logo.png ├── logo192.png ├── logo512.png └── manifest.json ├── relay.config.json ├── schema.graphql ├── scripts ├── ensurePublishLabel.js ├── fetchSchema.js ├── persistQuery.js ├── relay-compiler.js ├── relayLocalPersist.js └── updateEmoji.js ├── server.js.example ├── src ├── App.css ├── Attribution.js ├── Avatar.js ├── Comment.js ├── Comments.js ├── CommentsIcon.js ├── ConfigContext.js ├── Embed.js ├── Environment.js ├── ErrorBoundary.js ├── ErrorBox.js ├── GifPlayer.js ├── GitHubLoginButton.js ├── Head.js ├── Header.js ├── LoginQuery.js ├── MarkdownRenderer.js ├── MarkdownRenderer.test.js ├── Notifications.js ├── Post.js ├── PostRoot.js ├── Posts.js ├── PostsRoot.js ├── RssFeed.js ├── Sitemap.js ├── UserContext.js ├── Welcome.js ├── addIcon.js ├── base64Encode.js ├── clientSchema.graphql ├── config.js ├── emoji.js ├── emojiIcon.js ├── gifplayer.css ├── imageProxy.js ├── imageUrl.js ├── index.css ├── issueUrls.js ├── lib │ ├── codeHighlight.js │ ├── parseMarkdown.js │ ├── theme.js │ ├── trk.js │ └── useBasePath.js ├── loadingSpinner.js ├── logo.svg ├── ogImage.js ├── oneGraphLogo.js ├── pages │ ├── [...catchall].js │ ├── _app.js │ ├── _document.js │ ├── api │ │ ├── config.js │ │ ├── feed │ │ │ └── [ext].js │ │ ├── image │ │ │ ├── [base64Url].js │ │ │ └── firstFrame │ │ │ │ └── [base64Url].js │ │ ├── og-image │ │ │ └── [postNumber].js │ │ ├── robots.js │ │ └── sitemap.js │ ├── index.js │ └── post │ │ └── [...slug].js └── staticPaths.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["macros", "@babel/plugin-proposal-optional-chaining"], 3 | "presets": ["next/babel", "@babel/preset-flow"] 4 | } 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | build 4 | .github/ 5 | .git/ 6 | dist 7 | build 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_ONEGRAPH_APP_ID="Your OneGraph App ID" 2 | NEXT_PUBLIC_GITHUB_REPO_OWNER="The owner of the GitHub repo that the issues are stored under" 3 | NEXT_PUBLIC_GITHUB_REPO_NAME="The name of the GitHub repo that the issues are stored under" 4 | NEXT_PUBLIC_TITLE="Title for your blog" 5 | NEXT_PUBLIC_DESCRIPTION="Description for your blog" 6 | NEXT_PUBLIC_SITE_HOSTNAME="Hostname for your blog, e.g. https://example.com" 7 | NEXT_PUBLIC_DEFAULT_GITHUB_LOGIN="The github login for the owner of the blog" 8 | NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID="Optional google analytics id, e.g. UA-28732921-1" 9 | OG_GITHUB_TOKEN="The OneGraph server-side auth token created with OneGraph that has access to GitHub" 10 | OG_DASHBOARD_ACCESS_TOKEN="The OneGraph API token that is allowed to create persisted queries" 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /.firebaserc.example: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "staging": "adslkjflsakdjfklsdjfka" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | module.system=haste 3 | module.system.haste.use_name_reducers=true 4 | # get basename 5 | module.system.haste.name_reducers='^.*/\([a-zA-Z0-9$_.-]+\.js\(\.flow\)?\)$' -> '\1' 6 | # strip .js or .js.flow suffix 7 | module.system.haste.name_reducers='^\(.*\)\.js\(\.flow\)?$' -> '\1' 8 | 9 | module.system.haste.paths.excludes=.*/__tests__/.* 10 | module.system.haste.paths.excludes=.*/__mocks__/.* 11 | module.system.haste.paths.includes=/node_modules/fbjs/lib/.* 12 | 13 | esproposal.class_static_fields=enable 14 | esproposal.class_instance_fields=enable 15 | esproposal.optional_chaining=enable 16 | 17 | munge_underscores=true 18 | 19 | suppress_type=$FlowIssue 20 | suppress_type=$FlowFixMe 21 | suppress_type=$FlowFixMeProps 22 | suppress_type=$FlowFixMeState 23 | 24 | well_formed_exports=true 25 | types_first=true 26 | experimental.abstract_locations=true 27 | 28 | esproposal.optional_chaining=enable 29 | 30 | [lints] 31 | untyped-type-import=error 32 | 33 | [strict] 34 | deprecated-type 35 | nonstrict-import 36 | sketchy-null 37 | unclear-type 38 | unsafe-getters-setters 39 | untyped-import 40 | untyped-type-import 41 | 42 | [declarations] 43 | /node_modules/.* 44 | .*/__generated__/.* 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | src/__generated__ 14 | src/pages/api/__generated__ 15 | 16 | # misc 17 | .DS_Store 18 | .env 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | /.firebase/ 29 | /firebase-debug.log 30 | /built-netlify-functions/ 31 | /.runtimeconfig.json 32 | 33 | .vercel 34 | .next 35 | .netlify 36 | 37 | # Files generated by next-on-netlify command 38 | /out_publish/ 39 | /out_functions/ 40 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.14.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /src/__generated__/ 2 | /node_modules/ 3 | /.git/ 4 | /.netlify/ 5 | /.firebase/ 6 | /build 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: "all" 3 | bracketSpacing: false 4 | jsxBracketSameLine: true 5 | bracketSameLine: true 6 | printWidth: 80 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a blog powered by GitHub issues 2 | 3 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2FOneGraph%2Foneblog%2Ftree%2Fnext&env=NEXT_PUBLIC_ONEGRAPH_APP_ID,NEXT_PUBLIC_TITLE,OG_GITHUB_TOKEN,OG_DASHBOARD_ACCESS_TOKEN,VERCEL_URL,VERCEL_GITHUB_ORG,VERCEL_GITHUB_REPO&envDescription=Variables%20needed%20to%20build%20your%20OneBlog&envLink=https%3A%2F%2Fgithub.com%2FOneGraph%2Foneblog%2Ftree%2Fnext%23environment-variables&project-name=oneblog&repo-name=oneblog) 4 | 5 | This repo allows you to generate a blog from GitHub issues on a repo. It powers the [OneGraph Product Updates blog](https://www.onegraph.com/changelog), [Stepan Parunashvili's blog](https://stopa.io/), [bdougie.live](https://www.bdougie.live/), and more. 6 | 7 | All of the posts are stored as issues on the repo (e.g. [OneGraph/onegraph-changelog](https://github.com/OneGraph/onegraph-changelog/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3Apublish+)). 8 | 9 | When you visit the page at [onegraph.com/changelog](https://www.onegraph.com/changelog), a GraphQL query fetches the issues from GitHub via OneGraph's persisted queries and renders them as blog posts. 10 | 11 | The persisted queries are stored with authentication credentials for GitHub that allows them to make authenticated requests. Persisting the queries locks them down so that they can't be made to send arbitrary requests to GitHub. 12 | 13 | You can learn more about [persisted queries in the docs](https://www.onegraph.com/docs/persisted_queries.html). 14 | 15 | ## Setup 16 | 17 | Use an existing OneGraph app or sign up sign up at [OneGraph](https://www.onegraph.com) to create a new app. 18 | 19 | Copy `/.env.example` to `/.env` and set the following environment variables. 20 | 21 | ### Environment variables 22 | 23 | | Environment Variable | Description | 24 | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | 25 | | `GITHUB_TOKEN` | A GitHub token, which you can get from https://github.com/settings/tokens. The token only needs the "public_repo" scope. | 26 | | `NEXT_PUBLIC_TITLE` | The title of your site | 27 | | `NEXT_PUBLIC_DESCRIPTION` | A short description of your site. | 28 | | `NEXT_PUBLIC_GITHUB_REPO_OWNER` | The owner of the repo that we should pull issues from (e.g. linus in linus/oneblog). If you're using the Vercel deploy button, you don't need to provide this. | 29 | | `NEXT_PUBLIC_GITHUB_REPO_NAME` | The name of the repo that we should pull issues from (e.g. oneblog in linus/oneblog). If you're using the Vercel deploy button, you don't need to provide this. | 30 | 31 | ### Setup relay 32 | 33 | Remove the generated files (they're tied to the OneGraph app they were generated with): 34 | 35 | ``` 36 | yarn relay:clean 37 | # which runs rm -r src/__generated__ 38 | ``` 39 | 40 | (Note: any time you change the variables in `.env`, it's a good idea to stop the relay compiler, remove the files in `src/__generated__`, and restart the compiler) 41 | 42 | Install dependencies 43 | 44 | ``` 45 | yarn install 46 | ``` 47 | 48 | ### Run the Relay compiler 49 | 50 | This project uses Relay as its GraphQL client because of its high-quality compiler and great support for persisted queries. 51 | 52 | In another terminal window, start the relay compiler 53 | 54 | ``` 55 | yarn relay --watch 56 | ``` 57 | 58 | You may need to install [watchman](https://facebook.github.io/watchman/), a file watching service. On mac, do `brew install watchman`. On Windows or Linux, follow the instructions at [https://facebook.github.io/watchman/docs/install.html](https://facebook.github.io/watchman/docs/install.html). 59 | 60 | ### Start the server 61 | 62 | Now that we've generated the relay files, we can start the server. 63 | 64 | ``` 65 | yarn start 66 | ``` 67 | 68 | The project will load at [http://localhost:3000](http://localhost:3000). 69 | 70 | ## Deploying 71 | 72 | The project comes with setups for deploying to Google's Firebase, Zeit's Now, Netlify, and Fly.io. 73 | 74 | ### Deploy with Vercel 75 | 76 | Use the deploy button to set up a new repo: 77 | 78 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2FOneGraph%2Foneblog%2Ftree%2Fnext&env=NEXT_PUBLIC_ONEGRAPH_APP_ID,NEXT_PUBLIC_TITLE,OG_GITHUB_TOKEN,OG_DASHBOARD_ACCESS_TOKEN,VERCEL_URL,VERCEL_GITHUB_ORG,VERCEL_GITHUB_REPO&envDescription=Variables%20needed%20to%20build%20your%20OneBlog&envLink=https%3A%2F%2Fgithub.com%2FOneGraph%2Foneblog%2Ftree%2Fnext%23environment-variables&project-name=oneblog&repo-name=oneblog) 79 | 80 | If you've already set up the repo, just run the vercel command. 81 | 82 | ``` 83 | # If not installed 84 | # npm i -g vercel 85 | 86 | vercel 87 | ``` 88 | 89 | If you see an error when you visit the site, make sure the site's origin is listed in the CORS origins for your app on the OneGraph dashboard. 90 | 91 | ### Deploying with Firebase 92 | 93 | Please open an issue if you'd like help deploying with Firebase. 94 | 95 | ### Deploying with Netlify 96 | 97 | Please open an issue if you'd like help deploying with Netlify. 98 | 99 | ### Deploying with Fly.io 100 | 101 | Please open an issue if you'd like help deploying with Fly.io 102 | 103 | ## Project setup 104 | 105 | ### Client 106 | 107 | The client is an ordinary React app. The best to place to start is `/src/App.js`. 108 | 109 | It uses Grommet as the UI library. Visit [https://v2.grommet.io/components](https://v2.grommet.io/components) to view the documentation for Grommet. 110 | 111 | It uses Relay as the GraphQL client. [https://relay.dev/docs/en/graphql-in-relay](https://relay.dev/docs/en/graphql-in-relay) has a good introduction to Relay. 112 | 113 | To refresh the GraphQL schema, run `yarn fetch-schema`. That will fetch the schema from OneGraph and add some client-only directives that we use when we persist the queries to OneGraph. 114 | 115 | #### How persisting works 116 | 117 | The `persistFunction` for the relay compiler is set to `/scripts/persistQuery.js`. Every time a GraphQL query in the project changes, the relay compiler will call that function with the new query. 118 | 119 | That function will parse the query and pull out the `@persistedQueryConfiguration` directive to determine if any auth should be stored alongside the query. In the changelog, the queries for fetching posts use persisted auth, but the mutations for adding reactions require the user to log in with OneGraph and use their auth. 120 | 121 | The `@persistedQueryConfiguration` directive is stripped from the query and a next api route is generated to execute the query. 122 | 123 | ### Server 124 | 125 | The server uses [Next.js](https://nextjs.org) to allow us to render the content on the server. This helps with SEO and allows people to view the blog with Javascript turned off. 126 | 127 | When a request comes in to the server, the server creates a mock Relay environment and prefetches the query for the route using `fetchQuery` from `relay-runtime`. This populates the record source that Relay uses to render. 128 | 129 | React renders the app to a string, which is sent to the client. 130 | 131 | On the client, React rehydates the app. To prevent Relay from showing a loading state, we inject the serialized record source with `getStaticProps`. That data is stored in the environment before Relay makes its first query. The `fetchPolicy` opt is set to "store-and-network" so that it uses the data from the store instead of showing a loading state. 132 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 as builder 2 | 3 | COPY package.json . 4 | COPY yarn.lock . 5 | COPY .env . 6 | 7 | RUN yarn install 8 | 9 | COPY . . 10 | 11 | ENV NODE_ENV=production 12 | 13 | RUN yarn build && rm -rf .next/cache 14 | 15 | # Make smaller prod image 16 | FROM node:14 as node_installer 17 | 18 | ENV NODE_ENV=production 19 | 20 | COPY package.json . 21 | COPY yarn.lock . 22 | 23 | RUN yarn install --production 24 | 25 | FROM node:14 26 | 27 | ENV NODE_ENV=production 28 | 29 | COPY package.json . 30 | COPY yarn.lock . 31 | COPY .env . 32 | COPY server.js.example server.js 33 | COPY next.config.js . 34 | 35 | COPY --from=builder .next .next 36 | COPY --from=node_installer node_modules node_modules 37 | CMD [ "node", "server.js" ] 38 | -------------------------------------------------------------------------------- /firebase.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build/public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**/**", 12 | "function": "changelog" 13 | } 14 | ] 15 | }, 16 | "functions": { 17 | "source": "." 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /flow-typed/dev.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | declare var __DEV__: boolean; 4 | -------------------------------------------------------------------------------- /flow-typed/next.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | declare module "next" { 4 | declare type NextApp = { 5 | prepare(): Promise; 6 | getRequestHandler(): any; 7 | render(req: any, res: any, pathname: string, query: any): any; 8 | }; 9 | declare module.exports: (...opts: any) => NextApp 10 | } 11 | 12 | declare module "next/head" { 13 | declare module.exports: Class>; 14 | } 15 | 16 | declare module "next/link" { 17 | declare module.exports: Class>; 18 | } 19 | 20 | declare module "next/error" { 21 | declare module.exports: Class>; 22 | } 23 | 24 | declare module "next/document" { 25 | declare export var Head: Class>; 26 | declare export var Main: Class>; 27 | declare export var NextScript: Class>; 28 | declare export default Class> & { 29 | getInitialProps: (ctx: {pathname: string, query: any, req?: any, res?: any, err?: any}) => Promise; 30 | renderPage(cb: Function): void; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /flow-typed/npm/unified_v6.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: f2af1542ae054acf44d3946166d67132 2 | // flow-typed version: c6154227d1/unified_v6.x.x/flow_>=v0.104.x 3 | 4 | interface Unified$Point { 5 | line: mixed; 6 | column: mixed; 7 | offset?: mixed; 8 | } 9 | 10 | interface Unified$Position { 11 | start: Unified$Point; 12 | end: Unified$Point; 13 | indent?: mixed; 14 | } 15 | 16 | interface Unified$Data {} 17 | 18 | interface Unified$Node { 19 | type: string; 20 | data?: Unified$Data; 21 | position?: Unified$Position; 22 | } 23 | 24 | type Done = (err: Error) => void | ((doc: mixed, extra?: mixed) => void); 25 | 26 | declare class Unified { 27 | data(key: string): mixed; 28 | data(key: string, value: mixed): this; 29 | freeze(): this; 30 | use(plugin: mixed, options?: mixed): this; 31 | parse(file: mixed): Unified$Node; 32 | stringify(node: Unified$Node, file?: mixed): string; 33 | run(node: Unified$Node, file?: mixed): Promise<*>; 34 | run(node: Unified$Node, file: mixed, done: Done): void; 35 | runSync(node: Unified$Node, file?: mixed): Unified$Node; 36 | process(doc: mixed): Promise<*>; 37 | process(doc: mixed, done: Done): void; 38 | processSync(doc: mixed): mixed; 39 | } 40 | 41 | declare module "unified" { 42 | declare module.exports: () => Unified; 43 | } 44 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "onechangelog" 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // index.js for firebase function 2 | 3 | const functions = require('firebase-functions'); 4 | const {createApp} = require('./build/server').default; 5 | 6 | const config = functions.config(); 7 | 8 | const basePath = config && config.changelog && config.changelog.public_path; 9 | 10 | const app = createApp(basePath); 11 | 12 | exports.changelog = functions.https.onRequest(app); 13 | -------------------------------------------------------------------------------- /netlify-functions/index.js: -------------------------------------------------------------------------------- 1 | const awsServerlessExpress = require('aws-serverless-express'); 2 | const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware'); 3 | 4 | const binaryMimeTypes = [ 5 | 'application/javascript', 6 | 'application/json', 7 | 'application/octet-stream', 8 | 'application/xml', 9 | 'font/eot', 10 | 'font/opentype', 11 | 'font/otf', 12 | 'image/jpeg', 13 | 'image/png', 14 | 'image/svg+xml', 15 | 'image/gif', 16 | 'text/comma-separated-values', 17 | 'text/css', 18 | 'text/html', 19 | 'text/javascript', 20 | 'text/plain', 21 | 'text/text', 22 | 'text/xml', 23 | ]; 24 | 25 | const {createApp} = require('../build/server').default; 26 | 27 | const app = createApp(); 28 | app.use(awsServerlessExpressMiddleware.eventContext()); 29 | const server = awsServerlessExpress.createServer(app, null, binaryMimeTypes); 30 | 31 | exports.handler = (event, context) => { 32 | awsServerlessExpress.proxy(server, event, context); 33 | }; 34 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn install && yarn relay && yarn build && yarn next-on-netlify" 3 | publish = "out_publish" 4 | functions = "out_functions/" 5 | environment = { NETLIFY = "true" } 6 | 7 | [context] 8 | environment = { NETLIFY = "true"} 9 | 10 | [template.environment] 11 | NEXT_PUBLIC_ONEGRAPH_APP_ID = "Your OneGraph app id" 12 | NEXT_PUBLIC_GITHUB_REPO_NAME = "Name of the repo that OneBlog should pull issues from" 13 | NEXT_PUBLIC_GITHUB_REPO_OWNER = "Owner of the repo that OneBlog should pull issues from" 14 | NEXT_PUBLIC_TITLE = "The title of the blog" 15 | NEXT_PUBLIC_DESCRIPTION="Description of the blog" 16 | NEXT_PUBLIC_SITE_HOSTNAME="Hostname for your blog, e.g. https://example.com" 17 | NEXT_PUBLIC_DEFAULT_GITHUB_LOGIN="The github login for the owner of the blog" 18 | OG_GITHUB_TOKEN = "The server-side token from OneGraph that will be used in persisted queries" 19 | OG_DASHBOARD_ACCESS_TOKEN = "The OneGraph API token that allows the build to create persisted queries" 20 | 21 | [[redirects]] 22 | from = "/feed*" 23 | to = "/.netlify/functions/next_api_feed_ext" 24 | status = 200 25 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | const opts = { 3 | basePath: process.env.BASE_PATH, 4 | env: { 5 | // Backwards compatibility for people migrating from RAZZLE 6 | NEXT_PUBLIC_SITE_HOSTNAME: 7 | process.env.NEXT_PUBLIC_SITE_HOSTNAME || 8 | process.env.RAZZLE_SITE_HOSTNAME || 9 | process.env.URL, 10 | NEXT_PUBLIC_VERCEL_URL: process.env.VERCEL_URL, 11 | NEXT_PUBLIC_ONEGRAPH_APP_ID: 12 | process.env.NEXT_PUBLIC_ONEGRAPH_APP_ID || 13 | process.env.RAZZLE_ONEGRAPH_APP_ID, 14 | NEXT_PUBLIC_GITHUB_REPO_OWNER: 15 | process.env.NEXT_PUBLIC_GITHUB_REPO_OWNER || 16 | process.env.RAZZLE_GITHUB_REPO_OWNER || 17 | process.env.VERCEL_GITHUB_ORG, 18 | NEXT_PUBLIC_GITHUB_REPO_NAME: 19 | process.env.NEXT_PUBLIC_GITHUB_REPO_NAME || 20 | process.env.RAZZLE_GITHUB_REPO_NAME || 21 | process.env.VERCEL_GITHUB_REPO, 22 | NEXT_PUBLIC_TITLE: 23 | process.env.NEXT_PUBLIC_TITLE || process.env.RAZZLE_TITLE, 24 | NEXT_PUBLIC_DESCRIPTION: 25 | process.env.NEXT_PUBLIC_DESCRIPTION || process.env.RAZZLE_DESCRIPTION, 26 | NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID: 27 | process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING_ID || 28 | process.env.RAZZLE_GOOGLE_ANALYTICS_TRACKING_ID, 29 | }, 30 | experimental: { 31 | reactMode: 'concurrent', 32 | }, 33 | async rewrites() { 34 | return [ 35 | { 36 | source: '/feed.:ext', 37 | destination: '/api/feed/:ext', 38 | }, 39 | { 40 | source: '/sitemap.xml', 41 | destination: '/api/sitemap', 42 | }, 43 | { 44 | source: '/robots.txt', 45 | destination: '/api/robots', 46 | }, 47 | ]; 48 | }, 49 | }; 50 | if (process.env.NETLIFY) { 51 | opts.target = 'serverless'; 52 | } 53 | return opts; 54 | }; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oneblog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@tippyjs/react": "4.1.0", 7 | "@vercel/fetch-retry": "^5.0.3", 8 | "date-fns": "2.9.0", 9 | "email-reply-parser": "1.2.6", 10 | "feed": "^4.0.0", 11 | "gif-stream": "^1.1.0", 12 | "grommet": "2.30.0", 13 | "grommet-icons": "^4.4.0", 14 | "inline-css": "^2.5.1", 15 | "intersection-observer": "^0.11.0", 16 | "lower-case": "^2.0.1", 17 | "neuquant": "^1.0.2", 18 | "next": "^13.2.3", 19 | "node-fetch": "^2.6.0", 20 | "onegraph-auth": "^3.0.0", 21 | "pixel-stream": "^1.0.3", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "react-intersection-observer": "^8.28.5", 25 | "react-is": "^18.2.0", 26 | "react-markdown": "^4.3.1", 27 | "react-player": "^2.6.2", 28 | "react-relay": "14.1.0", 29 | "relay-runtime": "14.1.0", 30 | "scriptjs": "^2.5.9", 31 | "sentence-case": "^3.0.3", 32 | "sitemap": "^6.3.2", 33 | "styled-components": "^5.3.8" 34 | }, 35 | "scripts": { 36 | "dev": "next", 37 | "start": "next", 38 | "build": "yarn relay && next build", 39 | "start:prod": "NODE_ENV=production node build/server.js", 40 | "relay": "node scripts/relay-compiler.js", 41 | "relay:clean": "rm -r src/__generated__ src/pages/api/__generated__", 42 | "flow": "flow", 43 | "fetch-schema": "node scripts/fetchSchema.js --path schema.graphql", 44 | "ensure-publish-label": "node scripts/ensurePublishLabel.js", 45 | "deploy": "vercel", 46 | "prettier": "prettier --write \"src/**/*.js\" README.md" 47 | }, 48 | "eslintConfig": { 49 | "extends": "react-app" 50 | }, 51 | "browserslist": { 52 | "production": [">0.2%", "not dead", "not op_mini all"], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@babel/core": "^7.21.0", 61 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 62 | "@babel/preset-flow": "^7.0.0", 63 | "@babel/runtime": "^7.21.0", 64 | "babel-eslint": "10.x", 65 | "babel-plugin-macros": "^2.6.1", 66 | "babel-plugin-relay": "14.1.0", 67 | "dotenv": "^16.0.3", 68 | "eslint": "6.x", 69 | "eslint-config-react-app": "^5.2.1", 70 | "eslint-plugin-flowtype": "4.x", 71 | "eslint-plugin-import": "2.x", 72 | "eslint-plugin-jsx-a11y": "6.x", 73 | "eslint-plugin-react": "7.x", 74 | "eslint-plugin-react-hooks": "2.x", 75 | "flow-bin": "^0.133.0", 76 | "flow-typed": "^3.2.1", 77 | "get-port": "^6.1.2", 78 | "graphql": "^14.4.2", 79 | "prettier": "^2.1.2", 80 | "relay-compiler": "14.1.0", 81 | "relay-test-utils": "14.1.0", 82 | "require-times": "^1.1.0", 83 | "source-map-explorer": "^2.0.1" 84 | }, 85 | "babelMacros": { 86 | "relay": {} 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneGraph/oneblog/347cbb92da818962d050d3a1ff4c024c7c22b584/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneGraph/oneblog/347cbb92da818962d050d3a1ff4c024c7c22b584/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneGraph/oneblog/347cbb92da818962d050d3a1ff4c024c7c22b584/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneGraph/oneblog/347cbb92da818962d050d3a1ff4c024c7c22b584/public/logo.png -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneGraph/oneblog/347cbb92da818962d050d3a1ff4c024c7c22b584/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneGraph/oneblog/347cbb92da818962d050d3a1ff4c024c7c22b584/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "OneGraph Updates", 3 | "name": "OneGraph Product Updates", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /relay.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": "./src/", 3 | "language": "flow", 4 | "schema": "./schema.graphql", 5 | "exclude": ["**/node_modules/**", "**/__generated__/**"], 6 | "schemaExtensions": ["./src/"], 7 | "persistConfig": { 8 | "url": "http://localhost:2999" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/ensurePublishLabel.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const yargs = require('yargs'); 3 | 4 | require('dotenv').config(); 5 | 6 | const REPO_OWNER = 7 | process.env.NEXT_PUBLIC_GITHUB_REPO_OWNER || 8 | process.env.RAZZLE_GITHUB_REPO_OWNER || 9 | process.env.VERCEL_GITHUB_ORG; 10 | const REPO_NAME = 11 | process.env.NEXT_PUBLIC_GITHUB_REPO_NAME || 12 | process.env.RAZZLE_GITHUB_REPO_NAME || 13 | process.env.VERCEL_GITHUB_REPO; 14 | 15 | const TOKEN = process.env.OG_GITHUB_TOKEN; 16 | 17 | const APP_ID = 18 | process.env.NEXT_PUBLIC_ONEGRAPH_APP_ID || process.env.RAZZLE_ONEGRAPH_APP_ID; 19 | 20 | function ensureConfig() { 21 | if (!REPO_OWNER) { 22 | console.warn( 23 | "Can't ensure publish label on repo, unable to determine repo owner. Add NEXT_PUBLIC_GITHUB_REPO_OWNER to the environment variables.", 24 | ); 25 | return; 26 | } 27 | if (!REPO_NAME) { 28 | console.warn( 29 | "Can't ensure publish label on repo, unable to determine repo name. Add NEXT_PUBLIC_GITHUB_REPO_NAME to the environment variables.", 30 | ); 31 | return; 32 | } 33 | if (!TOKEN) { 34 | console.warn( 35 | "Can't ensure publish label on repo, unable to write to GitHub. Add OG_GITHUB_TOKEN to the environment variables.", 36 | ); 37 | return; 38 | } 39 | if (!APP_ID) { 40 | console.warn( 41 | "Can't ensure publish label on repo, unable to determine OneGraph appId. Add NEXT_PUBLIC_ONEGRAPH_APP_ID to the environment variables.", 42 | ); 43 | return; 44 | } 45 | return { 46 | repoOwner: REPO_OWNER, 47 | repoName: REPO_NAME, 48 | token: TOKEN, 49 | appId: APP_ID, 50 | }; 51 | } 52 | 53 | const createPublishLabelMutation = /* GraphQL */ ` 54 | mutation CreatePublishLabelMutation($path: String!) { 55 | gitHub { 56 | makeRestCall { 57 | post( 58 | path: $path 59 | jsonBody: { 60 | name: "Publish" 61 | color: "1997c6" 62 | description: "Add this label to an issue to publish it on the blog" 63 | } 64 | ) { 65 | jsonBody 66 | } 67 | } 68 | } 69 | } 70 | `; 71 | 72 | const labelsQuery = /* GraphQL */ ` 73 | query GitHubLabelsQuery($repoOwner: String!, $repoName: String!) { 74 | gitHub { 75 | repository(owner: $repoOwner, name: $repoName) { 76 | labels(first: 100) { 77 | nodes { 78 | name 79 | } 80 | } 81 | } 82 | } 83 | } 84 | `; 85 | 86 | async function gqlFetch({appId, token, query, variables}) { 87 | const response = await fetch( 88 | `https://serve.onegraph.com/graphql?app_id=${appId}`, 89 | { 90 | method: 'POST', 91 | headers: { 92 | 'Content-Type': 'application/json', 93 | Accept: 'application/json', 94 | Authorization: `Bearer ${token}`, 95 | }, 96 | body: JSON.stringify({query, variables}), 97 | }, 98 | ); 99 | const json = await response.json(); 100 | if (json.errors || !json.data) { 101 | const msg = json.errors[0] && json.errors[0].message; 102 | throw new Error(msg || 'There was an error running the query'); 103 | } else { 104 | return json.data; 105 | } 106 | } 107 | 108 | async function ensureLabel() { 109 | const config = ensureConfig(); 110 | if (!config) { 111 | return; 112 | } 113 | try { 114 | const labelData = await gqlFetch({ 115 | appId: config.appId, 116 | token: config.token, 117 | query: labelsQuery, 118 | variables: {repoName: config.repoName, repoOwner: config.repoOwner}, 119 | }); 120 | if ( 121 | labelData && 122 | labelData.gitHub && 123 | labelData.gitHub.repository && 124 | labelData.gitHub.repository.labels && 125 | labelData.gitHub.repository.labels.nodes 126 | ) { 127 | const nodes = labelData.gitHub.repository.labels.nodes; 128 | if (nodes.find((n) => n.name === 'Publish' || n.name === 'publish')) { 129 | console.log('repo has publish label'); 130 | return; 131 | } else { 132 | const createLabelData = await gqlFetch({ 133 | token: config.token, 134 | appId: config.appId, 135 | query: createPublishLabelMutation, 136 | variables: { 137 | path: `/repos/${config.repoOwner}/${config.repoName}/labels`, 138 | }, 139 | }); 140 | if ( 141 | createLabelData && 142 | createLabelData.gitHub && 143 | createLabelData.gitHub.makeRestCall && 144 | createLabelData.gitHub.makeRestCall.post && 145 | createLabelData.gitHub.makeRestCall.post.jsonBody && 146 | createLabelData.gitHub.makeRestCall.post.jsonBody && 147 | createLabelData.gitHub.makeRestCall.post.jsonBody.name === 'Publish' 148 | ) { 149 | console.log('Created label on repo'); 150 | } else { 151 | console.warn( 152 | 'There was an error adding the publish label to the repo', 153 | ); 154 | } 155 | } 156 | } 157 | } catch (e) { 158 | console.log( 159 | "Can't ensure publish label on repo, there was an error making the query.", 160 | e.message, 161 | ); 162 | } 163 | } 164 | 165 | async function main(config) { 166 | await ensureLabel(); 167 | } 168 | 169 | const argv = yargs.usage('Ensure the publish label exists on the repo').help() 170 | .argv; 171 | 172 | main(argv).catch((error) => { 173 | console.error(String(error.stack || error)); 174 | process.exit(1); 175 | }); 176 | -------------------------------------------------------------------------------- /scripts/fetchSchema.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | 3 | require('dotenv').config(); 4 | 5 | const { 6 | getIntrospectionQuery, 7 | buildClientSchema, 8 | printSchema, 9 | } = require('graphql'); 10 | 11 | const fs = require('fs'); 12 | 13 | const yargs = require('yargs'); 14 | 15 | const token = process.env.GITHUB_TOKEN; 16 | 17 | if (!token) { 18 | throw new Error('Missing env variable GITHUB_TOKEN'); 19 | } 20 | 21 | function runIntrospectionQuery() { 22 | return new Promise((resolve, reject) => { 23 | const body = JSON.stringify({query: getIntrospectionQuery()}); 24 | let data = ''; 25 | const req = https.request( 26 | { 27 | hostname: 'api.github.com', 28 | port: 443, 29 | path: '/graphql', 30 | method: 'POST', 31 | headers: { 32 | 'Content-Type': 'application/json', 33 | 'Content-Length': body.length, 34 | 'User-Agent': 'oneblog', 35 | 'Authorization': `Bearer ${token}` 36 | }, 37 | }, 38 | (res) => { 39 | res.on('data', (chunk) => { 40 | data += chunk; 41 | }); 42 | res.on('end', () => { 43 | const resp = JSON.parse(data); 44 | if (resp.errors) { 45 | throw new Error( 46 | 'Error running introspection query, errors=' + 47 | JSON.stringify(resp.errors), 48 | ); 49 | } else { 50 | resolve(printSchema(buildClientSchema(resp.data))); 51 | } 52 | }); 53 | }, 54 | ); 55 | req.write(body); 56 | req.end(); 57 | }); 58 | } 59 | 60 | const persistQueryConfigDirective = ` 61 | input PersistedQueryFixedVariablesConfiguration { 62 | "The environment variable that holds the fixed variables" 63 | environmentVariable: String! 64 | } 65 | 66 | directive @persistedQueryConfiguration( 67 | fixedVariables: PersistedQueryFixedVariablesConfiguration 68 | "List of variables that the user can provide" 69 | freeVariables: [String!] 70 | "Number of seconds to cache the results of the query" 71 | cacheSeconds: Float 72 | ) on QUERY | MUTATION | SUBSCRIPTION 73 | `; 74 | 75 | function addPersistQueryDirective(schema) { 76 | return persistQueryConfigDirective + schema; 77 | } 78 | 79 | function writeFile(path, content) { 80 | fs.writeFileSync(path, content); 81 | } 82 | 83 | async function main(config) { 84 | const schema = await runIntrospectionQuery(); 85 | const withPersistQueryDirective = addPersistQueryDirective(schema); 86 | writeFile(config.path, withPersistQueryDirective); 87 | } 88 | 89 | const argv = yargs 90 | .usage('Fetch GitHub GraphQL schema $0 --path ') 91 | .options({ 92 | path: { 93 | describe: 'Path to save schema.graphql', 94 | demandOption: true, 95 | type: 'string', 96 | array: false, 97 | }, 98 | }) 99 | .help().argv; 100 | 101 | main(argv).catch((error) => { 102 | console.error(String(error.stack || error)); 103 | process.exit(1); 104 | }); 105 | -------------------------------------------------------------------------------- /scripts/persistQuery.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const GraphQLLanguage = require('graphql/language'); 3 | const {parse, print} = require('graphql'); 4 | const fs = require('fs'); 5 | const prettier = require('prettier'); 6 | 7 | require('dotenv').config(); 8 | 9 | if ( 10 | (!process.env.REPOSITORY_FIXED_VARIABLES && 11 | // Backwards compat with older apps that started with razzle 12 | process.env.RAZZLE_GITHUB_REPO_OWNER && 13 | process.env.RAZZLE_GITHUB_REPO_NAME) || 14 | (process.env.NEXT_PUBLIC_GITHUB_REPO_OWNER && 15 | process.env.NEXT_PUBLIC_GITHUB_REPO_NAME) || 16 | (process.env.VERCEL_GITHUB_ORG && process.env.VERCEL_GITHUB_REPO) 17 | ) { 18 | const repoName = 19 | process.env['RAZZLE_GITHUB_REPO_NAME'] || 20 | process.env['NEXT_PUBLIC_GITHUB_REPO_NAME'] || 21 | process.env['VERCEL_GITHUB_REPO']; 22 | const repoOwner = 23 | process.env['RAZZLE_GITHUB_REPO_OWNER'] || 24 | process.env['NEXT_PUBLIC_GITHUB_REPO_OWNER'] || 25 | process.env['VERCEL_GITHUB_ORG']; 26 | process.env[ 27 | 'REPOSITORY_FIXED_VARIABLES' 28 | ] = `{"repoName": "${repoName}", "repoOwner": "${repoOwner}"}`; 29 | } 30 | 31 | async function persistQuery(queryText) { 32 | const ast = parse(queryText, {noLocation: true}); 33 | 34 | const freeVariables = new Set([]); 35 | let fixedVariables = null; 36 | let cacheSeconds = null; 37 | let operationName = null; 38 | let transformedAst = GraphQLLanguage.visit(ast, { 39 | OperationDefinition: { 40 | enter(node) { 41 | operationName = node.name.value; 42 | operationType = node.operation; 43 | for (const directive of node.directives) { 44 | if (directive.name.value === 'persistedQueryConfiguration') { 45 | const fixedVariablesArg = directive.arguments.find( 46 | (a) => a.name.value === 'fixedVariables', 47 | ); 48 | const freeVariablesArg = directive.arguments.find( 49 | (a) => a.name.value === 'freeVariables', 50 | ); 51 | 52 | const cacheSecondsArg = directive.arguments.find( 53 | (a) => a.name.value === 'cacheSeconds', 54 | ); 55 | 56 | if (fixedVariablesArg) { 57 | const envArg = fixedVariablesArg.value.fields.find( 58 | (f) => f.name.value === 'environmentVariable', 59 | ); 60 | if (envArg) { 61 | if (fixedVariables) { 62 | throw new Error( 63 | 'fixedVariables are already defined for operation=' + 64 | node.name.value, 65 | ); 66 | } 67 | const envVar = envArg.value.value; 68 | try { 69 | fixedVariables = JSON.parse(process.env[envVar]); 70 | } catch (e) { 71 | console.error(e); 72 | } 73 | if (!fixedVariables) { 74 | throw new Error( 75 | 'Cannot persist query. Missing environment variable `' + 76 | envVar + 77 | '`.', 78 | ); 79 | } 80 | } 81 | } 82 | 83 | if (freeVariablesArg) { 84 | for (const v of freeVariablesArg.value.values) { 85 | freeVariables.add(v.value); 86 | } 87 | } 88 | 89 | if (cacheSecondsArg) { 90 | cacheSeconds = parseFloat(cacheSecondsArg.value.value); 91 | } 92 | } 93 | } 94 | return { 95 | ...node, 96 | directives: node.directives.filter( 97 | (d) => d.name.value !== 'persistedQueryConfiguration', 98 | ), 99 | }; 100 | }, 101 | }, 102 | }); 103 | 104 | const apiHandler = 105 | operationType === 'query' 106 | ? ` 107 | import fetch from 'node-fetch'; 108 | const query = \`${print(transformedAst)}\`; 109 | const token = process.env.GITHUB_TOKEN; 110 | const fixedVariables = ${JSON.stringify(fixedVariables || {}, null, 2)}; 111 | const freeVariables = new Set(${JSON.stringify([...freeVariables])}); 112 | 113 | function logRateLimit(resp) { 114 | const reset = new Date( 115 | parseInt(resp.headers.get('x-ratelimit-reset')) * 1000, 116 | ); 117 | console.log( 118 | 'GitHub request for ${operationName}, rate limit: %s/%s resets at %s', 119 | resp.headers.get('x-ratelimit-remaining'), 120 | resp.headers.get('x-ratelimit-limit'), 121 | reset, 122 | ); 123 | } 124 | 125 | export const fetchQuery = async (requestVariables) => { 126 | const variables = {...fixedVariables}; 127 | if (freeVariables.size > 0 && requestVariables) { 128 | for (const v of freeVariables) { 129 | variables[v] = requestVariables[v]; 130 | } 131 | } 132 | 133 | const resp = await fetch('https://api.github.com/graphql', { 134 | method: 'POST', 135 | headers: { 136 | 'Content-Type': 'application/json', 137 | Accept: 'application/json', 138 | Authorization: \`Bearer \${token}\`, 139 | 'User-Agent': 'oneblog', 140 | }, 141 | body: JSON.stringify({query, variables}) 142 | }); 143 | logRateLimit(resp); 144 | const json = await resp.json(); 145 | return json; 146 | }; 147 | 148 | const ${operationName} = async (req, res) => { 149 | const json = await fetchQuery(req.query.variables ? JSON.parse(req.query.variables) : null); 150 | res.setHeader('Content-Type', 'application/json'); 151 | if (${cacheSeconds}) { 152 | res.setHeader( 153 | 'Cache-Control', 154 | 'public, s-maxage=${cacheSeconds}, stale-while-revalidate=${cacheSeconds}' 155 | ); 156 | } 157 | res.status(200).send(json); 158 | } 159 | export default ${operationName};` 160 | : ` 161 | const ${operationName} = async (req, res) => { 162 | const json = {"errors": [{"message": "Mutations are not yet supported"}]}; 163 | res.setHeader('Content-Type', 'application/json'); 164 | res.status(200).send(json); 165 | } 166 | export default ${operationName}; 167 | `; 168 | 169 | fs.mkdirSync('./src/pages/api/__generated__/', {recursive: true}); 170 | 171 | const filename = `./src/pages/api/__generated__/${operationName}.js`; 172 | 173 | fs.writeFileSync(filename, prettier.format(apiHandler, {filepath: filename})); 174 | 175 | return operationName; 176 | } 177 | 178 | exports.default = persistQuery; 179 | -------------------------------------------------------------------------------- /scripts/relay-compiler.js: -------------------------------------------------------------------------------- 1 | const {tmpdir} = require('node:os'); 2 | const {mkdtempSync, writeFileSync} = require('node:fs'); 3 | const {sep} = require('node:path'); 4 | const relayConfig = require('../relay.config.json'); 5 | const {default: persistServer} = require('./relayLocalPersist'); 6 | const relayBin = require('relay-compiler'); 7 | const spawn = require('child_process').spawn; 8 | 9 | async function go() { 10 | const {server, port} = await persistServer(0); 11 | 12 | console.log('started persist server on port', port); 13 | 14 | const newRelayConfig = { 15 | ...relayConfig, 16 | persistConfig: {url: `http://localhost:${port}`}, 17 | }; 18 | 19 | const tmpDir = mkdtempSync(`${tmpdir()}${sep}`); 20 | const configFile = `${tmpDir}${sep}relay.config.json`; 21 | writeFileSync(configFile, JSON.stringify(newRelayConfig, null, 2), 'utf8'); 22 | 23 | const [_node, _file, ...args] = process.argv; 24 | spawn(relayBin, [...args, configFile], {stdio: 'inherit'}).on('exit', () => { 25 | server.close(); 26 | process.exit(); 27 | }); 28 | } 29 | 30 | go(); 31 | -------------------------------------------------------------------------------- /scripts/relayLocalPersist.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const {default: persistQuery} = require('./persistQuery'); 3 | 4 | async function requestListener(req, res) { 5 | if (req.method === 'POST') { 6 | const buffers = []; 7 | for await (const chunk of req) { 8 | buffers.push(chunk); 9 | } 10 | const data = Buffer.concat(buffers).toString(); 11 | res.writeHead(200, { 12 | 'Content-Type': 'application/json', 13 | }); 14 | try { 15 | if (req.headers['content-type'] !== 'application/x-www-form-urlencoded') { 16 | throw new Error( 17 | 'Only "application/x-www-form-urlencoded" requests are supported.', 18 | ); 19 | } 20 | const text = new URLSearchParams(data).get('text'); 21 | if (text == null) { 22 | throw new Error('Expected to have `text` parameter in the POST.'); 23 | } 24 | const id = await persistQuery(text); 25 | res.end(JSON.stringify({id: id})); 26 | } catch (e) { 27 | console.error(e); 28 | res.writeHead(400); 29 | res.end(`Unable to save query: ${e}.`); 30 | } 31 | } else { 32 | res.writeHead(400); 33 | res.end('Request is not supported.'); 34 | } 35 | } 36 | 37 | function start(port) { 38 | let serverPort = port; 39 | if (serverPort == null) { 40 | serverPort = 2999; 41 | } 42 | const server = http.createServer(requestListener); 43 | return new Promise((resolve) => { 44 | server.on('listening', () => { 45 | resolve({server, port: server.address().port}); 46 | }); 47 | server.listen(serverPort); 48 | }); 49 | } 50 | 51 | exports.default = start; 52 | 53 | if (require.main === module) { 54 | start().then(({port}) => { 55 | console.log('started relay persist server on port', port); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /scripts/updateEmoji.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const cheerio = require('cheerio'); 3 | 4 | // Before running this, dump the html from 5 | // https://github.com/autocomplete/emoji to 6 | // /emoji-dump.html 7 | 8 | async function updateEmoji() { 9 | const emojiHtml = fs.readFileSync(`${__dirname}/../emoji-dump.html`, 'utf-8'); 10 | const $ = cheerio.load(emojiHtml); 11 | 12 | const emoji = {}; 13 | $('g-emoji').each(function(i, elem) { 14 | const $g = $(this); 15 | const alias = $g.attr('alias'); 16 | if (emoji[alias]) { 17 | throw new Error('Duplicate for alias', alias); 18 | } 19 | emoji[alias] = $g.text(); 20 | }); 21 | 22 | fs.writeFileSync( 23 | `${__dirname}/../src/emoji.js`, 24 | `// @flow 25 | 26 | const emoji = JSON.parse( 27 | '${JSON.stringify(emoji)}', 28 | ); 29 | 30 | export default emoji; 31 | `, 32 | ); 33 | } 34 | 35 | updateEmoji(); 36 | -------------------------------------------------------------------------------- /server.js.example: -------------------------------------------------------------------------------- 1 | // Example of a standalone server file for deploying next.js outside of vercel 2 | 3 | const {createServer} = require('http'); 4 | const {parse} = require('url'); 5 | const next = require('next'); 6 | 7 | const dev = process.env.NODE_ENV !== 'production'; 8 | const app = next({dev}); 9 | const handle = app.getRequestHandler(); 10 | 11 | const port = process.env.PORT || 3000; 12 | 13 | app.prepare().then(() => { 14 | createServer((req, res) => { 15 | // Be sure to pass `true` as the second argument to `url.parse`. 16 | // This tells it to parse the query portion of the URL. 17 | const parsedUrl = parse(req.url, true); 18 | const {pathname, query} = parsedUrl; 19 | 20 | handle(req, res, parsedUrl); 21 | }).listen(port, (err) => { 22 | if (err) throw err; 23 | console.log(`> Ready on http://localhost:${port}`); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | .add-reaction-emoji { 6 | fill: rgb(88, 96, 105); 7 | stroke: rgb(88, 96, 105); 8 | display: flex; 9 | align-items: center; 10 | font-size: 16px; 11 | cursor: pointer; 12 | outline: none; 13 | } 14 | 15 | .add-reaction-emoji:hover { 16 | fill: #0366d6; 17 | stroke: #0366d6; 18 | } 19 | 20 | a { 21 | text-decoration: none; 22 | } 23 | 24 | a:hover { 25 | text-decoration: underline; 26 | } 27 | 28 | pre { 29 | overflow: scroll; 30 | } 31 | 32 | pre code { 33 | background: none; 34 | padding: 0; 35 | } 36 | 37 | code { 38 | background: rgb(248, 248, 248); 39 | font-size: 0.8em; 40 | padding: 2px 4px; 41 | } 42 | 43 | .layout { 44 | box-sizing: border-box; 45 | max-width: 704px; 46 | margin: auto; 47 | width: 100%; 48 | height: 100%; 49 | -webkit-box-pack: center; 50 | -webkit-justify-content: center; 51 | -ms-flex-pack: center; 52 | justify-content: center; 53 | grid-template-rows: auto 1fr; 54 | overflow-wrap: break-word; 55 | } 56 | 57 | blockquote { 58 | font-style: italic; 59 | box-shadow: inset 3px 0 0 0 rgba(0, 0, 0, 0.27); 60 | margin-inline-start: 0; 61 | margin-inline-end: 0; 62 | padding-left: 1em; 63 | } 64 | 65 | li p { 66 | margin: 0; 67 | } 68 | 69 | kbd { 70 | font-size: calc(1rem - 6px); 71 | line-height: 1rem; 72 | display: inline-block; 73 | padding: 2px 5px; 74 | color: #444d56; 75 | vertical-align: middle; 76 | background-color: #fafbfc; 77 | border-bottom-color: #d1d5da; 78 | border: 1px solid #d1d5da; 79 | border-radius: 6px; 80 | box-shadow: inset 0 -1px 0 #d1d5da; 81 | } 82 | -------------------------------------------------------------------------------- /src/Attribution.js: -------------------------------------------------------------------------------- 1 | export default function Attribution() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /src/Avatar.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import UserContext from './UserContext'; 4 | import imageUrl from './imageUrl'; 5 | import {Box} from 'grommet/components/Box'; 6 | import {Image} from 'grommet/components/Image'; 7 | import {Drop} from 'grommet/components/Drop'; 8 | import {Button} from 'grommet/components/Button'; 9 | import {Text} from 'grommet/components/Text'; 10 | import {Logout} from 'grommet-icons/icons/Logout'; 11 | import {Add} from 'grommet-icons/icons/Add'; 12 | import {MoreVertical} from 'grommet-icons/icons/MoreVertical'; 13 | import {Github} from 'grommet-icons/icons/Github'; 14 | import GitHubLoginButton from './GitHubLoginButton'; 15 | import {newIssueUrl} from './issueUrls'; 16 | import {createFragmentContainer, type RelayProp} from 'react-relay'; 17 | import {useFragment} from 'react-relay/hooks'; 18 | import graphql from 'babel-plugin-relay/macro'; 19 | 20 | import type {LoginStatus} from './UserContext'; 21 | import type { 22 | Avatar_gitHub$key, 23 | Avatar_gitHub$data, 24 | RepositoryPermission, 25 | } from './__generated__/Avatar_gitHub.graphql'; 26 | 27 | const MANAGE_LABEL_ROLES: Array = [ 28 | 'ADMIN', 29 | 'MAINTAIN', 30 | 'WRITE', 31 | 'TRIAGE', 32 | ]; 33 | 34 | function checkIsAdmin( 35 | loginStatus: LoginStatus, 36 | repository: ?{| 37 | +viewerPermission: ?RepositoryPermission, 38 | +viewerCanAdminister: boolean, 39 | |}, 40 | ) { 41 | if (loginStatus !== 'logged-in') { 42 | return false; 43 | } 44 | const viewerIsAdmin = repository?.viewerCanAdminister; 45 | const viewerPermission = repository?.viewerPermission; 46 | return viewerIsAdmin || MANAGE_LABEL_ROLES.includes(viewerPermission); 47 | } 48 | 49 | type AdminLink = {href: string, label: string, icon: any}; 50 | 51 | type Props = { 52 | gitHub: Avatar_gitHub$key, 53 | adminLinks: ?Array, 54 | }; 55 | 56 | export default function Avatar({gitHub, adminLinks: extraAdminLinks}: Props) { 57 | const ref = React.useRef(); 58 | const {loginStatus, logout, login} = React.useContext(UserContext); 59 | const [showOptions, setShowOptions] = React.useState(false); 60 | 61 | const data: Avatar_gitHub$data = useFragment( 62 | graphql` 63 | fragment Avatar_gitHub on Query 64 | @argumentDefinitions( 65 | repoName: {type: "String!"} 66 | repoOwner: {type: "String!"} 67 | ) { 68 | viewer { 69 | login 70 | avatarUrl(size: 96) 71 | } 72 | repository(name: $repoName, owner: $repoOwner) { 73 | viewerPermission 74 | viewerCanAdminister 75 | } 76 | } 77 | `, 78 | gitHub, 79 | ); 80 | 81 | if (loginStatus === 'checking' || loginStatus === 'error') { 82 | return null; 83 | } 84 | 85 | const viewer = data.viewer; 86 | 87 | const adminLinks = [ 88 | { 89 | href: newIssueUrl(), 90 | label: 'Create New Post', 91 | icon: , 92 | }, 93 | ].concat(extraAdminLinks || []); 94 | const isAdmin = checkIsAdmin(loginStatus, data.repository); 95 | return ( 96 | <> 97 | {loginStatus === 'logged-in' ? ( 98 | setShowOptions(!showOptions)} 102 | ref={ref} 103 | round="xsmall" 104 | style={{ 105 | height: 32, 106 | width: 32, 107 | overflow: 'hidden', 108 | cursor: 'pointer', 109 | }}> 110 | 115 | 116 | ) : ( 117 |