From 3455444dbad59565df86c2c0b414ab0af13849ea Mon Sep 17 00:00:00 2001 From: Stuart Colville Date: Tue, 3 May 2016 16:29:10 +0100 Subject: [PATCH] Refactor HTML for apps --- .eslintrc | 6 +- package.json | 1 + src/core/containers/ServerHtml.js | 87 +++++++++++ src/core/server/base.js | 108 ++++++-------- src/disco/containers/App.js | 5 + src/disco/server.js | 7 - src/search/containers/App.js | 5 + src/search/server.js | 7 - .../client/core/containers/TestServerHtml.js | 138 ++++++++++++++++++ tests/client/disco/containers/TestApp.js | 3 +- tests/client/search/containers/TestApp.js | 3 +- 11 files changed, 284 insertions(+), 86 deletions(-) create mode 100644 src/core/containers/ServerHtml.js delete mode 100644 src/disco/server.js delete mode 100644 src/search/server.js create mode 100644 tests/client/core/containers/TestServerHtml.js diff --git a/.eslintrc b/.eslintrc index 0425f8ded49..0b7b2f4ba5d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,9 +14,9 @@ }, "parser": "babel-eslint", "rules": { - "object-curly-spacing": 0, - "space-before-function-paren": [2, "never"], - "react/prefer-stateless-function": 0, + "object-curly-spacing": "off", + "space-before-function-paren": ["error", "never"], + "react/prefer-stateless-function": "off", "react/jsx-indent-props": "off", "react/jsx-closing-bracket-location": "off" } diff --git a/package.json b/package.json index 8da651767b9..add47e8d39d 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "piping": "0.3.1", "react": "15.0.2", "react-cookie": "0.4.5", + "react-helmet": "3.1.0", "react-redux": "4.4.5", "react-router": "2.4.0", "redux": "3.5.2", diff --git a/src/core/containers/ServerHtml.js b/src/core/containers/ServerHtml.js new file mode 100644 index 00000000000..99647866c94 --- /dev/null +++ b/src/core/containers/ServerHtml.js @@ -0,0 +1,87 @@ +import React, { Component, PropTypes } from 'react'; +import ReactDOM from 'react-dom/server'; +import serialize from 'serialize-javascript'; +import Helmet from 'react-helmet'; + + +export default class ServerHtml extends Component { + + static propTypes = { + appName: PropTypes.string.isRequired, + assets: PropTypes.object.isRequired, + component: PropTypes.object.isRequired, + includeSri: PropTypes.bool, + sriData: PropTypes.object, + store: PropTypes.object.isRequired, + }; + + getStatic({filePath, type, index}) { + const { includeSri, sriData, appName } = this.props; + const leafName = filePath.split('/').pop(); + let sriProps = {}; + // Only output files for the current app. + if (leafName.startsWith(appName)) { + if (includeSri) { + sriProps = { + integrity: sriData[leafName], + crossOrigin: 'anonymous', + }; + if (!sriProps.integrity) { + throw new Error(`SRI Data is missing for ${leafName}`); + } + } + switch (type) { + case 'css': + return (); + case 'js': + return ; + default: + throw new Error('Unknown static type'); + } + } else { + return null; + } + } + + getStyle() { + const { assets } = this.props; + return Object.keys(assets.styles).map((style, index) => + this.getStatic({filePath: assets.styles[style], type: 'css', index})); + } + + getScript() { + const { assets } = this.props; + return Object.keys(assets.javascript).map((js, index) => + this.getStatic({filePath: assets.javascript[js], type: 'js', index})); + } + + render() { + const { component, store } = this.props; + // This must happen before Helmet.rewind() see + // https://github.com/nfl/react-helmet#server-usage for more info. + const content = component ? ReactDOM.renderToString(component) : ''; + const head = Helmet.rewind(); + const htmlAttrs = head.htmlAttributes.toComponent(); + + return ( + + + + + + {head.title.toComponent()} + {head.meta.toComponent()} + {this.getStyle()} + + +
+ `; - }).join('\n'); - - const HTML = stripIndent` - - - - - Isomorphic Redux Demo - - ${styles} - - -
${componentHTML}
- - ${script} - - `; - - res.header('Content-Type', 'text/html'); - return res.end(HTML); - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error(error.stack); - res.status(500).end(errorString); - }); + return loadOnServer({...renderProps, store}) + .then(() => { + const InitialComponent = ( + + + + ); + + // Get SRI for deployed services only. + const sriData = (isDeployed) ? JSON.parse( + fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json')) + ) : {}; + + const pageProps = { + appName: appInstanceName, + assets: webpackIsomorphicTools.assets(), + component: InitialComponent, + head: ReactHelmet.rewind(), + sriData, + includeSri: isDeployed, + store, + }; + + const HTML = ReactDOM.renderToString(); + res.send(`${HTML}`); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error.stack); + res.status(500).end(errorString); + }); }); }); @@ -174,7 +144,9 @@ export function runServer({listen = true, app = appName} = {}) { // Webpack Isomorphic tools is ready // now fire up the actual server. return new Promise((resolve, reject) => { - const server = require(`${app}/server`).default; + const routes = require(`${app}/routes`).default; + const createStore = require(`${app}/store`).default; + const server = baseServer(routes, createStore, {appInstanceName: app}); if (listen === true) { server.listen(port, host, (err) => { if (err) { @@ -196,3 +168,5 @@ export function runServer({listen = true, app = appName} = {}) { console.error(err); }); } + +export default baseServer; diff --git a/src/disco/containers/App.js b/src/disco/containers/App.js index 3919f6dcaf0..cbe1a95b00e 100644 --- a/src/disco/containers/App.js +++ b/src/disco/containers/App.js @@ -1,6 +1,8 @@ import React, { PropTypes } from 'react'; +import Helmet from 'react-helmet'; import 'disco/css/App.scss'; +import { gettext as _ } from 'core/utils'; export default class App extends React.Component { @@ -12,6 +14,9 @@ export default class App extends React.Component { const { children } = this.props; return (
+ {children}
); diff --git a/src/disco/server.js b/src/disco/server.js deleted file mode 100644 index 14d243dcf2e..00000000000 --- a/src/disco/server.js +++ /dev/null @@ -1,7 +0,0 @@ -import baseServer from 'core/server/base'; -import createStore from './store'; - -import routes from './routes'; - -const app = baseServer(routes, createStore); -export default app; diff --git a/src/search/containers/App.js b/src/search/containers/App.js index bb39818e8bb..502eb41cbfb 100644 --- a/src/search/containers/App.js +++ b/src/search/containers/App.js @@ -1,6 +1,8 @@ import React, { PropTypes } from 'react'; +import Helmet from 'react-helmet'; import 'search/css/App.scss'; +import { gettext as _ } from 'core/utils'; export default class App extends React.Component { @@ -12,6 +14,9 @@ export default class App extends React.Component { const { children } = this.props; return (
+ {children}
); diff --git a/src/search/server.js b/src/search/server.js deleted file mode 100644 index 2b6d900dd97..00000000000 --- a/src/search/server.js +++ /dev/null @@ -1,7 +0,0 @@ -import baseServer from 'core/server/base'; -import createStore from './store'; -import routes from './routes'; - -const app = baseServer(routes, createStore); - -export default app; diff --git a/tests/client/core/containers/TestServerHtml.js b/tests/client/core/containers/TestServerHtml.js new file mode 100644 index 00000000000..1811caea89b --- /dev/null +++ b/tests/client/core/containers/TestServerHtml.js @@ -0,0 +1,138 @@ +import Helmet from 'react-helmet'; +import React, { PropTypes } from 'react'; + +import { findRenderedDOMComponentWithTag, renderIntoDocument } from 'react-addons-test-utils'; +import ServerHtml from 'core/containers/ServerHtml'; + +describe('', () => { + beforeEach(() => { + Helmet.canUseDOM = false; + }); + + const fakeStore = { + getState: () => ({foo: 'bar'}), + }; + + const fakeAssets = { + styles: { + disco: '/bar/disco-blah.css', + search: '/search-blah.css', + }, + javascript: { + disco: '/foo/disco-blah.js', + search: '/search-blah.js', + }, + }; + + const fakeSRIData = { + 'disco-blah.css': 'sha512-disco-css', + 'search-blah.css': 'sha512-search-css', + 'disco-blah.js': 'sha512-disco-js', + 'search-blah.js': 'sha512-search-js', + }; + + class FakeApp extends React.Component { + static propTypes = { + children: PropTypes.node, + } + + render() { + const { children } = this.props; + return ( +
+ + {children} +
+ ); + } + } + + function render({ appName = 'disco', includeSri = true, + sriData = fakeSRIData } = {}) { + const pageProps = { + appName, + component: , + assets: fakeAssets, + includeSri, + store: fakeStore, + sriData, + }; + return renderIntoDocument(); + } + + it('renders html attrs provided', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + assert.equal(html.getAttribute('lang'), 'wat'); + }); + + it('renders css provided', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const styleSheets = html.querySelectorAll('link[rel="stylesheet"]'); + assert.equal(styleSheets.length, 1); + const styleSheetLink = styleSheets[0]; + assert.equal(styleSheetLink.getAttribute('href'), '/bar/disco-blah.css'); + }); + + it('renders js provided', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const js = html.querySelectorAll('script[src]'); + assert.equal(js.length, 1); + const scriptNode = js[0]; + assert.equal(scriptNode.getAttribute('src'), '/foo/disco-blah.js'); + }); + + it('renders css with SRI when present', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const styleSheet = html.querySelector('link[rel="stylesheet"]'); + assert.equal(styleSheet.getAttribute('integrity'), 'sha512-disco-css'); + assert.equal(styleSheet.getAttribute('crossorigin'), 'anonymous'); + }); + + it('renders js with SRI when present', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const js = html.querySelector('script[src]'); + assert.equal(js.getAttribute('integrity'), 'sha512-disco-js'); + assert.equal(js.getAttribute('crossorigin'), 'anonymous'); + }); + + it('renders state as JSON', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const json = html.querySelector('#redux-store-state').textContent; + assert.equal(JSON.parse(json).foo, 'bar'); + }); + + it('renders meta with utf8 charset ', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const meta = html.querySelector('meta[charset]'); + assert.equal(meta.getAttribute('charset'), 'utf-8'); + }); + + it('renders favicon', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const favicon = html.querySelector('link[rel="shortcut icon"]'); + assert.equal(favicon.getAttribute('href'), '/favicon.ico'); + }); + + it('renders title', () => { + const html = findRenderedDOMComponentWithTag(render(), 'html'); + const title = html.querySelector('title').textContent; + assert.equal(title, 'test title'); + }); + + it('throws for unknown static type', () => { + assert.throws(() => { + const html = render({includeSri: false}); + html.getStatic({filePath: 'disco-foo', type: 'whatever'}); + }, Error, 'Unknown static type'); + }); + + it('throws for missing SRI data', () => { + assert.throws(() => { + render({sriData: {}}); + }, Error, /SRI Data is missing/); + }); +}); diff --git a/tests/client/disco/containers/TestApp.js b/tests/client/disco/containers/TestApp.js index a989a975a6d..8c054a502c0 100644 --- a/tests/client/disco/containers/TestApp.js +++ b/tests/client/disco/containers/TestApp.js @@ -12,6 +12,7 @@ describe('App', () => { } const root = shallowRender(); assert.equal(root.type, 'div'); - assert.equal(root.props.children.type, MyComponent); + // First child is . + assert.equal(root.props.children[1].type, MyComponent); }); }); diff --git a/tests/client/search/containers/TestApp.js b/tests/client/search/containers/TestApp.js index 3b73ae71d39..8371999a9bb 100644 --- a/tests/client/search/containers/TestApp.js +++ b/tests/client/search/containers/TestApp.js @@ -12,6 +12,7 @@ describe('App', () => { } const root = shallowRender(); assert.equal(root.type, 'div'); - assert.equal(root.props.children.type, MyComponent); + // First child is . + assert.equal(root.props.children[1].type, MyComponent); }); });