UNPKG

react-static

Version:

A progressive static site generator for React

295 lines (250 loc) 8.13 kB
import crypto from 'crypto' import React from 'react' import { renderToString, renderToStaticMarkup } from 'react-dom/server' import Helmet from 'react-helmet' import { ReportChunks } from 'react-universal-component' import flushChunks from 'webpack-flush-chunks' import nodePath from 'path' import fs from 'fs-extra' import jsesc from 'jsesc' import Redirect from './components/Redirect' import plugins from './plugins' import { makePathAbsolute, is404Path } from '../utils' import { absoluteToRelativeChunkName } from '../utils/chunkBuilder' import makeHtmlWithMeta from './components/HtmlWithMeta' import makeHeadWithMeta from './components/HeadWithMeta' import makeBodyWithMeta from './components/BodyWithMeta' let cachedBasePath let cachedHrefReplace let cachedSrcReplace function getSHA256(str) { const hash = crypto.createHash('sha256') hash.update(str) return hash.digest('base64') } function getSubresourceHash(str) { const sha256 = getSHA256(str) return `sha256-${sha256}` } export function getEmbeddedRouteInfoScript(embeddedRouteInfo) { const routeInfoJSON = jsesc(JSON.stringify(embeddedRouteInfo), { isScriptContext: true, wrap: true, json: true, }) const script = `window.__routeInfo = JSON.parse(${routeInfoJSON});` const hash = getSubresourceHash(script) return { hash, script, } } export default (async function exportRoute(state) { const { config, DocumentTemplate, route, siteData, clientStats, incremental, } = state let { Comp } = state const { sharedHashesByProp, template, data, sharedData, path: routePath, remove, } = route if (incremental && remove) { if (is404Path(route.path) || route.path === '/') { throw new Error( `You are attempting to incrementally remove the ${ is404Path(route.path) ? '404' : 'index' } route from your export. This is currently not supported (or recommended) by React Static.` ) } const removeLocation = nodePath.join(config.paths.DIST, route.path) return fs.remove(removeLocation) } const basePath = cachedBasePath || (cachedBasePath = config.basePath) const hrefReplace = cachedHrefReplace || (cachedHrefReplace = new RegExp( `(href=["'])\\/(${basePath ? `${basePath}\\/` : ''})?([^\\/])`, 'gm' )) const srcReplace = cachedSrcReplace || (cachedSrcReplace = new RegExp( `(src=["'])\\/(${basePath ? `${basePath}\\/` : ''})?([^\\/])`, 'gm' )) // This routeInfo will be saved to disk. It should only include the // data and hashes to construct all of the props later. const routeInfo = await plugins.routeInfo( { template, sharedHashesByProp, data, path: routePath, }, state ) // This embeddedRouteInfo will be inlined into the HTML for this route. // It should include all of the data, including shared data const embeddedRouteInfo = { ...routeInfo, sharedData, siteData, } const inlineScripts = { routeInfo: getEmbeddedRouteInfoScript(embeddedRouteInfo), } state = { ...state, routeInfo, embeddedRouteInfo, inlineScripts, } // Make a place to collect chunks, meta info and head tags const meta = {} const chunkNames = [] let head = {} let clientScripts = [] let clientStyleSheets = [] let clientCss = {} let FinalComp // Get the react component from the Comp and pass it the export context. This // uses reactContext under the hood to pass down the exportContext, since // react's new context api doesn't survive across bundling. Comp = config.disableRuntime ? Comp : Comp(embeddedRouteInfo) if (route.redirect) { FinalComp = () => <Redirect fromPath={route.path} to={route.redirect} /> } else { FinalComp = props => ( <ReportChunks report={chunkName => { // if we are building to a absolute path we must make the detected // chunkName relative and matching to the one we set in // generateTemplates if (!config.paths.DIST.startsWith(config.paths.ROOT)) { chunkName = absoluteToRelativeChunkName( config.paths.ROOT, chunkName ) } chunkNames.push(chunkName) }} > <Comp {...props} /> </ReportChunks> ) } const renderToStringAndExtract = comp => { // Rend the app to string! const appHtml = renderToString(comp) const { scripts, stylesheets, css } = flushChunks(clientStats, { chunkNames, outputPath: config.paths.DIST, }) clientScripts = scripts clientStyleSheets = stylesheets clientCss = css // Extract head calls using Helmet synchronously right after renderToString // to not introduce any race conditions in the meta data rendering const helmet = Helmet.renderStatic() head = { htmlProps: helmet.htmlAttributes.toComponent(), bodyProps: helmet.bodyAttributes.toComponent(), base: helmet.base.toComponent(), link: helmet.link.toComponent(), meta: helmet.meta.toComponent(), noscript: helmet.noscript.toComponent(), script: helmet.script.toComponent(), style: helmet.style.toComponent(), title: helmet.title.toComponent(), } return appHtml } let appHtml state = { ...state, meta, } try { FinalComp = await plugins.beforeRenderToElement(FinalComp, state) if (config.renderToElement) { throw new Error( `config.renderToElement has been deprecated in favor of the ` + `'beforeRenderToElement' or 'beforeRenderToHtml' hooks instead.` ) } let RenderedComp = <FinalComp /> // Run the beforeRenderToHtml hook // Rum the Html hook RenderedComp = await plugins.beforeRenderToHtml(RenderedComp, state) if (config.renderToHtml) { throw new Error( `config.renderToHtml has been deprecated in favor of the ` + `'beforeRenderToHtml' or 'beforeHtmlToDocument' hooks instead.` ) } appHtml = renderToStringAndExtract(RenderedComp) appHtml = await plugins.beforeHtmlToDocument(appHtml, state) } catch (error) { if (error.then) { error.message = 'Components are not allowed to suspend during static export. Please ' + 'make its data available synchronously and try again!' } error.message = `Failed exporting HTML for URL ${route.path} (${route.template}): ${error.message}` throw error } state = { ...state, head, clientScripts, clientStyleSheets, clientCss, } const DocumentHtml = renderToStaticMarkup( <DocumentTemplate Html={await makeHtmlWithMeta(state)} Head={await makeHeadWithMeta(state)} Body={await makeBodyWithMeta(state)} state={state} > <div id="root" dangerouslySetInnerHTML={{ __html: appHtml }} /> </DocumentTemplate> ) // Render the html for the page inside of the base document. let html = `<!DOCTYPE html>${DocumentHtml}` html = await plugins.beforeDocumentToFile(html, state) // If the siteRoot is set and we're not in staging, prefix all absolute URLs // with the siteRoot const publicPath = makePathAbsolute(process.env.REACT_STATIC_PUBLIC_PATH) if (process.env.REACT_STATIC_DISABLE_ROUTE_PREFIXING !== 'true') { html = html.replace(hrefReplace, `$1${publicPath}$3`) } html = html.replace(srcReplace, `$1${publicPath}$3`) // If the route is a 404 page, write it directly to 404.html, instead of // inside a directory. const htmlFilename = is404Path(route.path) ? nodePath.join(config.paths.DIST, '404.html') : nodePath.join(config.paths.DIST, route.path, 'index.html') // Make the routeInfo sit right next to its companion html file const routeInfoFilename = nodePath.join( config.paths.DIST, route.path, 'routeInfo.json' ) const res = await Promise.all([ fs.outputFile(htmlFilename, html), !route.redirect ? fs.outputJson(routeInfoFilename, routeInfo) : Promise.resolve(), ]) return res })