├── .nvmrc ├── app ├── robots.txt ├── favicon.ico ├── App.css ├── pages │ ├── Home.css │ ├── More.css │ ├── More.re │ ├── More.bs.js │ ├── Home.re │ └── Home.bs.js ├── assets │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ └── android-chrome-512x512.png ├── client.js ├── components │ ├── Link.re │ └── Link.bs.js ├── client.re ├── manifest.webmanifest ├── App.re ├── client.bs.js ├── registerServiceWorker.js ├── index.html ├── App.bs.js ├── GraphqlHooks.bs.js └── GraphqlHooks.re ├── jest ├── fileTransformer.js └── setup.js ├── packages ├── api │ ├── .nvmrc │ ├── index.js │ ├── devServer.js │ ├── package.json │ ├── GraphqlSchema.re │ ├── server.re │ ├── StoryData.re │ ├── server.bs.js │ ├── StoryData.bs.js │ └── GraphqlSchema.bs.js └── e2e │ ├── .nvmrc │ ├── babel.config.js │ ├── jest-puppeteer.config.js │ ├── utils │ └── graphRequest.js │ ├── package.json │ └── __tests__ │ └── basic.test.js ├── .browserslistrc ├── server ├── index.js ├── utils.js └── ssrMiddleware.js ├── .storybook ├── webpack.config.js ├── addons.js └── config.js ├── renovate.json ├── .eslintignore ├── .prettierignore ├── .stylelintignore ├── .stylelintrc ├── .gitignore ├── .nowignore ├── __tests__ ├── utils │ ├── graphQLClient.js │ ├── testUtils.js │ └── fetchMocks.js ├── fixtures │ └── topStories.js ├── pages │ ├── More.test.js │ └── Home.test.js ├── components │ └── Link.test.js └── App.test.js ├── postcss.config.js ├── scripts └── deploy-ci.sh ├── stories └── ui.stories.jsx ├── babel.config.js ├── .eslintrc.js ├── now.json ├── .github ├── main.workflow └── workflows │ └── workflow.yml ├── jest.json ├── bsconfig.json ├── LICENSE ├── README.md ├── graphql_schema.json ├── package.json └── rollup.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.1 2 | -------------------------------------------------------------------------------- /app/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /jest/fileTransformer.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/api/.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.1 2 | -------------------------------------------------------------------------------- /packages/e2e/.nvmrc: -------------------------------------------------------------------------------- 1 | 12.13.1 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 4 versions 3 | -------------------------------------------------------------------------------- /jest/setup.js: -------------------------------------------------------------------------------- 1 | global.fetch = require('jest-fetch-mock'); 2 | -------------------------------------------------------------------------------- /packages/api/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../dist/api'); 2 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../dist/ssr-middleware'); 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/favicon.ico -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | return config; 3 | }; 4 | -------------------------------------------------------------------------------- /app/App.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | border-radius: 4px; 3 | background-color: var(--primary); 4 | } 5 | -------------------------------------------------------------------------------- /app/pages/Home.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | border-radius: 4px; 3 | background-color: var(--primary); 4 | } 5 | -------------------------------------------------------------------------------- /app/pages/More.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | border-radius: 4px; 3 | background-color: var(--primary); 4 | } 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true 6 | } 7 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /app/assets/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/assets/icons/favicon-16x16.png -------------------------------------------------------------------------------- /app/assets/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/assets/icons/favicon-32x32.png -------------------------------------------------------------------------------- /app/assets/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/assets/icons/mstile-150x150.png -------------------------------------------------------------------------------- /app/assets/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/assets/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /app/assets/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/assets/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /app/assets/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sync/hackerz/master/app/assets/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/api/devServer.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-global-assign 2 | require = require('esm')(module); 3 | module.exports = require('./server.bs'); 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | lib 5 | .cache 6 | *.bs.js 7 | *.gen.js 8 | renovate.json 9 | .graphql_ppx_cache 10 | storybook-static 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | lib 5 | .cache 6 | *.bs.js 7 | *.gen.js 8 | renovate.json 9 | .graphql_ppx_cache 10 | storybook-static 11 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | lib 5 | .cache 6 | *.bs.js 7 | *.gen.js 8 | renovate.json 9 | .graphql_ppx_cache 10 | storybook-static 11 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | ], 5 | "rules": { 6 | "declaration-colon-newline-after": null, 7 | "value-list-comma-newline-after": null 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .cache 4 | dist 5 | coverage 6 | .DS_Store 7 | 8 | .merlin 9 | /lib/* 10 | !/lib/js/* 11 | /bundledOutputs/ 12 | .bsb.lock 13 | .graphql_ppx_cache 14 | storybook-static 15 | 16 | -------------------------------------------------------------------------------- /.nowignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .cache 4 | coverage 5 | .DS_Store 6 | .merlin 7 | /lib/* 8 | !/lib/js/* 9 | /bundledOutputs/ 10 | .bsb.lock 11 | .graphql_ppx_cache 12 | storybook-static 13 | workflows 14 | .github 15 | -------------------------------------------------------------------------------- /__tests__/utils/graphQLClient.js: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-hooks'; 2 | 3 | const client = new GraphQLClient({ url: 'https:/www.test-url.com' }); 4 | client.logErrorResult = jest.fn(); 5 | 6 | export default client; 7 | -------------------------------------------------------------------------------- /app/pages/More.re: -------------------------------------------------------------------------------- 1 | type css = {. "foo": string}; 2 | [@bs.module] external css: css = "./More.css"; 3 | 4 | [@react.component] 5 | let make = () => { 6 | <>
{ReasonReact.string("More")}
; 7 | }; 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | autoprefixer({ 6 | overrideBrowserslist: ['> 1%', 'last 4 versions'], 7 | }), 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /packages/e2e/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache(true); 3 | 4 | return { 5 | env: { 6 | test: { 7 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]], 8 | }, 9 | }, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /app/client.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { hydrate } from 'react-dom'; 3 | import { register as registerServiceWorker } from './registerServiceWorker'; 4 | import App from './App'; 5 | 6 | registerServiceWorker(); 7 | 8 | hydrate(, document.getElementById('app')); 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/topStories.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | title: 4 | 'Scientists discover the chemicals behind the unique Parkinson’s smell', 5 | id: 19528250, 6 | }, 7 | { 8 | title: 'The Day the Dinosaurs Died', 9 | id: 19526679, 10 | }, 11 | ]; 12 | -------------------------------------------------------------------------------- /scripts/deploy-ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | BRANCH=$(echo "$GITHUB_REF" | sed -e 's/refs\/heads\///') 6 | 7 | if echo "$BRANCH" | grep "^master$"; then 8 | echo 'Deploying production'; 9 | yarn deploy:production 10 | else 11 | echo 'Deploying staging'; 12 | yarn deploy:staging 13 | fi; 14 | -------------------------------------------------------------------------------- /stories/ui.stories.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { make as Link } from '../app/components/Link.bs'; 4 | 5 | // stories 6 | 7 | storiesOf('Link', module).add('Default', () => { 8 | return This is a link; 9 | }); 10 | -------------------------------------------------------------------------------- /app/components/Link.re: -------------------------------------------------------------------------------- 1 | let handleClick = (href, event) => 2 | if (!ReactEvent.Mouse.defaultPrevented(event)) { 3 | ReactEvent.Mouse.preventDefault(event); 4 | ReasonReact.Router.push(href); 5 | }; 6 | 7 | [@react.component] 8 | let make = (~href, ~children, ()) => { 9 | children ; 10 | }; 11 | -------------------------------------------------------------------------------- /__tests__/pages/More.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../utils/testUtils'; 3 | import { make as More } from '../../app/pages/More.bs'; 4 | 5 | describe('More', () => { 6 | test('To render some more text', () => { 7 | const { getByText } = render(); 8 | expect(getByText('More')).toBeTruthy(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /server/utils.js: -------------------------------------------------------------------------------- 1 | export function getQueryParams(req) { 2 | let q = req.url.split('?'), 3 | result = {}; 4 | if (q.length >= 2) { 5 | q[1].split('&').forEach(item => { 6 | try { 7 | result[item.split('=')[0]] = item.split('=')[1]; 8 | } catch (e) { 9 | result[item.split('=')[0]] = ''; 10 | } 11 | }); 12 | } 13 | return result; 14 | } 15 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache(true); 3 | 4 | return { 5 | env: { 6 | test: { 7 | presets: [ 8 | [ 9 | '@babel/preset-env', 10 | { 11 | targets: { 12 | node: 'current', 13 | }, 14 | }, 15 | ], 16 | ['@babel/preset-react'], 17 | ], 18 | }, 19 | }, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/e2e/jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-puppeteer', 3 | testPathIgnorePatterns: ['/src/'], 4 | server: { 5 | command: 'yarn start', 6 | port: 8004, 7 | launchTimeout: 30000, 8 | usedPortAction: 'kill', 9 | }, 10 | launch: { 11 | dumpio: false, 12 | headless: true, 13 | slowMo: 250, 14 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dblechoc/api", 3 | "private": true, 4 | "version": "0.0.0", 5 | "engines": { 6 | "node": "12.x" 7 | }, 8 | "scripts": { 9 | "dev": "micro-dev devServer.js" 10 | }, 11 | "dependencies": { 12 | "body-parser": "1.19.0", 13 | "express": "4.17.1" 14 | }, 15 | "devDependencies": { 16 | "esm": "3.2.25", 17 | "micro": "9.3.4", 18 | "micro-dev": "3.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/pages/More.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as React from "react"; 4 | import * as MoreCss from "./More.css"; 5 | 6 | var css = MoreCss; 7 | 8 | function More(Props) { 9 | return React.createElement(React.Fragment, undefined, React.createElement("div", { 10 | className: css.foo 11 | }, "More")); 12 | } 13 | 14 | var make = More; 15 | 16 | export { 17 | css , 18 | make , 19 | 20 | } 21 | /* css Not a pure module */ 22 | -------------------------------------------------------------------------------- /app/client.re: -------------------------------------------------------------------------------- 1 | [@bs.module "./registerServiceWorker"] 2 | external registerServiceWorker: unit => unit = "register"; 3 | 4 | registerServiceWorker(); 5 | 6 | [@bs.val] [@bs.scope "window"] 7 | external initialState: GraphqlHooks.MemCache.config = "__INITIAL_STATE__"; 8 | 9 | let url = "/api/graphql"; 10 | let cache = GraphqlHooks.createMemCache(~initialState); 11 | let client = GraphqlHooks.createClient(~url, ~cache); 12 | 13 | let app = ; 14 | 15 | ReactDOMRe.hydrateToElementWithId(app, "app"); 16 | -------------------------------------------------------------------------------- /app/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hackerz", 3 | "short_name": "hackerz", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#fff", 8 | "theme_color": "#673ab8", 9 | "icons": [ 10 | { 11 | "src": "./assets/icons/android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "./assets/icons/android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /packages/e2e/utils/graphRequest.js: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | import config from '../jest-puppeteer.config'; 3 | 4 | export async function makeGraphRequest(query, variables) { 5 | const client = new GraphQLClient( 6 | `http://localhost:${config.server.port}/api/graphql`, 7 | ); 8 | return client.request(query, variables); 9 | } 10 | 11 | export const queries = { 12 | topStoriesQuery: ` 13 | query TopStoriesQuery { 14 | topStories(page: 0) { 15 | id 16 | title 17 | } 18 | } 19 | `, 20 | }; 21 | -------------------------------------------------------------------------------- /__tests__/utils/testUtils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { ClientContext } from 'graphql-hooks'; 4 | import client from '../utils/graphQLClient'; 5 | 6 | const AllTheProviders = ({ children }) => { 7 | return ( 8 | {children} 9 | ); 10 | }; 11 | 12 | const customRender = (ui, options) => 13 | render(ui, { wrapper: AllTheProviders, ...options }); 14 | 15 | // re-export everything 16 | export * from '@testing-library/react'; 17 | 18 | // override render method 19 | export { customRender as render }; 20 | -------------------------------------------------------------------------------- /packages/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dblechoc/e2e", 3 | "private": true, 4 | "version": "0.0.0", 5 | "engines": { 6 | "node": "12.x" 7 | }, 8 | "scripts": { 9 | "start": "yarn --cwd ../../ now-lambda" 10 | }, 11 | "devDependencies": { 12 | "graphql-request": "3.1.0", 13 | "jest": "26.5.3", 14 | "jest-puppeteer": "4.4.0", 15 | "puppeteer": "5.3.1" 16 | }, 17 | "jest": { 18 | "preset": "jest-puppeteer", 19 | "moduleFileExtensions": [ 20 | "js", 21 | "jsx" 22 | ], 23 | "transform": { 24 | "^.+\\.(js|jsx)$": "babel-jest" 25 | }, 26 | "testRegex": "/__tests__/.*\\.(js|jsx)$" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /__tests__/utils/fetchMocks.js: -------------------------------------------------------------------------------- 1 | export function mockFetchTopStoriesOnce( 2 | delay = 0, 3 | topStories = require('../fixtures/topStories').default, 4 | ) { 5 | fetch.mockResponseOnce( 6 | () => 7 | new Promise(resolve => 8 | setTimeout( 9 | () => 10 | resolve({ 11 | body: JSON.stringify({ 12 | data: { 13 | topStories, 14 | }, 15 | }), 16 | }), 17 | delay, 18 | ), 19 | ), 20 | ); 21 | 22 | return topStories; 23 | } 24 | 25 | export function mockFetchErrorResponseOnce(message = 'fake error message') { 26 | fetch.mockRejectOnce(new Error(message)); 27 | 28 | return message; 29 | } 30 | -------------------------------------------------------------------------------- /__tests__/components/Link.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { make as Link } from '../../app/components/Link.bs'; 4 | import * as ReasonReactRouter from 'reason-react/src/ReasonReactRouter.js'; 5 | 6 | jest.mock('reason-react/src/ReasonReactRouter.js'); 7 | 8 | describe('Link', () => { 9 | test('To render some link', () => { 10 | const linkText = 'Som test here'; 11 | const href = '/help'; 12 | const { getByText } = render({linkText}); 13 | 14 | const link = getByText(linkText); 15 | expect(link).toBeTruthy(); 16 | 17 | fireEvent.click(link); 18 | expect(ReasonReactRouter.push).toHaveBeenCalledWith(href); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { 2 | configure, 3 | addDecorator, 4 | getStorybook, 5 | setAddon, 6 | } from '@storybook/react'; 7 | import centered from '@storybook/addon-centered/react'; 8 | import createPercyAddon from '@percy-io/percy-storybook'; 9 | 10 | // automatically import all files ending in *.stories.js 11 | const req = require.context('../stories', true, /.stories.jsx?$/); 12 | function loadStories() { 13 | addDecorator(centered); 14 | req.keys().forEach(filename => req(filename)); 15 | } 16 | 17 | const { percyAddon, serializeStories } = createPercyAddon(); 18 | setAddon(percyAddon); 19 | 20 | configure(loadStories, module); 21 | 22 | // NOTE: if you're using the Storybook options addon, call serializeStories *BEFORE* the setOptions call 23 | serializeStories(getStorybook); 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | extends: [ 5 | 'eslint-config-synacor', 6 | 'prettier', 7 | 'prettier/react', 8 | 'plugin:jest/recommended', 9 | ], 10 | plugins: ['jest'], 11 | rules: { 12 | 'no-unused-vars': 'warn', 13 | 'react/sort-comp': 'off', 14 | 'lines-around-comment': 'off', 15 | 'react/prefer-stateless-function': 'off', 16 | }, 17 | settings: { 18 | react: { 19 | version: 'detect', 20 | }, 21 | }, 22 | globals: { 23 | fetch: true, 24 | __DEV__: true, 25 | window: true, 26 | FormData: true, 27 | XMLHttpRequest: true, 28 | requestAnimationFrame: true, 29 | cancelAnimationFrame: true, 30 | page: true, 31 | browser: true, 32 | jestPuppeteer: true, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "hackerz", 4 | "alias": "hackerz.now.sh", 5 | "scope": "dblechoc", 6 | "public": true, 7 | "regions": ["all"], 8 | "builds": [ 9 | { "src": "./dist/**", "use": "@now/static" }, 10 | { 11 | "src": "packages/api/index.js", 12 | "use": "@now/node" 13 | }, 14 | { "src": "server/index.js", "use": "@now/node" } 15 | ], 16 | "routes": [ 17 | { 18 | "src": "^/(.*)\\.(js|css|json|css.map|js.map|ico|webmanifest|png|txt)$", 19 | "dest": "/dist/$1.$2" 20 | }, 21 | { 22 | "src": "/api/graphql", 23 | "dest": "/packages/api/index.js" 24 | }, 25 | { 26 | "src": "^/more", 27 | "dest": "/server/index.js?path=more" 28 | }, 29 | { "src": "^/(.*)", "dest": "/server/index.js" } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /app/components/Link.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as React from "react"; 4 | import * as ReasonReactRouter from "reason-react/src/ReasonReactRouter.js"; 5 | 6 | function handleClick(href, $$event) { 7 | if ($$event.defaultPrevented) { 8 | return 0; 9 | } else { 10 | $$event.preventDefault(); 11 | return ReasonReactRouter.push(href); 12 | } 13 | } 14 | 15 | function Link(Props) { 16 | var href = Props.href; 17 | var children = Props.children; 18 | return React.createElement("a", { 19 | href: href, 20 | onClick: (function (param) { 21 | return handleClick(href, param); 22 | }) 23 | }, children); 24 | } 25 | 26 | var make = Link; 27 | 28 | export { 29 | handleClick , 30 | make , 31 | 32 | } 33 | /* react Not a pure module */ 34 | -------------------------------------------------------------------------------- /packages/e2e/__tests__/basic.test.js: -------------------------------------------------------------------------------- 1 | import * as graphRequest from '../utils/graphRequest'; 2 | import config from '../jest-puppeteer.config'; 3 | 4 | const timeout = 30000; 5 | 6 | const openPage = (pageUrl = '/') => 7 | global.page.goto(`http://localhost:${config.server.port}${pageUrl}`); 8 | 9 | describe('Basic integration', () => { 10 | let topStoriesResponse; 11 | 12 | beforeAll(async () => { 13 | const response = await graphRequest.makeGraphRequest( 14 | graphRequest.queries.topStoriesQuery, 15 | ); 16 | 17 | topStoriesResponse = response.topStories; 18 | 19 | await openPage(); 20 | }, timeout); 21 | 22 | it( 23 | 'loads the index', 24 | async () => { 25 | await expect(global.page).toMatch(topStoriesResponse[0].title); 26 | }, 27 | timeout, 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /.github/main.workflow: -------------------------------------------------------------------------------- 1 | workflow "Build, Test, and Deploy" { 2 | on = "push" 3 | resolves = [ 4 | "Install", 5 | "Test", 6 | "End to End", 7 | "Deploy" 8 | ] 9 | } 10 | 11 | action "Install" { 12 | uses = "./workflows/action-puppeteer/" 13 | args = "install" 14 | } 15 | 16 | action "Test" { 17 | uses = "./workflows/action-puppeteer/" 18 | needs = ["Install"] 19 | args = "ci" 20 | } 21 | 22 | action "Snapshot UI" { 23 | uses = "./workflows/action-puppeteer/" 24 | needs = ["Test"] 25 | args = "snapshot-ui" 26 | secrets = ["PERCY_TOKEN"] 27 | } 28 | 29 | action "End to End" { 30 | uses = "./workflows/action-puppeteer/" 31 | needs = ["Test"] 32 | args = "e2e" 33 | } 34 | 35 | action "Deploy" { 36 | uses = "./workflows/action-puppeteer/" 37 | needs = ["End to End", "Snapshot UI"] 38 | args = "deploy" 39 | secrets = ["NOW_TOKEN"] 40 | } 41 | -------------------------------------------------------------------------------- /jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["/__tests__"], 3 | "setupFiles": ["/jest/setup.js"], 4 | "setupFilesAfterEnv": ["@testing-library/jest-dom/extend-expect"], 5 | "modulePaths": ["", "/node_modules/"], 6 | "moduleFileExtensions": ["js", "jsx"], 7 | "moduleNameMapper": { 8 | "\\.(css|less)$": "identity-obj-proxy", 9 | "@dblechoc/(.+)$": "packages/$1/src" 10 | }, 11 | "transform": { 12 | "^.+\\.jsx?$": "babel-jest", 13 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest/fileTransformer.js" 14 | }, 15 | "testRegex": "__tests__/.*.test.js$", 16 | "transformIgnorePatterns": ["node_modules/(?!(bs-platform|reason-react)/)"], 17 | "testPathIgnorePatterns": [ 18 | "/dist/", 19 | "/packages/e2e/", 20 | "/packages/*/dist" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /app/App.re: -------------------------------------------------------------------------------- 1 | [@bs.val] external browser: bool = "process.browser"; 2 | 3 | if (!browser) { 4 | %raw 5 | "require('isomorphic-fetch')"; 6 | }; 7 | 8 | type route = 9 | | Home 10 | | More; 11 | 12 | let mapPathToRoute = (path: list(string)) => 13 | switch (path) { 14 | | [] => Home 15 | | ["more"] => More 16 | | _ => Home 17 | }; 18 | 19 | [@react.component] 20 | let make = (~client, ~serverPath=?) => { 21 | let url = 22 | switch (serverPath) { 23 | | Some(path) => 24 | ReasonReactRouter.useUrl( 25 | ~serverUrl={path: [path], hash: "", search: ""}, 26 | (), 27 | ) 28 | | None => ReasonReactRouter.useUrl() 29 | }; 30 | 31 |
32 | 33 |
34 | {switch (url.path |> mapPathToRoute) { 35 | | Home => 36 | | More => 37 | }} 38 |
39 |
40 |
; 41 | }; 42 | -------------------------------------------------------------------------------- /app/client.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as React from "react"; 4 | import * as ReactDOMRe from "reason-react/src/ReactDOMRe.js"; 5 | import * as App$Hackerz from "./App.bs.js"; 6 | import * as GraphqlHooks$Hackerz from "./GraphqlHooks.bs.js"; 7 | import * as RegisterServiceWorker from "./registerServiceWorker"; 8 | 9 | function registerServiceWorker(prim) { 10 | RegisterServiceWorker.register(); 11 | return /* () */0; 12 | } 13 | 14 | RegisterServiceWorker.register(); 15 | 16 | var url = "/api/graphql"; 17 | 18 | var cache = GraphqlHooks$Hackerz.createMemCache(window.__INITIAL_STATE__); 19 | 20 | var client = GraphqlHooks$Hackerz.createClient(url, cache); 21 | 22 | var app = React.createElement(App$Hackerz.make, { 23 | client: client 24 | }); 25 | 26 | ReactDOMRe.hydrateToElementWithId(app, "app"); 27 | 28 | export { 29 | registerServiceWorker , 30 | url , 31 | cache , 32 | client , 33 | app , 34 | 35 | } 36 | /* Not a pure module */ 37 | -------------------------------------------------------------------------------- /__tests__/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent, waitForElement } from '@testing-library/react'; 3 | import { make as App } from '../app/App.bs'; 4 | import { mockFetchTopStoriesOnce } from './utils/fetchMocks'; 5 | import client from './utils/graphQLClient'; 6 | 7 | describe('Initial Test of the App', () => { 8 | let topStories; 9 | 10 | beforeEach(() => { 11 | topStories = mockFetchTopStoriesOnce(); 12 | }); 13 | 14 | afterEach(() => { 15 | fetch.resetMocks(); 16 | }); 17 | 18 | test('To render home page', async () => { 19 | const { getByText } = render(); 20 | 21 | expect(getByText('HELLO')).toBeTruthy(); 22 | 23 | await waitForElement(() => getByText(topStories[0].title)); 24 | await waitForElement(() => getByText(topStories[1].title)); 25 | 26 | const link = getByText('See some more'); 27 | expect(link).toBeTruthy(); 28 | 29 | fireEvent.click(link); 30 | await waitForElement(() => getByText('More')); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /app/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | function register() { 2 | const isLocalhost = Boolean( 3 | window.location.hostname === 'localhost' || 4 | // [::1] is the IPv6 localhost address. 5 | window.location.hostname === '[::1]' || 6 | // 127.0.0.1/8 is considered localhost for IPv4. 7 | window.location.hostname.match( 8 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, 9 | ), 10 | ); 11 | 12 | if (!isLocalhost && 'serviceWorker' in navigator) { 13 | window.addEventListener('load', function() { 14 | navigator.serviceWorker 15 | .register('./service-worker.js') 16 | .then(function(registration) { 17 | // eslint-disable-next-line no-console 18 | console.log('SW registered: ', registration); 19 | }) 20 | .catch(function(registrationError) { 21 | // eslint-disable-next-line no-console 22 | console.log('SW registration failed: ', registrationError); 23 | }); 24 | }); 25 | } 26 | } 27 | 28 | export { register }; 29 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackerz", 3 | "reason": { 4 | "react-jsx": 3 5 | }, 6 | "bsc-flags": ["-bs-no-version-header", "-bs-super-errors"], 7 | "ppx-flags": ["@baransu/graphql_ppx_re/ppx6", "bs-let/ppx"], 8 | "namespace": true, 9 | "bs-dependencies": [ 10 | "reason-react", 11 | "reason-graphql", 12 | "reason-future", 13 | "@glennsl/bs-json", 14 | "bs-fetch" 15 | ], 16 | "sources": [ 17 | { 18 | "dir": "app", 19 | "subdirs": true 20 | }, 21 | { 22 | "dir": "server", 23 | "subdirs": true 24 | }, 25 | { 26 | "dir": "packages/api", 27 | "subdirs": true 28 | } 29 | ], 30 | "package-specs": { 31 | "module": "es6", 32 | "in-source": true 33 | }, 34 | "suffix": ".bs.js", 35 | "warnings": { 36 | "number": "-45-44", 37 | "error": "+101" 38 | }, 39 | "refmt": 3, 40 | "gentypeconfig": { 41 | "language": "untyped", 42 | "shims": {}, 43 | "debug": { 44 | "all": false, 45 | "basic": false 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/pages/Home.re: -------------------------------------------------------------------------------- 1 | type css = {. "foo": string}; 2 | [@bs.module] external css: css = "./Home.css"; 3 | 4 | let ste = ReasonReact.string; 5 | 6 | module TopStoriesQuery = [%graphql 7 | {| 8 | { 9 | topStories(page: 0) { 10 | id 11 | title 12 | } 13 | } 14 | |} 15 | ]; 16 | 17 | let query = TopStoriesQuery.make(); 18 | 19 | [@react.component] 20 | let make = () => { 21 | let result = GraphqlHooks.useQuery(~query); 22 | 23 | let queryResult = 24 | switch (result) { 25 | | Loading =>
{ste("Loading")}
26 | | Error(message) =>
{ste(message)}
27 | | Data(response) => 28 | switch (response##topStories) { 29 | | Some(stories) => 30 |
    31 | {stories 32 | |> Array.map(story => 33 |
  • string_of_int}> 34 | {story##title |> ste} 35 |
  • 36 | ) 37 | |> ReasonReact.array} 38 |
39 | | _ => "No stories found" |> ste 40 | } 41 | }; 42 | 43 | <> 44 |
{"HELLO" |> ste}
45 | queryResult 46 | {"See some more" |> ste} 47 | ; 48 | }; 49 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Hackerz 12 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/App.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as React from "react"; 4 | import * as Home$Hackerz from "./pages/Home.bs.js"; 5 | import * as More$Hackerz from "./pages/More.bs.js"; 6 | import * as GraphqlHooks from "graphql-hooks"; 7 | import * as ReasonReactRouter from "reason-react/src/ReasonReactRouter.js"; 8 | 9 | if (!process.browser) { 10 | ((require('isomorphic-fetch'))); 11 | } 12 | 13 | function mapPathToRoute(path) { 14 | if (path && path[0] === "more" && !path[1]) { 15 | return /* More */1; 16 | } else { 17 | return /* Home */0; 18 | } 19 | } 20 | 21 | function App(Props) { 22 | var client = Props.client; 23 | var serverPath = Props.serverPath; 24 | var url = serverPath !== undefined ? ReasonReactRouter.useUrl({ 25 | path: /* :: */[ 26 | serverPath, 27 | /* [] */0 28 | ], 29 | hash: "", 30 | search: "" 31 | }, /* () */0) : ReasonReactRouter.useUrl(undefined, /* () */0); 32 | var match = mapPathToRoute(url.path); 33 | return React.createElement("div", undefined, React.createElement(GraphqlHooks.ClientContext.Provider, { 34 | value: client, 35 | children: React.createElement("main", undefined, match ? React.createElement(More$Hackerz.make, { }) : React.createElement(Home$Hackerz.make, { })) 36 | })); 37 | } 38 | 39 | var make = App; 40 | 41 | export { 42 | mapPathToRoute , 43 | make , 44 | 45 | } 46 | /* Not a pure module */ 47 | -------------------------------------------------------------------------------- /packages/api/GraphqlSchema.re: -------------------------------------------------------------------------------- 1 | open GraphqlFuture; 2 | 3 | let storyTypeLazy = 4 | lazy 5 | Schema.( 6 | obj("story", ~fields=_ => 7 | [ 8 | field( 9 | "id", 10 | nonnull(int), 11 | ~args=[], 12 | ~resolve=(_ctx, story: StoryData.story) => 13 | story.id 14 | ), 15 | field( 16 | "title", 17 | nonnull(string), 18 | ~args=[], 19 | ~resolve=(_ctx, story: StoryData.story) => 20 | story.title 21 | ), 22 | ] 23 | ) 24 | ); 25 | 26 | let storyType = Lazy.force(storyTypeLazy); 27 | 28 | let handleJsPromiseError = Js.String.make; 29 | 30 | let query = 31 | Schema.( 32 | query([ 33 | async_field( 34 | "story", 35 | storyType, 36 | ~args=Arg.[arg("id", nonnull(int))], 37 | ~resolve=(_ctx, (), id) => 38 | StoryData.getStory(id) 39 | ->FutureJs.fromPromise(handleJsPromiseError) 40 | ->Future.mapOk(result => Some(result)) 41 | ), 42 | async_field( 43 | "topStories", 44 | list(nonnull(storyType)), 45 | ~args=Arg.[arg("page", nonnull(int))], 46 | ~resolve=(_ctx, (), page) => 47 | StoryData.getTopStories(page) 48 | ->FutureJs.fromPromise(handleJsPromiseError) 49 | ->Future.mapOk(result => Some(Belt.List.fromArray(result))) 50 | ), 51 | ]) 52 | ); 53 | 54 | let schema: Schema.schema(unit) = Schema.create(query); 55 | -------------------------------------------------------------------------------- /__tests__/pages/Home.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitForElement } from '../utils/testUtils'; 3 | import { make as Home } from '../../app/pages/Home.bs'; 4 | import { 5 | mockFetchTopStoriesOnce, 6 | mockFetchErrorResponseOnce, 7 | } from '../utils/fetchMocks'; 8 | 9 | describe('Home', () => { 10 | afterEach(() => { 11 | fetch.resetMocks(); 12 | }); 13 | 14 | test('To successfully render top stories', async () => { 15 | let delay = 10; 16 | const topStories = mockFetchTopStoriesOnce(delay); 17 | 18 | const { getByText } = render(); 19 | expect(getByText('HELLO')).toBeTruthy(); 20 | 21 | await waitForElement(() => getByText('Loading')); 22 | 23 | await waitForElement(() => getByText(topStories[0].title)); 24 | await waitForElement(() => getByText(topStories[1].title)); 25 | 26 | expect(getByText('See some more')).toBeTruthy(); 27 | }); 28 | 29 | // eslint-disable-next-line jest/expect-expect 30 | test('To successfully render no top stories', async () => { 31 | mockFetchTopStoriesOnce(0, null); 32 | 33 | const { getByText } = render(); 34 | 35 | await waitForElement(() => getByText('No stories found')); 36 | }); 37 | 38 | // eslint-disable-next-line jest/expect-expect 39 | test('To error when fetching top stories', async () => { 40 | const errorMessage = mockFetchErrorResponseOnce(); 41 | 42 | const { getByText } = render(); 43 | 44 | await waitForElement(() => getByText(errorMessage)); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main workflow 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Install, Test, Snapshot, e2e and Publish 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | 10 | - name: Setup node 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: '12.x' 14 | 15 | - name: Setup puppeteer 16 | run: | 17 | sudo apt-get update 18 | sudo apt-get install -y wget gnupg --no-install-recommends 19 | wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 20 | sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 21 | sudo apt-get update 22 | sudo apt-get -y install procps google-chrome-unstable --no-install-recommends 23 | 24 | - name: Get yarn cache 25 | id: yarn-cache 26 | run: echo "::set-output name=dir::$(yarn cache dir)" 27 | 28 | - name: Cache Yarn 29 | uses: actions/cache@v1 30 | with: 31 | path: ${{ steps.yarn-cache.outputs.dir }} 32 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-yarn- 35 | 36 | - name: Install 37 | run: yarn install 38 | 39 | - name: Test 40 | run: yarn ci 41 | 42 | - name: Snapshot UI 43 | run: yarn snapshot-ui 44 | env: 45 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 46 | 47 | - name: End to End 48 | run: yarn e2e 49 | 50 | - name: Deploy 51 | run: yarn deploy 52 | env: 53 | NOW_TOKEN: ${{ secrets.NOW_TOKEN }} 54 | -------------------------------------------------------------------------------- /packages/api/server.re: -------------------------------------------------------------------------------- 1 | open Graphql.Language; 2 | open GraphqlFuture; 3 | 4 | let schema = GraphqlSchema.schema; 5 | 6 | type express; 7 | [@bs.module "express"] external express: unit => express = "default"; 8 | 9 | type response; 10 | [@bs.send] external send: (response, string) => unit = "send"; 11 | [@bs.send] external sendJSON: (response, Js.Json.t) => unit = "send"; 12 | 13 | type request; 14 | type handler = (request, response) => unit; 15 | [@bs.send] external use: (express, handler) => unit = "use"; 16 | [@bs.send] external get: (express, string, handler) => unit = "get"; 17 | [@bs.send] external post: (express, string, handler) => unit = "post"; 18 | 19 | [@bs.get] [@bs.return null_undefined_to_opt] 20 | external bodyJSON: request => option(Js.Json.t) = "body"; 21 | 22 | let app = express(); 23 | 24 | module BodyParser = { 25 | [@bs.val] [@bs.module "body-parser"] external json: unit => handler = "json"; 26 | }; 27 | 28 | use(app, BodyParser.json()); 29 | 30 | let getQueryString = json => 31 | json 32 | ->Belt.Option.flatMap(json => Js.Json.decodeObject(json)) 33 | ->Belt.Option.flatMap(dict => Js.Dict.get(dict, "query")) 34 | ->Belt.Option.flatMap(json => Js.Json.decodeString(json)); 35 | 36 | let executeGraphqlQuery = query => 37 | Schema.execute( 38 | schema, 39 | ~document=Parser.parse(query)->Belt.Result.getExn, 40 | ~ctx=(), 41 | ) 42 | ->Schema.resultToJson; 43 | 44 | let graphqlHandler = (req, res) => { 45 | let queryString = req->bodyJSON->getQueryString; 46 | 47 | switch (queryString) { 48 | | Some(query) => 49 | query 50 | ->executeGraphqlQuery 51 | ->Schema.Io.map(jsonResult => sendJSON(res, jsonResult)) 52 | ->ignore 53 | | _ => send(res, "unable to parse query") 54 | }; 55 | }; 56 | 57 | get(app, "*", graphqlHandler); 58 | post(app, "*", graphqlHandler); 59 | 60 | let default = app; 61 | -------------------------------------------------------------------------------- /packages/api/StoryData.re: -------------------------------------------------------------------------------- 1 | %raw 2 | "require('isomorphic-fetch')"; 3 | 4 | let apiBaseUrl = "https://hacker-news.firebaseio.com"; 5 | 6 | let topStoriesUrl = () => {j|$apiBaseUrl/v0/topstories.json|j}; 7 | 8 | let storyUrl = id => {j|$apiBaseUrl/v0/item/$id.json|j}; 9 | 10 | type story = { 11 | by: string, 12 | descendants: option(int), 13 | id: int, 14 | score: int, 15 | time: int, 16 | title: string, 17 | url: option(string), 18 | }; 19 | 20 | type topstories = array(story); 21 | 22 | module Decode = { 23 | let idsArray = (json): array(int) => Json.Decode.(json |> array(int)); 24 | let story = (json): story => 25 | Json.Decode.{ 26 | by: json |> field("by", string), 27 | descendants: json |> optional(field("descendants", int)), 28 | id: json |> field("id", int), 29 | score: json |> field("score", int), 30 | time: json |> field("time", int), 31 | title: json |> field("title", string), 32 | url: json |> optional(field("url", string)), 33 | }; 34 | let stories = (json): array(story) => Json.Decode.(json |> array(story)); 35 | }; 36 | 37 | let getStory = id => 38 | Js.Promise.( 39 | Fetch.fetch(storyUrl(id)) 40 | |> then_(Fetch.Response.json) 41 | |> then_(json => json |> Decode.story |> resolve) 42 | ); 43 | 44 | let perPage = 25; 45 | 46 | let sliced = (array, ~page: int) => 47 | array |> Belt.Array.slice(~offset=page * perPage, ~len=perPage); 48 | 49 | let getStoriesForIds = ids => { 50 | Js.Promise.( 51 | ids 52 | |> Array.map(getStory) 53 | |> Js.Promise.all 54 | |> Js.Promise.then_(res => res |> resolve) 55 | ); 56 | }; 57 | 58 | let getTopStories = page => { 59 | Js.Promise.( 60 | Fetch.fetch(topStoriesUrl()) 61 | |> then_(Fetch.Response.json) 62 | |> then_(json => json |> Decode.idsArray |> sliced(~page) |> resolve) 63 | |> then_(getStoriesForIds) 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /packages/api/server.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as Curry from "bs-platform/lib/es6/curry.js"; 4 | import * as Js_dict from "bs-platform/lib/es6/js_dict.js"; 5 | import * as Js_json from "bs-platform/lib/es6/js_json.js"; 6 | import * as Express from "express"; 7 | import * as Belt_Option from "bs-platform/lib/es6/belt_Option.js"; 8 | import * as Belt_Result from "bs-platform/lib/es6/belt_Result.js"; 9 | import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; 10 | import * as BodyParser from "body-parser"; 11 | import * as GraphqlFuture from "reason-graphql/src/variations/GraphqlFuture.bs.js"; 12 | import * as GraphqlSchema$Hackerz from "./GraphqlSchema.bs.js"; 13 | import * as Graphql_Language_Parser from "reason-graphql/src/language/Graphql_Language_Parser.bs.js"; 14 | 15 | var app = Express.default(); 16 | 17 | var BodyParser$1 = { }; 18 | 19 | app.use(BodyParser.json()); 20 | 21 | function getQueryString(json) { 22 | return Belt_Option.flatMap(Belt_Option.flatMap(Belt_Option.flatMap(json, Js_json.decodeObject), (function (dict) { 23 | return Js_dict.get(dict, "query"); 24 | })), Js_json.decodeString); 25 | } 26 | 27 | function executeGraphqlQuery(query) { 28 | return Curry._1(GraphqlFuture.Schema.resultToJson, Curry._4(GraphqlFuture.Schema.execute, undefined, Belt_Result.getExn(Graphql_Language_Parser.parse(query)), GraphqlSchema$Hackerz.schema, /* () */0)); 29 | } 30 | 31 | function graphqlHandler(req, res) { 32 | var queryString = getQueryString(Caml_option.nullable_to_opt(req.body)); 33 | if (queryString !== undefined) { 34 | Curry._2(GraphqlFuture.Schema.Io.map, executeGraphqlQuery(queryString), (function (jsonResult) { 35 | res.send(jsonResult); 36 | return /* () */0; 37 | })); 38 | return /* () */0; 39 | } else { 40 | res.send("unable to parse query"); 41 | return /* () */0; 42 | } 43 | } 44 | 45 | app.get("*", graphqlHandler); 46 | 47 | app.post("*", graphqlHandler); 48 | 49 | var schema = GraphqlSchema$Hackerz.schema; 50 | 51 | var $$default = app; 52 | 53 | export { 54 | schema , 55 | app , 56 | BodyParser$1 as BodyParser, 57 | getQueryString , 58 | executeGraphqlQuery , 59 | graphqlHandler , 60 | $$default , 61 | $$default as default, 62 | 63 | } 64 | /* app Not a pure module */ 65 | -------------------------------------------------------------------------------- /server/ssrMiddleware.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderToString, renderToStaticMarkup } from 'react-dom/server'; 3 | import { readFileSync } from 'fs'; 4 | const { GraphQLClient } = require('graphql-hooks'); 5 | const memCache = require('graphql-hooks-memcache'); 6 | const fetch = require('isomorphic-fetch'); 7 | import { getQueryParams } from './utils'; 8 | 9 | import { make as App } from '../app/App.bs.js'; 10 | 11 | const rawHTML = readFileSync(`${__dirname}/../dist/index.html`, 'utf8'); 12 | 13 | module.exports = async (req, res) => { 14 | try { 15 | const { 16 | headers: { host }, 17 | } = req; 18 | 19 | // host.includes('localhost') is definitely not good 20 | const url = `${ 21 | host.includes('localhost') ? 'http' : 'https' 22 | }://${host}/api/graphql`; 23 | 24 | const cache = memCache(); 25 | const client = new GraphQLClient({ 26 | url, 27 | cache, 28 | fetch, 29 | ssrMode: true, 30 | }); 31 | 32 | const queryParams = getQueryParams(req); 33 | const serverPath = queryParams.path || ''; 34 | let app = ; 35 | // first render for ssr cache 36 | renderToStaticMarkup(app); 37 | 38 | // prefetch graphql queries 39 | let initialState = client.cache.getInitialState(); 40 | if (client.ssrPromises.length) { 41 | await Promise.all(client.ssrPromises); 42 | // clear promises 43 | client.ssrPromises = []; 44 | // recurse there may be dependant queries 45 | initialState = client.cache.getInitialState(); 46 | } 47 | 48 | client.ssrMode = false; 49 | const rendered = renderToString(app); 50 | 51 | res.setHeader('Content-Type', 'text/html'); 52 | 53 | // hydrate react app 54 | const appString = '
'; 55 | 56 | // hydrate graphql state 57 | const scriptString = ''; 58 | 59 | const finalHTML = rawHTML 60 | .replace(appString, `
${rendered}
`) 61 | .replace( 62 | scriptString, 63 | ``, 69 | ); 70 | 71 | res.end(finalHTML); 72 | } catch (e) { 73 | console.error(e); 74 | res.writeHead(500); 75 | res.end(); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /app/GraphqlHooks.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as Block from "bs-platform/lib/es6/block.js"; 4 | import * as Curry from "bs-platform/lib/es6/curry.js"; 5 | import * as Belt_Array from "bs-platform/lib/es6/belt_Array.js"; 6 | import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; 7 | import * as GraphqlHooks from "graphql-hooks"; 8 | import * as GraphqlHooksMemcache from "graphql-hooks-memcache"; 9 | 10 | var MemCache = { }; 11 | 12 | function createMemCache(initialState) { 13 | return GraphqlHooksMemcache.default(initialState); 14 | } 15 | 16 | var Client = { }; 17 | 18 | function createClient(url, cache) { 19 | return new GraphqlHooks.GraphQLClient({ 20 | url: url, 21 | cache: cache 22 | }); 23 | } 24 | 25 | var provider = GraphqlHooks.ClientContext.Provider; 26 | 27 | var Provider = { 28 | provider: provider 29 | }; 30 | 31 | function extractErrorMessage(clientRequestError) { 32 | if (clientRequestError !== undefined) { 33 | var match = clientRequestError; 34 | var match$1 = match.fetchError; 35 | if (match$1 !== undefined) { 36 | return match$1.message; 37 | } else { 38 | var match$2 = match.httpError; 39 | if (match$2 !== undefined) { 40 | return match$2.body; 41 | } else { 42 | var match$3 = match.graphQLErrors; 43 | if (match$3 !== undefined) { 44 | return Belt_Array.reduce(match$3, "", (function (currentMsg, error) { 45 | return currentMsg + (", " + error.message); 46 | })); 47 | } else { 48 | return ; 49 | } 50 | } 51 | } 52 | } 53 | 54 | } 55 | 56 | function useQuery(query) { 57 | var result = GraphqlHooks.useQuery(query.query); 58 | var match = result.loading; 59 | var match$1 = extractErrorMessage(result.error); 60 | var match$2 = result.data; 61 | if (match) { 62 | return /* Loading */0; 63 | } else if (match$1 !== undefined) { 64 | if (match$2 !== undefined) { 65 | return /* Error */Block.__(0, ["Something went wrong"]); 66 | } else { 67 | return /* Error */Block.__(0, [match$1]); 68 | } 69 | } else if (match$2 !== undefined) { 70 | return /* Data */Block.__(1, [Curry._1(query.parse, Caml_option.valFromOption(match$2))]); 71 | } else { 72 | return /* Error */Block.__(0, ["Something went wrong"]); 73 | } 74 | } 75 | 76 | export { 77 | MemCache , 78 | createMemCache , 79 | Client , 80 | createClient , 81 | Provider , 82 | extractErrorMessage , 83 | useQuery , 84 | 85 | } 86 | /* provider Not a pure module */ 87 | -------------------------------------------------------------------------------- /app/GraphqlHooks.re: -------------------------------------------------------------------------------- 1 | module MemCache = { 2 | type t; 3 | 4 | type config = {initialState: Js.Json.t}; 5 | 6 | type conf = Js.Json.t; 7 | 8 | [@bs.module "graphql-hooks-memcache"] 9 | external _createMemCache: config => t = "default"; 10 | }; 11 | 12 | let createMemCache = (~initialState) => { 13 | let config = { 14 | initialState; 15 | }; 16 | MemCache._createMemCache(config); 17 | }; 18 | 19 | module Client = { 20 | type t; 21 | 22 | type config = { 23 | url: string, 24 | cache: MemCache.t, 25 | }; 26 | 27 | [@bs.module "graphql-hooks"] [@bs.new] 28 | external _createClient: config => t = "GraphQLClient"; 29 | }; 30 | 31 | let createClient = (~url: string, ~cache: MemCache.t) => { 32 | open Client; 33 | let config = {url, cache}; 34 | Client._createClient(config); 35 | }; 36 | 37 | [@bs.val] [@bs.module "graphql-hooks"] 38 | external context: React.Context.t(Client.t) = "ClientContext"; 39 | 40 | module Provider = { 41 | let provider = React.Context.provider(context); 42 | 43 | [@react.component] [@bs.module "graphql-hooks"] [@bs.scope "ClientContext"] 44 | external make: (~value: Client.t, ~children: React.element) => React.element = 45 | "Provider"; 46 | }; 47 | 48 | type error = {message: string}; 49 | 50 | type httpError = { 51 | status: int, 52 | statusText: string, 53 | body: string, 54 | }; 55 | 56 | type clientRequestError = { 57 | fetchError: option(error), 58 | httpError: option(httpError), 59 | graphQLErrors: option(array(error)), 60 | }; 61 | 62 | type clientRequestResult('any) = { 63 | loading: bool, 64 | cacheHit: bool, 65 | error: option(clientRequestError), 66 | data: 'any, 67 | }; 68 | 69 | type queryResponse('a) = 70 | | Loading 71 | | Error(string) 72 | | Data('a); 73 | 74 | [@bs.module "graphql-hooks"] 75 | external _useQuery: string => clientRequestResult('any) = "useQuery"; 76 | 77 | let extractErrorMessage = (~clientRequestError) => { 78 | switch (clientRequestError) { 79 | | Some({fetchError: Some(fetchError), _}) => Some(fetchError.message) 80 | | Some({httpError: Some(httpError), _}) => Some(httpError.body) 81 | | Some({graphQLErrors: Some(graphQLErrors), _}) => 82 | graphQLErrors 83 | ->Belt.Array.reduce("", (currentMsg, error) => 84 | currentMsg ++ ", " ++ error.message 85 | ) 86 | ->Some 87 | | _ => None 88 | }; 89 | }; 90 | 91 | let useQuery = (~query) => { 92 | let result = _useQuery(query##query); 93 | switch ( 94 | result.loading, 95 | extractErrorMessage(~clientRequestError=result.error), 96 | result.data, 97 | ) { 98 | | (true, _, _) => Loading 99 | | (false, None, Some(response)) => Data(response |> query##parse) 100 | | (false, Some(message), None) => Error(message) 101 | | _ => Error("Something went wrong") 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /packages/api/StoryData.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as $$Array from "bs-platform/lib/es6/array.js"; 4 | import * as Belt_Array from "bs-platform/lib/es6/belt_Array.js"; 5 | import * as Caml_int32 from "bs-platform/lib/es6/caml_int32.js"; 6 | import * as Json_decode from "@glennsl/bs-json/src/Json_decode.bs.js"; 7 | 8 | require('isomorphic-fetch') 9 | ; 10 | 11 | var apiBaseUrl = "https://hacker-news.firebaseio.com"; 12 | 13 | function topStoriesUrl(param) { 14 | return "" + (String(apiBaseUrl) + "/v0/topstories.json"); 15 | } 16 | 17 | function storyUrl(id) { 18 | return "" + (String(apiBaseUrl) + ("/v0/item/" + (String(id) + ".json"))); 19 | } 20 | 21 | function idsArray(json) { 22 | return Json_decode.array(Json_decode.$$int, json); 23 | } 24 | 25 | function story(json) { 26 | return { 27 | by: Json_decode.field("by", Json_decode.string, json), 28 | descendants: Json_decode.optional((function (param) { 29 | return Json_decode.field("descendants", Json_decode.$$int, param); 30 | }), json), 31 | id: Json_decode.field("id", Json_decode.$$int, json), 32 | score: Json_decode.field("score", Json_decode.$$int, json), 33 | time: Json_decode.field("time", Json_decode.$$int, json), 34 | title: Json_decode.field("title", Json_decode.string, json), 35 | url: Json_decode.optional((function (param) { 36 | return Json_decode.field("url", Json_decode.string, param); 37 | }), json) 38 | }; 39 | } 40 | 41 | function stories(json) { 42 | return Json_decode.array(story, json); 43 | } 44 | 45 | var Decode = { 46 | idsArray: idsArray, 47 | story: story, 48 | stories: stories 49 | }; 50 | 51 | function getStory(id) { 52 | return fetch(storyUrl(id)).then((function (prim) { 53 | return prim.json(); 54 | })).then((function (json) { 55 | return Promise.resolve(story(json)); 56 | })); 57 | } 58 | 59 | function sliced(array, page) { 60 | var arg = Caml_int32.imul(page, 25); 61 | return (function (param) { 62 | return Belt_Array.slice(param, arg, 25); 63 | })(array); 64 | } 65 | 66 | function getStoriesForIds(ids) { 67 | return Promise.all($$Array.map(getStory, ids)).then((function (res) { 68 | return Promise.resolve(res); 69 | })); 70 | } 71 | 72 | function getTopStories(page) { 73 | return fetch(topStoriesUrl(/* () */0)).then((function (prim) { 74 | return prim.json(); 75 | })).then((function (json) { 76 | return Promise.resolve(sliced(Json_decode.array(Json_decode.$$int, json), page)); 77 | })).then(getStoriesForIds); 78 | } 79 | 80 | var perPage = 25; 81 | 82 | export { 83 | apiBaseUrl , 84 | topStoriesUrl , 85 | storyUrl , 86 | Decode , 87 | getStory , 88 | perPage , 89 | sliced , 90 | getStoriesForIds , 91 | getTopStories , 92 | 93 | } 94 | /* Not a pure module */ 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## hackerz 2 | 3 | [View the application](https://hackerz.now.sh) 4 | 5 | Ultra high performance progressive web application built with React + Reason (hooks, react ppx 3), GraphQL (api and client) and rollup. 6 | 7 | [![This project is using Percy.io for visual regression testing.](https://percy.io/static/images/percy-badge.svg)](https://percy.io) 8 | 9 | ## Features 10 | 11 | - Progressive web app 12 | - offline 13 | - install prompts on supported platforms 14 | - Server side rendering (including prefetching graphql queries for the current route) 15 | - GraphQL (client using graphql-hooks) 16 | - GrappQL (server, using reason-graphql) 17 | - Rollup (dual bundling, module and nomodule) 18 | - Now.sh 2.x 19 | - Reason React (hooks, react ppx 3) 20 | - Yarn (monorepo with workspaces) 21 | - Routing (including ssr support, static routing) 22 | 23 | ## Things to know 24 | 25 | - A production build is deployed from a merge to master 26 | - A staging build is deployed from a PR against master 27 | 28 | ## Setting the project up locally 29 | 30 | First of all make sure you are using node `8.11.3` and latest yarn, you can always have a look at the `engines` section of the `package.json`. 31 | 32 | ```sh 33 | $ yarn (install) 34 | $ yarn dev 35 | ``` 36 | 37 | After doing this, you'll have a server with hot-reloading (ui only) running at [http://localhost:8004](http://localhost:8004) 38 | 39 | You can also start in production. 40 | 41 | ```sh 42 | $ yarn start 43 | ``` 44 | 45 | After doing this, you'll have a server running at [http://localhost:8004](http://localhost:8004) 46 | 47 | ## If working on the graphql server and want hot reloading 48 | 49 | ```sh 50 | $ yarn dev-graphql 51 | ``` 52 | 53 | After doing this, you'll have a server with hot-reloading running at [http://localhost:3000](http://localhost:3000) 54 | 55 | ## When changing the graphql server schema 56 | 57 | ```sh 58 | $ yarn send-introspection-query http://localhost:8004/api/graphql 59 | ``` 60 | 61 | ## Run tests and friends 62 | 63 | We don't want to use snapshots, we use also use [react-testing-library](https://github.com/testing-library/react-testing-library) to avoid having to use enzyme and to enforce best test practices. 64 | 65 | ```sh 66 | $ yarn lint 67 | $ yarn build 68 | $ yarn test 69 | ``` 70 | 71 | or 72 | 73 | ```sh 74 | $ yarn ci 75 | ``` 76 | 77 | ## End to end tests 78 | 79 | The end to end test go fetch latest news from the server and expect it to be found inside the homepage. Please check `e2e/basic.test.js` for more details. 80 | 81 | ```sh 82 | $ yarn e2e 83 | ``` 84 | 85 | ## Storybook 86 | 87 | This is where we list all our components (comes with hot reloading) 88 | 89 | ```sh 90 | $ yarn storybook 91 | ``` 92 | 93 | After doing this, you'll have a showcase page running at [http://localhost:6006](http://localhost:6006) 94 | 95 | ## CI 96 | 97 | We are using [Github Actions](https://help.github.com/en/articles/about-github-actions). 98 | 99 | ### Useful commands 100 | 101 | ```sh 102 | # force a deploy 103 | $ now 104 | 105 | # check all running instances 106 | $ now ls 107 | 108 | # check logs for a given instance 109 | $ now logs hackerz.now.sh --all 110 | ``` 111 | -------------------------------------------------------------------------------- /packages/api/GraphqlSchema.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as Curry from "bs-platform/lib/es6/curry.js"; 4 | import * as Future from "reason-future/src/Future.bs.js"; 5 | import * as Caml_obj from "bs-platform/lib/es6/caml_obj.js"; 6 | import * as FutureJs from "reason-future/src/FutureJs.bs.js"; 7 | import * as Belt_List from "bs-platform/lib/es6/belt_List.js"; 8 | import * as GraphqlFuture from "reason-graphql/src/variations/GraphqlFuture.bs.js"; 9 | import * as CamlinternalLazy from "bs-platform/lib/es6/camlinternalLazy.js"; 10 | import * as StoryData$Hackerz from "./StoryData.bs.js"; 11 | 12 | var storyTypeLazy = Caml_obj.caml_lazy_make((function (param) { 13 | return Curry._4(GraphqlFuture.Schema.obj, undefined, undefined, (function (param) { 14 | return /* :: */[ 15 | Curry._6(GraphqlFuture.Schema.field, undefined, undefined, /* [] */0, (function (_ctx, story) { 16 | return story.id; 17 | }), "id", Curry._1(GraphqlFuture.Schema.nonnull, GraphqlFuture.Schema.$$int)), 18 | /* :: */[ 19 | Curry._6(GraphqlFuture.Schema.field, undefined, undefined, /* [] */0, (function (_ctx, story) { 20 | return story.title; 21 | }), "title", Curry._1(GraphqlFuture.Schema.nonnull, GraphqlFuture.Schema.string)), 22 | /* [] */0 23 | ] 24 | ]; 25 | }), "story"); 26 | })); 27 | 28 | var storyType = CamlinternalLazy.force(storyTypeLazy); 29 | 30 | function handleJsPromiseError(prim) { 31 | return String(prim); 32 | } 33 | 34 | var query = Curry._1(GraphqlFuture.Schema.query, /* :: */[ 35 | Curry._6(GraphqlFuture.Schema.async_field, undefined, undefined, /* :: */[ 36 | Curry._3(GraphqlFuture.Schema.Arg.arg, undefined, "id", Curry._1(GraphqlFuture.Schema.Arg.nonnull, GraphqlFuture.Schema.Arg.$$int)), 37 | /* [] */0 38 | ], (function (_ctx, param, id) { 39 | return Future.mapOk(FutureJs.fromPromise(StoryData$Hackerz.getStory(id), handleJsPromiseError), (function (result) { 40 | return result; 41 | })); 42 | }), "story", storyType), 43 | /* :: */[ 44 | Curry._6(GraphqlFuture.Schema.async_field, undefined, undefined, /* :: */[ 45 | Curry._3(GraphqlFuture.Schema.Arg.arg, undefined, "page", Curry._1(GraphqlFuture.Schema.Arg.nonnull, GraphqlFuture.Schema.Arg.$$int)), 46 | /* [] */0 47 | ], (function (_ctx, param, page) { 48 | return Future.mapOk(FutureJs.fromPromise(StoryData$Hackerz.getTopStories(page), handleJsPromiseError), (function (result) { 49 | return Belt_List.fromArray(result); 50 | })); 51 | }), "topStories", Curry._1(GraphqlFuture.Schema.list, Curry._1(GraphqlFuture.Schema.nonnull, storyType))), 52 | /* [] */0 53 | ] 54 | ]); 55 | 56 | var schema = Curry._2(GraphqlFuture.Schema.create, undefined, query); 57 | 58 | export { 59 | storyTypeLazy , 60 | storyType , 61 | handleJsPromiseError , 62 | query , 63 | schema , 64 | 65 | } 66 | /* storyType Not a pure module */ 67 | -------------------------------------------------------------------------------- /graphql_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "__schema": { 3 | "directives": [], 4 | "types": [ 5 | { 6 | "possibleTypes": null, 7 | "enumValues": null, 8 | "interfaces": null, 9 | "inputFields": null, 10 | "fields": null, 11 | "description": null, 12 | "name": "Int", 13 | "kind": "SCALAR" 14 | }, 15 | { 16 | "possibleTypes": null, 17 | "enumValues": null, 18 | "interfaces": null, 19 | "inputFields": null, 20 | "fields": null, 21 | "description": null, 22 | "name": "String", 23 | "kind": "SCALAR" 24 | }, 25 | { 26 | "possibleTypes": null, 27 | "enumValues": null, 28 | "interfaces": [], 29 | "inputFields": null, 30 | "fields": [ 31 | { 32 | "deprecationReason": null, 33 | "isDeprecated": false, 34 | "type": { 35 | "ofType": { 36 | "ofType": null, 37 | "name": "Int", 38 | "kind": "SCALAR" 39 | }, 40 | "name": null, 41 | "kind": "NON_NULL" 42 | }, 43 | "args": [], 44 | "description": null, 45 | "name": "id" 46 | }, 47 | { 48 | "deprecationReason": null, 49 | "isDeprecated": false, 50 | "type": { 51 | "ofType": { 52 | "ofType": null, 53 | "name": "String", 54 | "kind": "SCALAR" 55 | }, 56 | "name": null, 57 | "kind": "NON_NULL" 58 | }, 59 | "args": [], 60 | "description": null, 61 | "name": "title" 62 | } 63 | ], 64 | "description": null, 65 | "name": "story", 66 | "kind": "OBJECT" 67 | }, 68 | { 69 | "possibleTypes": null, 70 | "enumValues": null, 71 | "interfaces": [], 72 | "inputFields": null, 73 | "fields": [ 74 | { 75 | "deprecationReason": null, 76 | "isDeprecated": false, 77 | "type": { 78 | "ofType": null, 79 | "name": "story", 80 | "kind": "OBJECT" 81 | }, 82 | "args": [ 83 | { 84 | "defaultValue": null, 85 | "type": { 86 | "ofType": { 87 | "ofType": null, 88 | "name": "Int", 89 | "kind": "SCALAR" 90 | }, 91 | "name": null, 92 | "kind": "NON_NULL" 93 | }, 94 | "description": null, 95 | "name": "id" 96 | } 97 | ], 98 | "description": null, 99 | "name": "story" 100 | }, 101 | { 102 | "deprecationReason": null, 103 | "isDeprecated": false, 104 | "type": { 105 | "ofType": { 106 | "ofType": { 107 | "ofType": null, 108 | "name": "story", 109 | "kind": "OBJECT" 110 | }, 111 | "name": null, 112 | "kind": "NON_NULL" 113 | }, 114 | "name": null, 115 | "kind": "LIST" 116 | }, 117 | "args": [ 118 | { 119 | "defaultValue": null, 120 | "type": { 121 | "ofType": { 122 | "ofType": null, 123 | "name": "Int", 124 | "kind": "SCALAR" 125 | }, 126 | "name": null, 127 | "kind": "NON_NULL" 128 | }, 129 | "description": null, 130 | "name": "page" 131 | } 132 | ], 133 | "description": null, 134 | "name": "topStories" 135 | } 136 | ], 137 | "description": null, 138 | "name": "Query", 139 | "kind": "OBJECT" 140 | } 141 | ], 142 | "subscriptionType": null, 143 | "mutationType": null, 144 | "queryType": { 145 | "name": "Query" 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackerz", 3 | "private": true, 4 | "workspaces": { 5 | "packages": [ 6 | "packages/*" 7 | ] 8 | }, 9 | "engines": { 10 | "node": "12.x" 11 | }, 12 | "config": { 13 | "publicDir": "dist" 14 | }, 15 | "scripts": { 16 | "clean": "rimraf dist && bsb -clean-world", 17 | "dev": "run-p -c dev:*", 18 | "dev:reason": "bsb -make-world -w", 19 | "dev:rollup": "cross-env NODE_ENV=development rollup -c -w", 20 | "dev:server": "now-lambda", 21 | "dev-graphql": "run-p -c dev-graphql:*", 22 | "dev-graphql:reason": "bsb -make-world -w", 23 | "dev-graphql:api": "yarn --cwd packages/api dev", 24 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md,html,graphql}\"", 25 | "build": "yarn clean && yarn build:reason && yarn create-bundles", 26 | "build:reason": "bsb -make-world", 27 | "create-bundles": "cross-env NODE_ENV=production rollup -c", 28 | "start": "yarn build && now-lambda", 29 | "test": "yarn build:reason && jest --config jest.json", 30 | "test-watch": "run-p -c test-watch:*", 31 | "test-watch:reason": "yarn dev:reason", 32 | "test-watch:jest": "jest --config jest.json --watch", 33 | "lint": "run-p -c lint:*", 34 | "lint:css": "stylelint '**/*.css'", 35 | "lint:ts": "eslint '**/*.js{,x}'", 36 | "ci": "yarn lint && yarn test", 37 | "deploy": "scripts/deploy-ci.sh", 38 | "deploy:production": "now --token $NOW_TOKEN --target production", 39 | "deploy:staging": "now --token $NOW_TOKEN --target staging", 40 | "e2e": "yarn build && yarn --cwd packages/e2e jest", 41 | "storybook": "run-p -c storybook:*", 42 | "storybook:reason": "yarn dev:reason", 43 | "storybook:start": "start-storybook -p 6006", 44 | "build-storybook": "yarn build:reason && build-storybook", 45 | "snapshot-ui": "build-storybook && percy-storybook --widths=320,1280", 46 | "update-schema": "get-graphql-schema http://localhost:3000/api/graphql -j > graphql_schema.json" 47 | }, 48 | "dependencies": { 49 | "@glennsl/bs-json": "5.0.2", 50 | "bs-fetch": "0.6.1", 51 | "bs-let": "0.1.16", 52 | "core-js": "3.6.5", 53 | "graphql-hooks": "5.0.0", 54 | "graphql-hooks-memcache": "2.0.0", 55 | "isomorphic-fetch": "3.0.0", 56 | "react": "16.14.0", 57 | "react-dom": "16.14.0", 58 | "reason-future": "2.5.0", 59 | "reason-graphql": "0.6.1", 60 | "reason-react": "0.8.0" 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "7.12.0", 64 | "@babel/polyfill": "7.11.5", 65 | "@babel/preset-env": "7.12.0", 66 | "@babel/preset-react": "7.10.4", 67 | "@baransu/graphql_ppx_re": "0.7.1", 68 | "@percy-io/percy-storybook": "2.1.0", 69 | "@storybook/addon-actions": "6.0.26", 70 | "@storybook/addon-centered": "5.3.21", 71 | "@storybook/addon-links": "6.0.26", 72 | "@storybook/addons": "6.0.26", 73 | "@storybook/react": "6.0.26", 74 | "@testing-library/jest-dom": "5.11.4", 75 | "@testing-library/react": "11.1.0", 76 | "autoprefixer": "9.8.6", 77 | "babel-jest": "26.5.2", 78 | "babel-loader": "8.1.0", 79 | "bs-platform": "7.3.2", 80 | "cross-env": "7.0.2", 81 | "eslint": "7.11.0", 82 | "eslint-config-prettier": "6.12.0", 83 | "eslint-config-synacor": "3.0.5", 84 | "eslint-plugin-jest": "24.1.0", 85 | "gentype": "3.36.0", 86 | "get-graphql-schema": "2.1.2", 87 | "husky": "4.3.0", 88 | "identity-obj-proxy": "3.0.0", 89 | "jest": "26.5.3", 90 | "jest-fetch-mock": "3.0.3", 91 | "jest-haste-map": "26.5.2", 92 | "jest-resolve": "26.5.2", 93 | "lint-staged": "10.4.0", 94 | "now": "16.7.3", 95 | "now-lambda-runner": "4.0.0", 96 | "npm-run-all": "4.1.5", 97 | "postcss-modules": "3.2.2", 98 | "prettier": "2.1.2", 99 | "rimraf": "3.0.2", 100 | "rollup": "2.30.0", 101 | "rollup-plugin-babel": "4.4.0", 102 | "rollup-plugin-commonjs": "10.1.0", 103 | "rollup-plugin-copy-assets": "2.0.1", 104 | "rollup-plugin-filesize": "9.0.2", 105 | "rollup-plugin-json": "4.0.0", 106 | "rollup-plugin-livereload": "2.0.0", 107 | "rollup-plugin-node-resolve": "5.2.0", 108 | "rollup-plugin-postcss": "3.1.8", 109 | "rollup-plugin-progress": "1.1.2", 110 | "rollup-plugin-replace": "2.2.0", 111 | "rollup-plugin-terser": "7.0.2", 112 | "rollup-plugin-workbox-build": "0.2.0", 113 | "stylelint": "13.7.2", 114 | "stylelint-config-recommended": "3.0.0", 115 | "stylelint-config-standard": "20.0.0" 116 | }, 117 | "prettier": { 118 | "singleQuote": true, 119 | "trailingComma": "all", 120 | "bracketSpacing": true 121 | }, 122 | "lint-staged": { 123 | "*.{js,json,css,md,html}": [ 124 | "yarn format", 125 | "git add" 126 | ] 127 | }, 128 | "husky": { 129 | "hooks": { 130 | "pre-commit": "lint-staged", 131 | "pre-push": "yarn lint", 132 | "post-commit": "git update-index -g" 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/pages/Home.bs.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import * as $$Array from "bs-platform/lib/es6/array.js"; 4 | import * as React from "react"; 5 | import * as Js_exn from "bs-platform/lib/es6/js_exn.js"; 6 | import * as Js_dict from "bs-platform/lib/es6/js_dict.js"; 7 | import * as Js_json from "bs-platform/lib/es6/js_json.js"; 8 | import * as Js_option from "bs-platform/lib/es6/js_option.js"; 9 | import * as HomeCss from "./Home.css"; 10 | import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; 11 | import * as Link$Hackerz from "../components/Link.bs.js"; 12 | import * as GraphqlHooks$Hackerz from "../GraphqlHooks.bs.js"; 13 | 14 | var css = HomeCss; 15 | 16 | function ste(prim) { 17 | return prim; 18 | } 19 | 20 | var ppx_printed_query = "query {\ntopStories(page: 0) {\nid \ntitle \n}\n\n}\n"; 21 | 22 | function parse(value) { 23 | var value$1 = Js_option.getExn(Js_json.decodeObject(value)); 24 | var match = Js_dict.get(value$1, "topStories"); 25 | var tmp; 26 | if (match !== undefined) { 27 | var value$2 = Caml_option.valFromOption(match); 28 | var match$1 = Js_json.decodeNull(value$2); 29 | tmp = match$1 !== undefined ? undefined : Js_option.getExn(Js_json.decodeArray(value$2)).map((function (value) { 30 | var value$1 = Js_option.getExn(Js_json.decodeObject(value)); 31 | var match = Js_dict.get(value$1, "id"); 32 | var tmp; 33 | if (match !== undefined) { 34 | var value$2 = Caml_option.valFromOption(match); 35 | var match$1 = Js_json.decodeNumber(value$2); 36 | tmp = match$1 !== undefined ? match$1 | 0 : Js_exn.raiseError("graphql_ppx: Expected int, got " + JSON.stringify(value$2)); 37 | } else { 38 | tmp = Js_exn.raiseError("graphql_ppx: Field id on type story is missing"); 39 | } 40 | var match$2 = Js_dict.get(value$1, "title"); 41 | var tmp$1; 42 | if (match$2 !== undefined) { 43 | var value$3 = Caml_option.valFromOption(match$2); 44 | var match$3 = Js_json.decodeString(value$3); 45 | tmp$1 = match$3 !== undefined ? match$3 : Js_exn.raiseError("graphql_ppx: Expected string, got " + JSON.stringify(value$3)); 46 | } else { 47 | tmp$1 = Js_exn.raiseError("graphql_ppx: Field title on type story is missing"); 48 | } 49 | return { 50 | id: tmp, 51 | title: tmp$1 52 | }; 53 | })); 54 | } else { 55 | tmp = undefined; 56 | } 57 | return { 58 | topStories: tmp 59 | }; 60 | } 61 | 62 | function make(param) { 63 | return { 64 | query: ppx_printed_query, 65 | variables: null, 66 | parse: parse 67 | }; 68 | } 69 | 70 | function makeWithVariables(param) { 71 | return { 72 | query: ppx_printed_query, 73 | variables: null, 74 | parse: parse 75 | }; 76 | } 77 | 78 | function makeVariables(param) { 79 | return null; 80 | } 81 | 82 | function definition_002(graphql_ppx_use_json_variables_fn) { 83 | return 0; 84 | } 85 | 86 | var definition = /* tuple */[ 87 | parse, 88 | ppx_printed_query, 89 | definition_002 90 | ]; 91 | 92 | function ret_type(f) { 93 | return { }; 94 | } 95 | 96 | var MT_Ret = { }; 97 | 98 | var TopStoriesQuery = { 99 | ppx_printed_query: ppx_printed_query, 100 | query: ppx_printed_query, 101 | parse: parse, 102 | make: make, 103 | makeWithVariables: makeWithVariables, 104 | makeVariables: makeVariables, 105 | definition: definition, 106 | ret_type: ret_type, 107 | MT_Ret: MT_Ret 108 | }; 109 | 110 | var query = make(/* () */0); 111 | 112 | function Home(Props) { 113 | var result = GraphqlHooks$Hackerz.useQuery(query); 114 | var queryResult; 115 | if (typeof result === "number") { 116 | queryResult = React.createElement("div", undefined, "Loading"); 117 | } else if (result.tag) { 118 | var match = result[0].topStories; 119 | queryResult = match !== undefined ? React.createElement("ul", undefined, $$Array.map((function (story) { 120 | return React.createElement("li", { 121 | key: String(story.id) 122 | }, story.title); 123 | }), match)) : "No stories found"; 124 | } else { 125 | queryResult = React.createElement("div", undefined, result[0]); 126 | } 127 | return React.createElement(React.Fragment, undefined, React.createElement("div", { 128 | className: css.foo 129 | }, "HELLO"), queryResult, React.createElement(Link$Hackerz.make, { 130 | href: "/more", 131 | children: "See some more" 132 | })); 133 | } 134 | 135 | var make$1 = Home; 136 | 137 | export { 138 | css , 139 | ste , 140 | TopStoriesQuery , 141 | query , 142 | make$1 as make, 143 | 144 | } 145 | /* css Not a pure module */ 146 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import filesize from 'rollup-plugin-filesize'; 3 | import nodeResolve from 'rollup-plugin-node-resolve'; 4 | import progress from 'rollup-plugin-progress'; 5 | import commonjs from 'rollup-plugin-commonjs'; 6 | import replace from 'rollup-plugin-replace'; 7 | import { terser } from 'rollup-plugin-terser'; 8 | import livereload from 'rollup-plugin-livereload'; 9 | import copy from 'rollup-plugin-copy-assets'; 10 | import postcss from 'rollup-plugin-postcss'; 11 | import workbox from 'rollup-plugin-workbox-build'; 12 | import json from 'rollup-plugin-json'; 13 | const pkg = require('./package.json'); 14 | 15 | const namedExports = { 16 | 'node_modules/react/index.js': [ 17 | 'Children', 18 | 'Component', 19 | 'PropTypes', 20 | 'createElement', 21 | 'isValidElement', 22 | 'Fragment', 23 | 'useState', 24 | 'useEffect', 25 | 'useContext', 26 | 'useLayoutEffect', 27 | 'useMemo', 28 | 'useRef', 29 | 'useReducer', 30 | 'render', 31 | 'hydrate', 32 | ], 33 | 'node_modules/react-dom/index.js': ['render', 'hydrate'], 34 | 'node_modules/react-dom/server.js': [ 35 | 'renderToString', 36 | 'renderToStaticMarkup', 37 | ], 38 | 'node_modules/body-parser/index.js': ['json'], 39 | }; 40 | 41 | // `npm run build` -> `production` is true 42 | // `npm run dev` -> `production` is false 43 | const production = !process.env.ROLLUP_WATCH; 44 | 45 | // Module config for