├── .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 |
3 |
4 | prerender-loader
5 |
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 |
--------------------------------------------------------------------------------