├── .editorconfig ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── index.js ├── util.js └── webpack-util.js └── test ├── __snapshots__ └── index.test.js.snap ├── _helpers.js ├── fixtures ├── basic │ ├── index.html │ └── index.js ├── document-url │ ├── index.html │ └── index.js ├── factory │ ├── index.html │ └── index.js ├── failure │ ├── index.html │ └── index.js ├── function-export-dom │ ├── index.html │ └── index.js ├── named-export-fn │ ├── index.html │ └── index.js ├── value-export │ ├── index.html │ └── index.js └── with-field │ ├── index.html │ └── index.js └── index.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | dist 3 | /package-lock.json 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | cache: npm 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2018 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | prerender-loader 3 |

4 | prerender-loader 5 | npm 6 |

7 |

8 | 9 | Painless universal prerendering for Webpack. Works great with 10 | [html-webpack-plugin]. 11 | 12 | > 🧐 **What is Prerendering?** 13 | > 14 | >Pre-rendering describes the process of rendering a client-side application at 15 | >build time, producing useful static HTML that can be sent to the browser 16 | >instead of an empty bootstrapping page. 17 | > 18 | >Pre-rendering is like Server-Side Rendering, just done at build time to produce 19 | >static files. Both techniques help get meaningful content onto the user's 20 | >screen faster. 21 | 22 | ## Features 23 | 24 | - Works entirely within Webpack 25 | - Integrates with [html-webpack-plugin] 26 | - Works with `webpack-dev-server` / `webpack serve` 27 | - Supports both DOM and String prerendering 28 | - Asynchronous rendering via async/await or Promises 29 | 30 | --- 31 | 32 | 33 | 34 | - [Features](#features) 35 | - [How does it work?](#how-does-it-work) 36 | - [Installation](#installation) 37 | - [Usage](#usage) 38 | - [DOM Prerendering](#dom-prerendering) 39 | - [String Prerendering](#string-prerendering) 40 | - [Injecting content into the HTML](#injecting-content-into-the-html) 41 | - [Prerendering JavaScript Files](#prerendering-javascript-files) 42 | - [Options](#options) 43 | - [License](#license) 44 | 45 | 46 | 47 | ## How does it work? 48 | 49 | `prerender-loader` renders your web application within Webpack during builds, 50 | producing static HTML. When the loader is applied to an HTML file, it creates a 51 | DOM structure from that HTML, compiles the application, runs it within the DOM 52 | and serializes the result back to HTML. 53 | 54 | --- 55 | 56 | ## Installation 57 | 58 | First, install `prerender-loader` as a development dependency: 59 | 60 | ```sh 61 | npm i -D prerender-loader 62 | ``` 63 | 64 | --- 65 | 66 | ## Usage 67 | 68 | In most cases, you'll want to apply the loader to your `html-webpack-plugin` 69 | template option: 70 | 71 | ```diff 72 | // webpack.config.js 73 | module.exports = { 74 | plugins: [ 75 | new HtmlWebpackPlugin({ 76 | - template: 'index.html', 77 | + template: '!!prerender-loader?string!index.html', 78 | 79 | // any other options you'd normally set are still supported: 80 | compile: false, 81 | inject: true 82 | }) 83 | ] 84 | } 85 | ``` 86 | 87 | What does all that punctuation mean? Let's break the whole loader string 88 | down: 89 | 90 | > In Webpack, a module identifier beginning with `!!` will bypass any configured 91 | >loaders from `module.rules` - here we're saying "don't do anything to 92 | >`index.html` except what I've defined here 93 | > 94 | >The `?string` parameter tells `prerender-loader` to output an ES module 95 | >exporting the prerendered HTML string, rather than returning the HTML directly. 96 | > 97 | >Finally, everything up to the last `!` in a module identifier is the inline 98 | >loader definition (the transforms to apply to a given module). The filename of 99 | >the module to load comes after the `!`. 100 | > 101 | >**Note:** If you've already set up `html-loader` or `raw-loader` to handle 102 | >`.html` files, you can skip both options and simply pass a `template` value of 103 | >`"prerender-loader!index.html"`! 104 | 105 | As with any loader, it is also possible to apply `prerender-loader` on-the-fly 106 | : 107 | 108 | ```js 109 | const html = require('prerender-loader?!./app.html'); 110 | ``` 111 | 112 | ... or in your Webpack configuration's `module.rules` section: 113 | 114 | ```js 115 | module.exports = { 116 | module: { 117 | rules: [ 118 | { 119 | test: 'src/index.html', 120 | loader: 'prerender-loader?string' 121 | } 122 | ] 123 | } 124 | } 125 | ``` 126 | 127 | 128 | Once you have `prerender-loader` in-place, prerendering is now turned on. During 129 | your build, the app will be executed, with any modifications it makes to 130 | `index.html` will be saved to disk. This is fine for the needs of many apps, 131 | but you can also take more explicit control over your prerendering: either using 132 | the DOM or by rendering to a String. 133 | 134 | ### DOM Prerendering 135 | 136 | During prerendering, your application gets compiled and run directly under 137 | NodeJS, but within a [JSDOM] container so that you can use the familiar browser 138 | globals like `document` and `window`. 139 | 140 | Here's an example `entry` module that uses DOM prerendering: 141 | 142 | ```js 143 | import { render } from 'fancy-dom-library'; 144 | import App from './app'; 145 | 146 | export default () => { 147 | render(, document.body); 148 | }; 149 | ``` 150 | 151 | In all cases, asynchronous functions and callbacks are supported: 152 | 153 | ```js 154 | import { mount } from 'other-fancy-library'; 155 | import app from './app'; 156 | 157 | export default async function prerender() { 158 | let res = await fetch('https://example.com'); 159 | let data = await res.json(); 160 | mount(app(data), document.getElementById('app')); 161 | } 162 | ``` 163 | 164 | ### String Prerendering 165 | 166 | It's also possible to export a function from your Webpack entry module, which 167 | gives you full control over prerendering: `prerender-loader` will call the 168 | function and its return value will be used as the static HTML. If the exported 169 | function returns a Promise, it will be awaited and the resolved value will be 170 | used. 171 | 172 | ```js 173 | import { renderToString } from 'react-dom'; 174 | import App from './app'; 175 | 176 | export default () => { 177 | const html = renderToString(); 178 | // returned HTML will be injected into : 179 | return html; 180 | }; 181 | ``` 182 | 183 | In addition to DOM and String prerendering, it's also possible to use a 184 | combination of the two. If an application's Webpack entry exports a prerender 185 | function that doesn't return a value, the default DOM serialization will kick 186 | in, just like in DOM prerendering. This means you can use your exported 187 | prerender function to trigger DOM manipulation ("client-side" rendering), and 188 | then just let `prerender-loader` handle generating the static HTML for whatever 189 | got rendered. 190 | 191 | Here's an example that renders a [Preact] application and waits for DOM 192 | rendering to settle down before allowing prerender-loader to serialize the 193 | document to static HTML: 194 | 195 | ```js 196 | import { h, options } from 'preact'; 197 | import { renderToString } from 'preact'; 198 | import App from './app'; 199 | 200 | // we're done when there are no renders for 50ms: 201 | const IDLE_TIMEOUT = 50; 202 | 203 | export default () => new Promise(resolve => { 204 | let timer; 205 | // each time preact re-renders, reset our idle timer: 206 | options.debounceRendering = commit => { 207 | clearTimeout(timer); 208 | timer = setTimeout(resolve, IDLE_TIMEOUT); 209 | commit(); 210 | }; 211 | 212 | // render into using normal client-side rendering: 213 | render(, document.body); 214 | }); 215 | ``` 216 | 217 | ### Injecting content into the HTML 218 | 219 | When applied to a `.html` file, `prerender-loader` will inject prerendered 220 | content at the end of `` by default. If you want to place the content 221 | somewhere else, you can add a `{{prerender}}` field: 222 | 223 | ```html 224 | 225 | 226 |
227 | 228 | {{prerender}} 229 |
230 | 231 | 232 | ``` 233 | 234 | This works well if you intend to provide a prerender function that only returns 235 | your application's HTML structure, not the full document's HTML. 236 | 237 | ### Prerendering JavaScript Files 238 | 239 | In addition to processing `.html` files, the loader can also directly pre-render 240 | `.js` scripts. The only difference is that the DOM used for prerender will be 241 | initially empty: 242 | 243 | ```js 244 | const prerenderedHtml = require('!prerender-loader?string!./app.js'); 245 | ``` 246 | 247 | --- 248 | 249 | ## Options 250 | 251 | All options are ... optional. 252 | 253 | | Option | Type | Default | Description | 254 | | ------------- | ------- | ------------------ | ---------------------------------------------------------------------- | 255 | | `string` | boolean | false | Output a JS module exporting an HTML String instead of the HTML itself | 256 | | `disabled` | boolean | false | Bypass the loader entirely (but still respect `options.string`) | 257 | | `documentUrl` | string | 'http://localhost' | Change the jsdom's URL (affects `window.location`, `document.URL`...) | 258 | | `params` | object | null | Options to pass to your prerender function | 259 | | `env` | object | {} | Environment variables to define when building JS for prerendering | 260 | 261 | 262 | --- 263 | 264 | ## License 265 | 266 | [Apache 2.0](LICENSE) 267 | 268 | This is not an official Google product. 269 | 270 | [html-webpack-plugin]: https://github.com/jantimon/html-webpack-plugin 271 | [JSDOM]: https://github.com/jsdom/jsdom 272 | [Preact]: https://preactjs.com 273 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prerender-loader", 3 | "version": "1.3.0", 4 | "description": "Painless universal prerendering for Webpack. Works great with html-webpack-plugin.", 5 | "main": "dist/prerender-loader.js", 6 | "source": "src/index.js", 7 | "license": "Apache-2.0", 8 | "author": "The Chromium Authors", 9 | "contributors": [ 10 | { 11 | "name": "Jason Miller", 12 | "email": "developit@google.com" 13 | } 14 | ], 15 | "keywords": [ 16 | "pre-render", 17 | "prerendering", 18 | "webpack plugin", 19 | "SSR", 20 | "performance", 21 | "first contentful paint", 22 | "FCP" 23 | ], 24 | "repository": "GoogleChromeLabs/prerender-loader", 25 | "scripts": { 26 | "build": "microbundle -f cjs --no-compress --external all", 27 | "docs": "documentation readme -q --no-markdown-toc -a public -s Usage --sort-order alpha src", 28 | "test": "jest --coverage", 29 | "prepare": "npm run -s build", 30 | "release": "npm run build -s && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" 31 | }, 32 | "babel": { 33 | "presets": [ 34 | "env" 35 | ] 36 | }, 37 | "jest": { 38 | "testEnvironment": "node", 39 | "coverageReporters": [ 40 | "text" 41 | ], 42 | "collectCoverageFrom": [ 43 | "src/**/*" 44 | ], 45 | "watchPathIgnorePatterns": [ 46 | "node_modules", 47 | "dist" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "babel-core": "^6.26.3", 52 | "babel-jest": "^23.0.0", 53 | "babel-preset-env": "^1.7.0", 54 | "documentation": "^7.0.0", 55 | "eslint": "^4.19.1", 56 | "eslint-config-standard": "^11.0.0", 57 | "eslint-plugin-import": "^2.12.0", 58 | "eslint-plugin-jest": "^21.15.2", 59 | "eslint-plugin-node": "^6.0.1", 60 | "eslint-plugin-promise": "^3.8.0", 61 | "eslint-plugin-standard": "^3.1.0", 62 | "file-loader": "^1.1.11", 63 | "html-webpack-plugin": "^3.2.0", 64 | "jest": "^23.0.0", 65 | "memory-fs": "^0.4.1", 66 | "microbundle": "^0.4.4", 67 | "raw-loader": "^0.5.1", 68 | "webpack": "^4.8.3" 69 | }, 70 | "dependencies": { 71 | "jsdom": "^11.11.0", 72 | "loader-utils": "^1.1.0" 73 | }, 74 | "peerDependencies": { 75 | "webpack": "*", 76 | "memory-fs": "*" 77 | }, 78 | "eslintConfig": { 79 | "extends": [ 80 | "standard", 81 | "plugin:jest/recommended" 82 | ], 83 | "env": { 84 | "browser": false, 85 | "node": true 86 | }, 87 | "rules": { 88 | "indent": [ 89 | 2, 90 | 2 91 | ], 92 | "semi": [ 93 | 2, 94 | "always" 95 | ], 96 | "prefer-const": 1 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import os from 'os'; 18 | import jsdom from 'jsdom'; 19 | import loaderUtils from 'loader-utils'; 20 | import LibraryTemplatePlugin from 'webpack/lib/LibraryTemplatePlugin'; 21 | import NodeTemplatePlugin from 'webpack/lib/node/NodeTemplatePlugin'; 22 | import NodeTargetPlugin from 'webpack/lib/node/NodeTargetPlugin'; 23 | import { DefinePlugin } from 'webpack'; 24 | import MemoryFs from 'memory-fs'; 25 | import { runChildCompiler, getRootCompiler, getBestModuleExport, stringToModule, convertPathToRelative } from './util'; 26 | import { applyEntry } from './webpack-util'; 27 | 28 | // Used to annotate this plugin's hooks in Tappable invocations 29 | const PLUGIN_NAME = 'prerender-loader'; 30 | 31 | // Internal name used for the output bundle (never written to disk) 32 | const FILENAME = 'ssr-bundle.js'; 33 | 34 | // Searches for fields of the form {{prerender}} or {{prerender:./some/module}} 35 | const PRERENDER_REG = /\{\{prerender(?::\s*([^}]+?)\s*)?\}\}/; 36 | 37 | /** 38 | * prerender-loader can be applied to any HTML or JS file with the given options. 39 | * @public 40 | * @param {Options} options Options to control how Critters inlines CSS. 41 | * 42 | * @example 43 | * // webpack.config.js 44 | * module.exports = { 45 | * plugins: [ 46 | * new HtmlWebpackPlugin({ 47 | * // `!!` tells webpack to skip any configured loaders for .html files 48 | * // `?string` tells prerender-loader output a JS module exporting the HTML string 49 | * template: '!!prerender-loader?string!index.html' 50 | * }) 51 | * ] 52 | * } 53 | * 54 | * @example 55 | * // inline demo: assumes you have html-loader set up: 56 | * import prerenderedHtml from '!prerender-loader!./file.html'; 57 | */ 58 | export default function PrerenderLoader (content) { 59 | const options = loaderUtils.getOptions(this) || {}; 60 | const outputFilter = options.as === 'string' || options.string ? stringToModule : String; 61 | 62 | if (options.disabled === true) { 63 | return outputFilter(content); 64 | } 65 | 66 | // When applied to HTML, attempts to inject into a specified {{prerender}} field. 67 | // @note: this is only used when the entry module exports a String or function 68 | // that resolves to a String, otherwise the whole document is serialized. 69 | let inject = false; 70 | if (!this.request.match(/\.(js|ts)x?$/i)) { 71 | const matches = content.match(PRERENDER_REG); 72 | if (matches) { 73 | inject = true; 74 | options.entry = matches[1]; 75 | } 76 | options.templateContent = content; 77 | } 78 | 79 | const callback = this.async(); 80 | 81 | prerender(this._compilation, this.request, options, inject, this) 82 | .then(output => { 83 | callback(null, outputFilter(output)); 84 | }) 85 | .catch(err => { 86 | // console.error(err); 87 | callback(err); 88 | }); 89 | } 90 | 91 | async function prerender (parentCompilation, request, options, inject, loader) { 92 | const parentCompiler = getRootCompiler(parentCompilation.compiler); 93 | const context = parentCompiler.options.context || process.cwd(); 94 | const customEntry = options.entry && ([].concat(options.entry).pop() || '').trim(); 95 | const entry = customEntry ? ('./' + customEntry) : convertPathToRelative(context, parentCompiler.options.entry, './'); 96 | 97 | const outputOptions = { 98 | // fix: some plugins ignore/bypass outputfilesystem, so use a temp directory and ignore any writes. 99 | path: os.tmpdir(), 100 | filename: FILENAME 101 | }; 102 | 103 | // Only copy over allowed plugins (excluding them breaks extraction entirely). 104 | const allowedPlugins = /(MiniCssExtractPlugin|ExtractTextPlugin)/i; 105 | const plugins = (parentCompiler.options.plugins || []).filter(c => allowedPlugins.test(c.constructor.name)); 106 | 107 | // Compile to an in-memory filesystem since we just want the resulting bundled code as a string 108 | const compiler = parentCompilation.createChildCompiler('prerender', outputOptions, plugins); 109 | compiler.context = parentCompiler.context; 110 | compiler.outputFileSystem = new MemoryFs(); 111 | 112 | // Define PRERENDER to be true within the SSR bundle, plus any other custom SSR env vars 113 | new DefinePlugin({ 114 | PRERENDER: 'true', 115 | ...(options.env || {}) 116 | }).apply(compiler); 117 | 118 | // ... then define PRERENDER to be false within the client bundle 119 | new DefinePlugin({ 120 | PRERENDER: 'false' 121 | }).apply(parentCompiler); 122 | 123 | // Compile to CommonJS to be executed by Node 124 | new NodeTemplatePlugin(outputOptions).apply(compiler); 125 | new NodeTargetPlugin().apply(compiler); 126 | 127 | new LibraryTemplatePlugin('PRERENDER_RESULT', 'var').apply(compiler); 128 | 129 | // Kick off compilation at our entry module (either the parent compiler's entry or a custom one defined via `{{prerender:entry.js}}`) 130 | applyEntry(context, entry, compiler); 131 | 132 | // Set up cache inheritance for the child compiler 133 | const subCache = 'subcache ' + request; 134 | function addChildCache (compilation, data) { 135 | if (compilation.cache) { 136 | if (!compilation.cache[subCache]) compilation.cache[subCache] = {}; 137 | compilation.cache = compilation.cache[subCache]; 138 | } 139 | } 140 | if (compiler.hooks) { 141 | compiler.hooks.compilation.tap(PLUGIN_NAME, addChildCache); 142 | } else { 143 | compiler.plugin('compilation', addChildCache); 144 | } 145 | 146 | const compilation = await runChildCompiler(compiler); 147 | let result; 148 | let dom, window, injectParent, injectNextSibling; 149 | 150 | // A promise-like that never resolves and does not retain references to callbacks. 151 | function BrokenPromise () {} 152 | BrokenPromise.prototype.then = BrokenPromise.prototype.catch = BrokenPromise.prototype.finally = () => new BrokenPromise(); 153 | 154 | if (compilation.assets[compilation.options.output.filename]) { 155 | // Get the compiled main bundle 156 | const output = compilation.assets[compilation.options.output.filename].source(); 157 | 158 | // @TODO: provide a non-DOM option to allow turning off JSDOM entirely. 159 | 160 | const tpl = options.templateContent || ''; 161 | dom = new jsdom.JSDOM(tpl.replace(PRERENDER_REG, '
'), { 162 | // suppress console-proxied eval() errors, but keep console proxying 163 | virtualConsole: new jsdom.VirtualConsole({ omitJSDOMErrors: false }).sendTo(console), 164 | 165 | // `url` sets the value returned by `window.location`, `document.URL`... 166 | // Useful for routers that depend on the current URL (such as react-router or reach-router) 167 | url: options.documentUrl || 'http://localhost', 168 | 169 | // don't track source locations for performance reasons 170 | includeNodeLocations: false, 171 | 172 | // don't allow inline event handlers & script tag exec 173 | runScripts: 'outside-only' 174 | }); 175 | window = dom.window; 176 | 177 | // Find the placeholder node for injection & remove it 178 | const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT'); 179 | if (injectPlaceholder) { 180 | injectParent = injectPlaceholder.parentNode; 181 | injectNextSibling = injectPlaceholder.nextSibling; 182 | injectPlaceholder.remove(); 183 | } 184 | 185 | // These are missing from JSDOM 186 | let counter = 0; 187 | window.requestAnimationFrame = () => ++counter; 188 | window.cancelAnimationFrame = () => { }; 189 | 190 | // Never prerender Custom Elements: by skipping registration, we get only the Light DOM which is desirable. 191 | window.customElements = { 192 | define () {}, 193 | get () {}, 194 | upgrade () {}, 195 | whenDefined: () => new BrokenPromise() 196 | }; 197 | 198 | // Fake MessagePort 199 | window.MessagePort = function () { 200 | (this.port1 = new window.EventTarget()).postMessage = () => {}; 201 | (this.port2 = new window.EventTarget()).postMessage = () => {}; 202 | }; 203 | 204 | // Never matches 205 | window.matchMedia = () => ({ addListener () {} }); 206 | 207 | // Never register ServiceWorkers 208 | if (!window.navigator) window.navigator = {}; 209 | window.navigator.serviceWorker = { 210 | register: () => new BrokenPromise() 211 | }; 212 | 213 | // When DefinePlugin isn't sufficient 214 | window.PRERENDER = true; 215 | 216 | // Inject a require shim 217 | window.require = moduleId => { 218 | const asset = compilation.assets[moduleId.replace(/^\.?\//g, '')]; 219 | if (!asset) { 220 | try { 221 | return require(moduleId); 222 | } catch (e) { 223 | throw Error(`Error: Module not found. attempted require("${moduleId}")`); 224 | } 225 | } 226 | const mod = { exports: {} }; 227 | window.eval(`(function(exports, module, require){\n${asset.source()}\n})`)(mod.exports, mod, window.require); 228 | return mod.exports; 229 | }; 230 | 231 | // Invoke the SSR bundle within the JSDOM document and grab the exported/returned result 232 | result = window.eval(output + '\nPRERENDER_RESULT'); 233 | } 234 | 235 | // Deal with ES Module exports (just use the best guess): 236 | if (result && typeof result === 'object') { 237 | result = getBestModuleExport(result); 238 | } 239 | 240 | if (typeof result === 'function') { 241 | result = result(options.params || null); 242 | } 243 | 244 | // The entry can export or return a Promise in order to perform fully async prerendering: 245 | if (result && result.then) { 246 | result = await result; 247 | } 248 | 249 | // Returning or resolving to `null` / `undefined` defaults to serializing the whole document. 250 | // Note: this pypasses `inject` because the document is already derived from the template. 251 | if (result !== undefined && options.templateContent) { 252 | const template = window.document.createElement('template'); 253 | template.innerHTML = result || ''; 254 | const content = template.content || template; 255 | const parent = injectParent || window.document.body; 256 | let child; 257 | while ((child = content.firstChild)) { 258 | parent.insertBefore(child, injectNextSibling || null); 259 | } 260 | } else if (inject) { 261 | // Otherwise inject the prerendered HTML into the template 262 | return options.templateContent.replace(PRERENDER_REG, result || ''); 263 | } 264 | 265 | // dom.serialize() doesn't properly serialize HTML appended to document.body. 266 | // return `${window.document.documentElement.outerHTML}`; 267 | let serialized = dom.serialize(); 268 | if (!/^${serialized}`; 270 | } 271 | return serialized; 272 | 273 | // // Returning or resolving to `null` / `undefined` defaults to serializing the whole document. 274 | // // Note: this pypasses `inject` because the document is already derived from the template. 275 | // if (result == null && dom) { 276 | // // result = dom.serialize(); 277 | // } else if (inject) { 278 | // // @TODO determine if this is really worthwhile/necessary for the string return case 279 | // if (injectParent || options.templateContent) { 280 | // console.log(injectParent.outerHTML); 281 | // (injectParent || document.body).insertAdjacentHTML('beforeend', result || ''); 282 | // // result = dom.serialize(); 283 | // } else { 284 | // // Otherwise inject the prerendered HTML into the template 285 | // return options.templateContent.replace(PRERENDER_REG, result || ''); 286 | // } 287 | // } 288 | 289 | // return dom.serialize(); 290 | // return result; 291 | } 292 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import path from 'path'; 18 | 19 | /** 20 | * Promisified version of compiler.runAsChild() with error hoisting and isolated output/assets. 21 | * (runAsChild() merges assets into the parent compilation, we don't want that) 22 | */ 23 | export function runChildCompiler (compiler) { 24 | return new Promise((resolve, reject) => { 25 | compiler.compile((err, compilation) => { 26 | // still allow the parent compiler to track execution of the child: 27 | compiler.parentCompilation.children.push(compilation); 28 | if (err) return reject(err); 29 | 30 | // Bubble stat errors up and reject the Promise: 31 | if (compilation.errors && compilation.errors.length) { 32 | const errorDetails = compilation.errors.map(error => error.details).join('\n'); 33 | return reject(Error('Child compilation failed:\n' + errorDetails)); 34 | } 35 | 36 | resolve(compilation); 37 | }); 38 | }); 39 | } 40 | 41 | /** Crawl up the compiler tree and return the outermost compiler instance */ 42 | export function getRootCompiler (compiler) { 43 | while (compiler.parentCompilation && compiler.parentCompilation.compiler) { 44 | compiler = compiler.parentCompilation.compiler; 45 | } 46 | return compiler; 47 | } 48 | 49 | /** Find the best possible export for an ES Module. Returns `undefined` for no exports. */ 50 | export function getBestModuleExport (exports) { 51 | if (exports.default) { 52 | return exports.default; 53 | } 54 | for (const prop in exports) { 55 | if (prop !== '__esModule') { 56 | return exports[prop]; 57 | } 58 | } 59 | } 60 | 61 | /** Wrap a String up into an ES Module that exports it */ 62 | export function stringToModule (str) { 63 | return 'export default ' + JSON.stringify(str); 64 | } 65 | 66 | export function convertPathToRelative (context, entry, prefix = '') { 67 | if (Array.isArray(entry)) { 68 | return entry.map(entry => prefix + path.relative(context, entry)); 69 | } else if (entry && typeof entry === 'object') { 70 | return Object.keys(entry).reduce((acc, key) => { 71 | acc[key] = Array.isArray(entry[key]) 72 | ? entry[key].map(item => prefix + path.relative(context, item)) 73 | : prefix + path.relative(context, entry[key]); 74 | return acc; 75 | }, {}); 76 | } 77 | return prefix + path.relative(context, entry); 78 | } 79 | -------------------------------------------------------------------------------- /src/webpack-util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import SingleEntryPlugin from 'webpack/lib/SingleEntryPlugin'; 18 | import MultiEntryPlugin from 'webpack/lib/MultiEntryPlugin'; 19 | 20 | /** Handle "object", "string" and "array" types of entry */ 21 | export function applyEntry (context, entry, compiler) { 22 | if (typeof entry === 'string' || Array.isArray(entry)) { 23 | itemToPlugin(context, entry, 'main').apply(compiler); 24 | } else if (typeof entry === 'object') { 25 | Object.keys(entry).forEach(name => { 26 | itemToPlugin(context, entry[name], name).apply(compiler); 27 | }); 28 | } 29 | } 30 | 31 | function itemToPlugin (context, item, name) { 32 | if (Array.isArray(item)) { 33 | return new MultiEntryPlugin(context, item, name); 34 | } 35 | 36 | return new SingleEntryPlugin(context, item, name); 37 | } 38 | -------------------------------------------------------------------------------- /test/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`prerender-loader!x.html Imperative DOM, no {{prerender}} field should return serialized HTML 1`] = ` 4 | " 5 | Basic Demo 6 | 7 | 8 |
this counts as SSR
" 9 | `; 10 | 11 | exports[`prerender-loader!x.html default export function, no {{prerender}} field should inject returned HTML into 1`] = ` 12 | " 13 | export factory returning HTML 14 | 15 | 16 |
some returned HTML
" 17 | `; 18 | 19 | exports[`prerender-loader!x.html default export function, with {{prerender}} field should inject returned HTML in place of {{prerender}} 1`] = ` 20 | " 21 | prerender field demo 22 | 23 | 24 |
25 |
more returned HTML
26 |
27 | " 28 | `; 29 | 30 | exports[`prerender-loader!x.html exported function with no return value should serialize the whole document 1`] = ` 31 | " 32 | function export with default DOM serialization 33 | 34 | 35 |
content injected into the dom
" 36 | `; 37 | 38 | exports[`webpack compilation smoke test (no prerendering) should compile 1`] = ` 39 | " 40 | 41 | 42 | Basic Demo 43 | 44 | 45 | 46 | 47 | " 48 | `; 49 | 50 | exports[`webpack compilation smoke test (no prerendering) should compile array entries 1`] = ` 51 | " 52 | 53 | 54 | Basic Demo 55 | 56 | 57 | 58 | 59 | " 60 | `; 61 | 62 | exports[`webpack compilation smoke test (no prerendering) should compile named entries 1`] = ` 63 | " 64 | 65 | 66 | Basic Demo 67 | 68 | 69 | 70 | 71 | " 72 | `; 73 | -------------------------------------------------------------------------------- /test/_helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import { promisify } from 'util'; 18 | import jsdom from 'jsdom'; 19 | import fs from 'fs'; 20 | import path from 'path'; 21 | import webpack from 'webpack'; 22 | 23 | const DOMParser = new jsdom.JSDOM(``, { includeNodeLocations: false }).window.DOMParser; 24 | 25 | // parse a string into a JSDOM Document 26 | export const parseDom = html => new DOMParser().parseFromString(html, 'text/html'); 27 | 28 | // returns a promise resolving to the contents of a file 29 | export const readFile = file => promisify(fs.readFile)(path.resolve(__dirname, file), 'utf8'); 30 | 31 | // invoke webpack on a given entry module, optionally mutating the default configuration 32 | export function compile (entry, configDecorator) { 33 | return new Promise((resolve, reject) => { 34 | const context = path.dirname(path.resolve(__dirname, entry)); 35 | entry = path.basename(entry); 36 | let config = { 37 | context, 38 | entry: path.resolve(context, entry), 39 | output: { 40 | path: path.resolve(__dirname, path.resolve(context, 'dist')), 41 | filename: 'bundle.js', 42 | chunkFilename: '[name].chunk.js' 43 | }, 44 | resolveLoader: { 45 | modules: [path.resolve(__dirname, '../node_modules')] 46 | }, 47 | module: { 48 | rules: [] 49 | }, 50 | plugins: [] 51 | }; 52 | if (configDecorator) { 53 | config = configDecorator(config) || config; 54 | } 55 | webpack(config, (err, stats) => { 56 | if (err) return reject(err); 57 | const info = stats.toJson(); 58 | if (stats.hasErrors()) return reject(info.errors.join('\n')); 59 | resolve(info); 60 | }); 61 | }); 62 | } 63 | 64 | // invoke webpack via compile(), injecting `html` and `document` properties into the webpack build info. 65 | export async function compileToHtml (fixture, configDecorator, crittersOptions = {}) { 66 | const info = await compile(`fixtures/${fixture}/index.js`, configDecorator); 67 | info.html = await readFile(`fixtures/${fixture}/dist/index.html`); 68 | info.document = parseDom(info.html); 69 | return info; 70 | } 71 | -------------------------------------------------------------------------------- /test/fixtures/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Basic Demo 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/basic/index.js: -------------------------------------------------------------------------------- 1 | var div = document.createElement('div'); 2 | div.appendChild(document.createTextNode('this counts as SSR')); 3 | document.body.appendChild(div); 4 | -------------------------------------------------------------------------------- /test/fixtures/document-url/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | document with different window.location 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/document-url/index.js: -------------------------------------------------------------------------------- 1 | export default () => `
${window.location}
`; 2 | -------------------------------------------------------------------------------- /test/fixtures/factory/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | export factory returning HTML 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/factory/index.js: -------------------------------------------------------------------------------- 1 | export default () => `
some returned HTML
`; 2 | -------------------------------------------------------------------------------- /test/fixtures/failure/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Failure Demo 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/failure/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './does-not-exist'; 2 | -------------------------------------------------------------------------------- /test/fixtures/function-export-dom/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | function export with default DOM serialization 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/function-export-dom/index.js: -------------------------------------------------------------------------------- 1 | export default () => new Promise(resolve => { 2 | var div = document.createElement('div'); 3 | div.appendChild(document.createTextNode('content injected into the dom')); 4 | document.body.appendChild(div); 5 | 6 | setTimeout(() => { 7 | resolve(); 8 | }, 10); 9 | }); 10 | -------------------------------------------------------------------------------- /test/fixtures/named-export-fn/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | named export factory 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/named-export-fn/index.js: -------------------------------------------------------------------------------- 1 | export function prerender () { 2 | return `
some HTML returned from prerender()
`; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/value-export/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | value export 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/value-export/index.js: -------------------------------------------------------------------------------- 1 | export const PRERENDERED_HTML = Promise.resolve(`
this is the resolved value of PRERENDERED_HTML
`); 2 | -------------------------------------------------------------------------------- /test/fixtures/with-field/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | prerender field demo 5 | 6 | 7 |
8 | {{prerender}} 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /test/fixtures/with-field/index.js: -------------------------------------------------------------------------------- 1 | export default () => `
more returned HTML
`; 2 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 19 | import { compile, compileToHtml, readFile } from './_helpers'; 20 | 21 | const PRERENDER_LOADER = path.resolve(__dirname, '../src'); 22 | 23 | const configure = prerenderLoaderOptions => config => { 24 | let template = path.resolve(config.context, 'index.html'); 25 | if (prerenderLoaderOptions === true) { 26 | template = `!!${PRERENDER_LOADER}?string!${template}`; 27 | } else if (prerenderLoaderOptions) { 28 | template = `!!${PRERENDER_LOADER}?${JSON.stringify(prerenderLoaderOptions)}!${template}`; 29 | } else { 30 | template = `!!raw-loader!${template}`; 31 | } 32 | config.plugins.push( 33 | new HtmlWebpackPlugin({ 34 | filename: 'index.html', 35 | template, 36 | compile: false, 37 | inject: true, 38 | minify: { 39 | collapseWhitespace: true, 40 | preserveLineBreaks: true 41 | } 42 | }) 43 | ); 44 | }; 45 | 46 | const withoutPrerender = configure(false); 47 | const withPrerender = configure(true); 48 | 49 | describe('webpack compilation smoke test (no prerendering)', () => { 50 | it('should compile', async () => { 51 | const info = await compile('fixtures/basic/index.js', withoutPrerender); 52 | expect(info.assets).toHaveLength(2); 53 | 54 | const html = await readFile('fixtures/basic/dist/index.html'); 55 | expect(html).toMatchSnapshot(); 56 | }); 57 | 58 | it('should compile named entries', async () => { 59 | const info = await compile('fixtures/basic/index.js', config => { 60 | config = withoutPrerender(config) || config; 61 | config.entry = { app: config.entry }; 62 | return config; 63 | }); 64 | expect(info.assets).toHaveLength(2); 65 | 66 | const html = await readFile('fixtures/basic/dist/index.html'); 67 | expect(html).toMatchSnapshot(); 68 | }); 69 | 70 | it('should compile array entries', async () => { 71 | const info = await compile('fixtures/basic/index.js', config => { 72 | config = withoutPrerender(config) || config; 73 | config.entry = [config.entry]; 74 | return config; 75 | }); 76 | expect(info.assets).toHaveLength(2); 77 | 78 | const html = await readFile('fixtures/basic/dist/index.html'); 79 | expect(html).toMatchSnapshot(); 80 | }); 81 | 82 | it('should propagate child compiler errors', async () => { 83 | await expect(compile('fixtures/failure/index.js', withPrerender)).rejects.toMatch(/does-not-exist/); 84 | }); 85 | }); 86 | 87 | describe('prerender-loader!x.html', () => { 88 | describe('?disabled', async () => { 89 | const { html } = await compileToHtml('basic', configure({ disabled: true, string: true })); 90 | expect(html).not.toMatch(/
this counts as SSR<\/div>/); 91 | }); 92 | 93 | describe('Imperative DOM, no {{prerender}} field', () => { 94 | it('should return serialized HTML', async () => { 95 | const { html, document } = await compileToHtml('basic', withPrerender); 96 | 97 | // verify that our DOM-generated content has been prerendered into the static HTML: 98 | expect(html).toMatch(/
this counts as SSR<\/div>/); 99 | // ... and that there's no extra children: 100 | expect(document.body.children).toHaveLength(2); 101 | // ... and that html-webpack-plugin was able to inject scripts after the content: 102 | expect(document.body.firstElementChild).toHaveProperty('outerHTML', '
this counts as SSR
'); 103 | expect(html).toMatchSnapshot(); 104 | }); 105 | }); 106 | 107 | describe('default export function, no {{prerender}} field', () => { 108 | it('should inject returned HTML into ', async () => { 109 | const { html, document } = await compileToHtml('factory', withPrerender); 110 | 111 | // verify that our DOM-generated content has been prerendered into the static HTML: 112 | expect(html).toMatch(/
some returned HTML<\/div>/); 113 | // ... and that there's no extra children: 114 | expect(document.body.children).toHaveLength(2); 115 | // ... and that html-webpack-plugin was able to inject scripts after the content: 116 | expect(document.body.firstElementChild).toHaveProperty('outerHTML', '
some returned HTML
'); 117 | expect(html).toMatchSnapshot(); 118 | }); 119 | }); 120 | 121 | describe('default export function, with {{prerender}} field', () => { 122 | //
some returned HTML
123 | it('should inject returned HTML in place of {{prerender}}', async () => { 124 | const { html, document } = await compileToHtml('with-field', withPrerender); 125 | 126 | // verify that our DOM-generated content has been prerendered into the static HTML: 127 | expect(html).toMatch(/
more returned HTML<\/div>/); 128 | expect(document.body.children).toHaveLength(2); 129 | // verify our the wrapper element that contained {{prerender}} is still present: 130 | expect(document.body.firstElementChild).toHaveProperty('id', 'wrapper'); 131 | // verify the wrapper element contains all of the prerendered content: 132 | expect(document.getElementById('wrapper').innerHTML).toMatch(/^\s*
more returned HTML<\/div>\s*$/); 133 | expect(html).toMatchSnapshot(); 134 | }); 135 | }); 136 | 137 | describe('named export function', () => { 138 | it('should invoke the only named export', async () => { 139 | const { html } = await compileToHtml('named-export-fn', withPrerender); 140 | expect(html).toMatch(/
some HTML returned from prerender\(\)<\/div>/); 141 | }); 142 | }); 143 | 144 | describe('exported value', () => { 145 | it('should invoke the only named export', async () => { 146 | const { html } = await compileToHtml('value-export', withPrerender); 147 | expect(html).toMatch(/
this is the resolved value of PRERENDERED_HTML<\/div>/); 148 | }); 149 | }); 150 | 151 | describe('exported function with no return value', () => { 152 | it('should serialize the whole document', async () => { 153 | const { html, document } = await compileToHtml('function-export-dom', withPrerender); 154 | expect(html).toMatch(/
content injected into the dom<\/div>/); 155 | expect(document.body.children).toHaveLength(2); 156 | expect(html).toMatchSnapshot(); 157 | }); 158 | }); 159 | 160 | const DOCUMENT_URL = 'http://localhost/page'; 161 | 162 | describe(`?documentUrl=${DOCUMENT_URL}`, () => { 163 | it('should set the value returned by window.location', async () => { 164 | const { document } = await compileToHtml('document-url', configure({ string: true, documentUrl: DOCUMENT_URL })); 165 | 166 | // verify that our DOM-generated content has been prerendered into the static HTML: 167 | expect(document.body.firstElementChild).toHaveProperty('outerHTML', `
${DOCUMENT_URL}
`); 168 | }); 169 | }); 170 | }); 171 | --------------------------------------------------------------------------------