diff --git a/README.md b/README.md
index 3ac69c6..5b00031 100644
--- a/README.md
+++ b/README.md
@@ -45,12 +45,14 @@ export default defineConfig({
| Option | Type | Default | Description |
|---|---|---|---|
+| `devtoolsInProd` | `boolean` | `false` | Inject devtools bridge in production bundle instead of only in development mode |
| `devToolsEnabled` | `boolean` | `true` | Inject devtools bridge |
| `prefreshEnabled` | `boolean` | `true` | Inject [Prefresh](https://github.com/preactjs/prefresh) for HMR |
-| `devtoolsInProd` | `boolean` | `false` | Inject devtools bridge in production bundle instead of only in development mode |
| `reactAliasesEnabled` | `boolean` | `true` | Aliases `react`, `react-dom` to `preact/compat` |
+| `babel` | `object` | | See [Babel configuration](#babel-configuration) |
+| `prerender` | `object` | | See [Prerendering configuration](#prerendering-configuration) |
-### Babel configuration
+#### Babel configuration
The `babel` option lets you add plugins, presets, and [other configuration](https://babeljs.io/docs/en/options) to the Babel transformation performed on each JSX/TSX file.
@@ -68,6 +70,49 @@ preact({
})
```
+#### Prerendering configuration
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `enabled` | `boolean` | `false` | Enables prerendering |
+| `prerenderScript` | `string` | `undefined` | Absolute path to script containing exported `prerender()` function. If not provided, will try to find the prerender script in the scripts listed in your HTML entrypoint |
+| `renderTarget` | `string` | `"body"` | Query selector for where to insert prerender result in your HTML template |
+| `additionalPrerenderRoutes` | `string` | `undefined` | Prerendering will automatically discover links to prerender, but if there are unliked pages that you want to prererender (such as a `/404` page), use this option to specify them |
+
+To prerender your app, you'll need to set `prerender.enabled` to `true` and export a `prerender()` function one of the scripts listed in your HTML entry point (or the script specified through `prerender.prerenderScript`). How precisely you generate an HTML string from your app is up to you, but you'll likely want to use [`preact-render-to-string`](https://github.com/preactjs/preact-render-to-string) or a wrapper around it such as [`preact-iso`'s `prerender`](https://github.com/preactjs/preact-iso). Whatever you choose, you simply need to return an object from your `prerender()` function containing an `html` property with your HTML string.
+
+[For an example implementation, see our demo](./demo/src/index.tsx)
+
+```js
+import { render } from 'preact-render-to-string';
+
+export async function prerender(data) {
+ const html = render(`
hello world `);
+
+ return {
+ html,
+ // Optionally add additional links that should be
+ // prerendered (if they haven't already been)
+ links: new Set(['/foo', '/bar']),
+ // Optionally configure and add elements to the `` of
+ // the prerendered HTML document
+ head: {
+ // Sets the "lang" attribute: ``
+ lang: 'en',
+ // Sets the title for the current page: `My cool page `
+ title: 'My cool page',
+ // Sets any additional elements you want injected into the ``:
+ //
+ //
+ elements: new Set([
+ { type: 'link', props: { rel: 'stylesheet', href: 'foo.css' } },
+ { type: 'meta', props: { property: 'og:title', content: 'Social media title' } }
+ ])
+ }
+ };
+}
+```
+
## License
MIT, see [the license file](./LICENSE).
diff --git a/demo/index.html b/demo/index.html
index cbc1f5a..5f3db57 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/demo/public/local-fetch-test.txt b/demo/public/local-fetch-test.txt
new file mode 100644
index 0000000..b2c3cd2
--- /dev/null
+++ b/demo/public/local-fetch-test.txt
@@ -0,0 +1 @@
+Local fetch works
diff --git a/demo/src/components/LocalFetch.tsx b/demo/src/components/LocalFetch.tsx
new file mode 100644
index 0000000..11eea19
--- /dev/null
+++ b/demo/src/components/LocalFetch.tsx
@@ -0,0 +1,32 @@
+import { useState } from "preact/hooks";
+
+const cache = new Map();
+
+async function load(url: string) {
+ const res = await fetch(url);
+ return await res.text();
+}
+
+function useFetch(url: string) {
+ const [_, update] = useState({});
+
+ let data = cache.get(url);
+ if (!data) {
+ data = load(url);
+ cache.set(url, data);
+ data.then(
+ (res: string) => update((data.res = res)),
+ (err: Error) => update((data.err = err)),
+ );
+ }
+
+ if (data.res) return data.res;
+ if (data.err) throw data.err;
+ throw data;
+}
+
+export function LocalFetch() {
+ const data = useFetch("/local-fetch-test.txt");
+
+ return {data.trimEnd()}
;
+}
diff --git a/demo/src/index.tsx b/demo/src/index.tsx
index 8a31885..3fe367a 100644
--- a/demo/src/index.tsx
+++ b/demo/src/index.tsx
@@ -1,5 +1,10 @@
-import { render } from "preact";
-import { LocationProvider, Router, Route } from "preact-iso";
+import {
+ LocationProvider,
+ Router,
+ Route,
+ hydrate,
+ prerender as ssr,
+} from "preact-iso";
import { Header } from "./components/Header.jsx";
import { Home } from "./pages/Home/index.jsx";
@@ -20,4 +25,27 @@ export function App() {
);
}
-render( , document.getElementById("app")!);
+if (typeof window !== "undefined") {
+ hydrate( , document.getElementById("app"));
+}
+
+export async function prerender() {
+ const { html, links } = await ssr( );
+ return {
+ html,
+ links,
+ head: {
+ lang: "en",
+ title: "Prerendered Preact App",
+ elements: new Set([
+ {
+ type: "meta",
+ props: {
+ name: "description",
+ content: "This is a prerendered Preact app",
+ },
+ },
+ ]),
+ },
+ };
+}
diff --git a/demo/src/pages/Home/index.tsx b/demo/src/pages/Home/index.tsx
index 2eebe7c..d3de026 100644
--- a/demo/src/pages/Home/index.tsx
+++ b/demo/src/pages/Home/index.tsx
@@ -1,4 +1,5 @@
import { ReactComponent } from "../../components/Compat";
+import { LocalFetch } from "../../components/LocalFetch";
import preactLogo from "../../assets/preact.svg";
import "./style.css";
@@ -11,6 +12,7 @@ export function Home() {
Get Started building Vite-powered Preact Apps
+
=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-darwin-x64": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-darwin-x64/-/rml-darwin-x64-2.1.1.tgz",
+ "integrity": "sha512-uLeZKZFiygZntppOjbfGzU3YsFmx4joW/6UlKfCKwelrTrtKa3bYMz55NuFc2RW7IuuNrGEILwz+ZPUdfv3ykA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-linux-arm-gnueabihf": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-arm-gnueabihf/-/rml-linux-arm-gnueabihf-2.1.1.tgz",
+ "integrity": "sha512-Ou/h5ma/jsbgcWS//BdRh4bb/5RFRjebc1Yrlm6iyqSRNJqnY58hEl8pc9feOSkrjorvc8aAe5CWZPf7sZrzrA==",
+ "cpu": [
+ "arm"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-linux-arm64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-arm64-gnu/-/rml-linux-arm64-gnu-2.1.1.tgz",
+ "integrity": "sha512-hmNtRDxxNc9LVEr/tlzgHOG2wKM0w1rPdIu2zvIVpVi8JUFcenEcHGLrezFlQg8NK/BMQiAmMmMpxhB9Mr4wpA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-linux-arm64-musl": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-arm64-musl/-/rml-linux-arm64-musl-2.1.1.tgz",
+ "integrity": "sha512-sYY5m7fK1VhLodt4IDB+XSTmvu2g0D5VbT30RpBhXlL3CI37fgLyNvO+NSn9J6b4xrGqzdkIv677JfkhM9hHGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-linux-x64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-x64-gnu/-/rml-linux-x64-gnu-2.1.1.tgz",
+ "integrity": "sha512-n9Q6DOUgc6U05YqgnwKmpMdrKk3K4JtUpB6Wee3kyxrQr1FKT6E5FXqzzO2q62AxXqWsc1WVNE6IWldURUI2Bg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-linux-x64-musl": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-x64-musl/-/rml-linux-x64-musl-2.1.1.tgz",
+ "integrity": "sha512-4qq3jw6jqrL0L1A3Q1zIIl1392uamXycb6gws+RsWnNnaogVHa/QD47w9xTsVRVYqEfsroV+84OOvNSpaoBrQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-win32-arm64-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-win32-arm64-msvc/-/rml-win32-arm64-msvc-2.1.1.tgz",
+ "integrity": "sha512-QnsttcVOMvetJ0xZOsUSZ38hpAbydDGl4MwY4yGvKK0/6XbirKEq/bn3Zl9ABLX3elLaE/pghGSlxB9fsk70Qw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@xn-sakina/rml-win32-x64-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-win32-x64-msvc/-/rml-win32-x64-msvc-2.1.1.tgz",
+ "integrity": "sha512-AnNynVvyfp3mBUA0fQFqBth13pFcRjOhCDieF73UYYH3xDc/mowtjDO+kUWW9EtZH94CPDkT1Ei9WWESruOg6w==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -762,6 +901,11 @@
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
},
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -976,6 +1120,32 @@
"node": ">= 8"
}
},
+ "node_modules/css-select": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
+ "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -1007,6 +1177,57 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+ "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.4.553",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.553.tgz",
@@ -1039,6 +1260,17 @@
"node": ">=8.6"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -1547,6 +1779,14 @@
"node": ">=8"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/human-signals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
@@ -1830,6 +2070,17 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.5",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
+ "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1898,6 +2149,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/node-html-parser": {
+ "version": "6.1.12",
+ "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.12.tgz",
+ "integrity": "sha512-/bT/Ncmv+fbMGX96XG9g05vFt43m/+SYKIs9oAemQVYyVcZmDAI2Xq/SbNcpOA35eF0Zk2av3Ksf+Xk8Vt8abA==",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@@ -1924,6 +2184,17 @@
"node": ">=8"
}
},
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -2208,6 +2479,26 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rs-module-lexer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/rs-module-lexer/-/rs-module-lexer-2.1.1.tgz",
+ "integrity": "sha512-6F5a6PS3PJ/qZ0o+FKFcqABFBVsaMIOEMA4yBFyAwJnCpQ6a8CIk+ln3pRPrl0N2k6HgAupzXpQq4NuTVg5haQ==",
+ "hasInstallScript": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "optionalDependencies": {
+ "@xn-sakina/rml-darwin-arm64": "2.1.1",
+ "@xn-sakina/rml-darwin-x64": "2.1.1",
+ "@xn-sakina/rml-linux-arm-gnueabihf": "2.1.1",
+ "@xn-sakina/rml-linux-arm64-gnu": "2.1.1",
+ "@xn-sakina/rml-linux-arm64-musl": "2.1.1",
+ "@xn-sakina/rml-linux-x64-gnu": "2.1.1",
+ "@xn-sakina/rml-linux-x64-musl": "2.1.1",
+ "@xn-sakina/rml-win32-arm64-msvc": "2.1.1",
+ "@xn-sakina/rml-win32-x64-msvc": "2.1.1"
+ }
+ },
"node_modules/rxjs": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.6.tgz",
@@ -3088,6 +3379,60 @@
"integrity": "sha512-Ku5+GPFa12S3W26Uwtw+xyrtIpaZsGYHH6zxNbZlstmlvMYSZRzOwzwsXbxlVUbHyUucctSyuFtu6bNxwYomIw==",
"dev": true
},
+ "@xn-sakina/rml-darwin-arm64": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-darwin-arm64/-/rml-darwin-arm64-2.1.1.tgz",
+ "integrity": "sha512-Ljog63oD8+riz/3zLN0pAgvJSaFTpJY4KxrH+TJnfEbUoRb7ZhmGEpYGl5imi43/9yNkGyAoF41ysIHxg0yEAw==",
+ "optional": true
+ },
+ "@xn-sakina/rml-darwin-x64": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-darwin-x64/-/rml-darwin-x64-2.1.1.tgz",
+ "integrity": "sha512-uLeZKZFiygZntppOjbfGzU3YsFmx4joW/6UlKfCKwelrTrtKa3bYMz55NuFc2RW7IuuNrGEILwz+ZPUdfv3ykA==",
+ "optional": true
+ },
+ "@xn-sakina/rml-linux-arm-gnueabihf": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-arm-gnueabihf/-/rml-linux-arm-gnueabihf-2.1.1.tgz",
+ "integrity": "sha512-Ou/h5ma/jsbgcWS//BdRh4bb/5RFRjebc1Yrlm6iyqSRNJqnY58hEl8pc9feOSkrjorvc8aAe5CWZPf7sZrzrA==",
+ "optional": true
+ },
+ "@xn-sakina/rml-linux-arm64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-arm64-gnu/-/rml-linux-arm64-gnu-2.1.1.tgz",
+ "integrity": "sha512-hmNtRDxxNc9LVEr/tlzgHOG2wKM0w1rPdIu2zvIVpVi8JUFcenEcHGLrezFlQg8NK/BMQiAmMmMpxhB9Mr4wpA==",
+ "optional": true
+ },
+ "@xn-sakina/rml-linux-arm64-musl": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-arm64-musl/-/rml-linux-arm64-musl-2.1.1.tgz",
+ "integrity": "sha512-sYY5m7fK1VhLodt4IDB+XSTmvu2g0D5VbT30RpBhXlL3CI37fgLyNvO+NSn9J6b4xrGqzdkIv677JfkhM9hHGw==",
+ "optional": true
+ },
+ "@xn-sakina/rml-linux-x64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-x64-gnu/-/rml-linux-x64-gnu-2.1.1.tgz",
+ "integrity": "sha512-n9Q6DOUgc6U05YqgnwKmpMdrKk3K4JtUpB6Wee3kyxrQr1FKT6E5FXqzzO2q62AxXqWsc1WVNE6IWldURUI2Bg==",
+ "optional": true
+ },
+ "@xn-sakina/rml-linux-x64-musl": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-linux-x64-musl/-/rml-linux-x64-musl-2.1.1.tgz",
+ "integrity": "sha512-4qq3jw6jqrL0L1A3Q1zIIl1392uamXycb6gws+RsWnNnaogVHa/QD47w9xTsVRVYqEfsroV+84OOvNSpaoBrQQ==",
+ "optional": true
+ },
+ "@xn-sakina/rml-win32-arm64-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-win32-arm64-msvc/-/rml-win32-arm64-msvc-2.1.1.tgz",
+ "integrity": "sha512-QnsttcVOMvetJ0xZOsUSZ38hpAbydDGl4MwY4yGvKK0/6XbirKEq/bn3Zl9ABLX3elLaE/pghGSlxB9fsk70Qw==",
+ "optional": true
+ },
+ "@xn-sakina/rml-win32-x64-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@xn-sakina/rml-win32-x64-msvc/-/rml-win32-x64-msvc-2.1.1.tgz",
+ "integrity": "sha512-AnNynVvyfp3mBUA0fQFqBth13pFcRjOhCDieF73UYYH3xDc/mowtjDO+kUWW9EtZH94CPDkT1Ei9WWESruOg6w==",
+ "optional": true
+ },
"aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -3152,6 +3497,11 @@
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true
},
+ "boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
+ },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -3296,6 +3646,23 @@
"which": "^2.0.1"
}
},
+ "css-select": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
+ "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
+ "requires": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ }
+ },
+ "css-what": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
+ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
+ },
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -3316,6 +3683,39 @@
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true
},
+ "dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ }
+ },
+ "domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
+ },
+ "domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "requires": {
+ "domelementtype": "^2.3.0"
+ }
+ },
+ "domutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+ "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+ "requires": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ }
+ },
"electron-to-chromium": {
"version": "1.4.553",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.553.tgz",
@@ -3345,6 +3745,11 @@
"ansi-colors": "^4.1.1"
}
},
+ "entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
+ },
"error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -3614,6 +4019,11 @@
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
+ "he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
+ },
"human-signals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
@@ -3827,6 +4237,14 @@
"yallist": "^3.0.2"
}
},
+ "magic-string": {
+ "version": "0.30.5",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
+ "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==",
+ "requires": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
"make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -3874,6 +4292,15 @@
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
},
+ "node-html-parser": {
+ "version": "6.1.12",
+ "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.12.tgz",
+ "integrity": "sha512-/bT/Ncmv+fbMGX96XG9g05vFt43m/+SYKIs9oAemQVYyVcZmDAI2Xq/SbNcpOA35eF0Zk2av3Ksf+Xk8Vt8abA==",
+ "requires": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
"node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@@ -3894,6 +4321,14 @@
"path-key": "^3.0.0"
}
},
+ "nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "requires": {
+ "boolbase": "^1.0.0"
+ }
+ },
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -4080,6 +4515,22 @@
"fsevents": "~2.3.2"
}
},
+ "rs-module-lexer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/rs-module-lexer/-/rs-module-lexer-2.1.1.tgz",
+ "integrity": "sha512-6F5a6PS3PJ/qZ0o+FKFcqABFBVsaMIOEMA4yBFyAwJnCpQ6a8CIk+ln3pRPrl0N2k6HgAupzXpQq4NuTVg5haQ==",
+ "requires": {
+ "@xn-sakina/rml-darwin-arm64": "2.1.1",
+ "@xn-sakina/rml-darwin-x64": "2.1.1",
+ "@xn-sakina/rml-linux-arm-gnueabihf": "2.1.1",
+ "@xn-sakina/rml-linux-arm64-gnu": "2.1.1",
+ "@xn-sakina/rml-linux-arm64-musl": "2.1.1",
+ "@xn-sakina/rml-linux-x64-gnu": "2.1.1",
+ "@xn-sakina/rml-linux-x64-musl": "2.1.1",
+ "@xn-sakina/rml-win32-arm64-msvc": "2.1.1",
+ "@xn-sakina/rml-win32-x64-msvc": "2.1.1"
+ }
+ },
"rxjs": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.6.tgz",
diff --git a/package.json b/package.json
index a4f2a09..4294281 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,10 @@
"babel-plugin-transform-hook-names": "^1.0.2",
"debug": "^4.3.4",
"kolorist": "^1.8.0",
- "resolve": "^1.22.8"
+ "magic-string": "0.30.5",
+ "node-html-parser": "^6.1.10",
+ "resolve": "^1.22.8",
+ "rs-module-lexer": "^2.1.1"
},
"peerDependencies": {
"@babel/core": "7.x",
@@ -59,6 +62,7 @@
"preact-render-to-string": "^6.3.1",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
+ "rollup": "^2.77.3",
"simple-git-hooks": "^2.0.2",
"ts-node": "^9.1.1",
"typescript": "^4.2.3",
diff --git a/src/index.ts b/src/index.ts
index 1a1c5f2..029cbdb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,7 @@ import type { TransformOptions } from "@babel/core";
import prefresh from "@prefresh/vite";
import { preactDevtoolsPlugin } from "./devtools.js";
import { createFilter, parseId } from "./utils.js";
+import { PrerenderPlugin } from "./prerender.js";
import { transformAsync } from "@babel/core";
export type BabelOptions = Omit<
@@ -43,6 +44,28 @@ export interface PreactPluginOptions {
*/
reactAliasesEnabled?: boolean;
+ /**
+ * Prerender plugin options
+ */
+ prerender?: {
+ /**
+ * Whether to prerender your app on build
+ */
+ enabled: boolean;
+ /**
+ * Absolute path to script containing an exported `prerender()` function
+ */
+ prerenderScript?: string;
+ /**
+ * Query selector for specifying where to insert prerender result in your HTML template
+ */
+ renderTarget?: string;
+ /**
+ * Additional routes that should be prerendered
+ */
+ additionalPrerenderRoutes?: string[];
+ };
+
/**
* RegExp or glob to match files to be transformed
*/
@@ -78,6 +101,7 @@ function preactPlugin({
devToolsEnabled,
prefreshEnabled,
reactAliasesEnabled,
+ prerender,
include,
exclude,
babel,
@@ -110,6 +134,7 @@ function preactPlugin({
devToolsEnabled = devToolsEnabled ?? true;
prefreshEnabled = prefreshEnabled ?? true;
reactAliasesEnabled = reactAliasesEnabled ?? true;
+ prerender = prerender ?? { enabled: false };
const jsxPlugin: Plugin = {
name: "vite:preact-jsx",
@@ -221,6 +246,7 @@ function preactPlugin({
...(prefreshEnabled
? [prefresh({ include, exclude, parserPlugins: baseParserOptions })]
: []),
+ ...(prerender.enabled ? [PrerenderPlugin(prerender)] : []),
];
}
diff --git a/src/prerender.ts b/src/prerender.ts
new file mode 100644
index 0000000..1825a19
--- /dev/null
+++ b/src/prerender.ts
@@ -0,0 +1,376 @@
+import path from "node:path";
+
+import { promises as fs } from "node:fs";
+
+import MagicString from "magic-string";
+import { parse as htmlParse } from "node-html-parser";
+import rsModuleLexer from "rs-module-lexer";
+
+import type { Plugin, ResolvedConfig } from "vite";
+
+// Vite re-exports Rollup's type defs in newer versions,
+// merge into above type import when we bump the Vite devDep
+import type { InputOption, OutputAsset, OutputChunk } from "rollup";
+
+interface HeadElement {
+ type: string;
+ props: Record;
+ children?: string;
+}
+
+interface Head {
+ lang: string;
+ title: string;
+ elements: Set;
+}
+
+interface PrerenderedRoute {
+ url: string;
+ _discoveredBy?: PrerenderedRoute;
+}
+
+function enc(str: string) {
+ return str
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
+
+function serializeElement(
+ element: HeadElement | HeadElement[] | string,
+): string {
+ if (element == null) return "";
+ if (typeof element !== "object") return String(element);
+ if (Array.isArray(element)) return element.map(serializeElement).join("");
+ const type = element.type;
+ let s = `<${type}`;
+ const props = element.props || {};
+ let children = element.children;
+ for (const prop of Object.keys(props)) {
+ const value = props[prop];
+ // Filter out empty values:
+ if (value == null) continue;
+ if (prop === "children" || prop === "textContent") children = value;
+ else s += ` ${prop}="${enc(value)}"`;
+ }
+ s += ">";
+ if (!/link|meta|base/.test(type)) {
+ if (children) s += serializeElement(children);
+ s += `${type}>`;
+ }
+ return s;
+}
+
+interface PrerenderPluginOptions {
+ prerenderScript?: string;
+ renderTarget?: string;
+ additionalPrerenderRoutes?: string[];
+}
+
+export function PrerenderPlugin({
+ prerenderScript,
+ renderTarget,
+ additionalPrerenderRoutes,
+}: PrerenderPluginOptions = {}): Plugin {
+ const preloadHelperId = "vite/preload-helper";
+ let viteConfig = {} as ResolvedConfig;
+
+ renderTarget ||= "body";
+ additionalPrerenderRoutes ||= [];
+
+ /**
+ * From the non-external scripts in entry HTML document, find the one (if any)
+ * that provides a `prerender` export
+ */
+ const getPrerenderScriptFromHTML = async (input: InputOption) => {
+ // prettier-ignore
+ const entryHtml =
+ typeof input === "string"
+ ? input
+ : Array.isArray(input)
+ ? input.find(i => /html$/.test(i))
+ : Object.values(input).find(i => /html$/.test(i));
+
+ if (!entryHtml) throw new Error("Unable to detect entry HTML");
+
+ const htmlDoc = htmlParse(await fs.readFile(entryHtml, "utf-8"));
+ const scripts = htmlDoc
+ .getElementsByTagName("script")
+ .map(s => s.getAttribute("src"))
+ .filter((src): src is string => !!src && !/^https:/.test(src));
+
+ if (scripts.length === 0)
+ throw new Error("No local scripts found in entry HTML");
+
+ const { output } = await rsModuleLexer.parseAsync({
+ input: await Promise.all(
+ scripts.map(async script => ({
+ filename: script,
+ code: await fs.readFile(path.join(viteConfig.root, script), "utf-8"),
+ })),
+ ),
+ });
+
+ let entryScript;
+ for (const module of output) {
+ const entry = module.exports.find(exp => exp.n === "prerender");
+ if (entry) {
+ entryScript = module.filename;
+ break;
+ }
+ }
+
+ if (!entryScript)
+ throw new Error("Unable to detect prerender entry script");
+
+ return path.join(viteConfig.root, entryScript);
+ };
+
+ return {
+ name: "preact:prerender",
+ apply: "build",
+ enforce: "post",
+ configResolved(config) {
+ viteConfig = config;
+ },
+ async options(opts) {
+ if (!opts.input) return;
+ if (!prerenderScript) {
+ prerenderScript = await getPrerenderScriptFromHTML(opts.input);
+ }
+
+ // prettier-ignore
+ opts.input =
+ typeof opts.input === "string"
+ ? [opts.input, prerenderScript]
+ : Array.isArray(opts.input)
+ ? [...opts.input, prerenderScript]
+ : { ...opts.input, prerenderEntry: prerenderScript };
+ opts.preserveEntrySignatures = "allow-extension";
+ },
+ // Injects a window check into Vite's preload helper, instantly resolving
+ // the module rather than attempting to add a to the document.
+ transform(code, id) {
+ // Vite keeps changing up the ID, best we can do for cross-version
+ // compat is an `includes`
+ if (id.includes(preloadHelperId)) {
+ // Through v5.0.4
+ // https://github.com/vitejs/vite/blob/b93dfe3e08f56cafe2e549efd80285a12a3dc2f0/packages/vite/src/node/plugins/importAnalysisBuild.ts#L95-L98
+ const s = new MagicString(code);
+ s.replace(
+ `if (!__VITE_IS_MODERN__ || !deps || deps.length === 0) {`,
+ `if (!__VITE_IS_MODERN__ || !deps || deps.length === 0 || typeof window === 'undefined') {`,
+ );
+ // 5.0.5+
+ // https://github.com/vitejs/vite/blob/c902545476a4e7ba044c35b568e73683758178a3/packages/vite/src/node/plugins/importAnalysisBuild.ts#L93
+ s.replace(
+ `if (__VITE_IS_MODERN__ && deps && deps.length > 0) {`,
+ `if (__VITE_IS_MODERN__ && deps && deps.length > 0 && typeof window !== 'undefined') {`,
+ );
+ return {
+ code: s.toString(),
+ map: s.generateMap({ hires: true }),
+ };
+ }
+ },
+ async generateBundle(_opts, bundle) {
+ // @ts-ignore
+ globalThis.location = {};
+ // @ts-ignore
+ globalThis.self = globalThis;
+
+ // Local, fs-based fetch implementation for prerendering
+ const nodeFetch = globalThis.fetch;
+ // @ts-ignore
+ globalThis.fetch = async (url: string, opts: RequestInit | undefined) => {
+ if (/^\//.test(url)) {
+ const text = () =>
+ fs.readFile(
+ `${path.join(
+ viteConfig.root,
+ viteConfig.build.outDir,
+ )}/${url.replace(/^\//, "")}`,
+ "utf-8",
+ );
+ return { text, json: () => text().then(JSON.parse) };
+ }
+
+ return nodeFetch(url, opts);
+ };
+
+ // Grab the generated HTML file, which we'll use as a template:
+ const tpl = (bundle["index.html"] as OutputAsset).source as string;
+ let htmlDoc = htmlParse(tpl);
+
+ // Create a tmp dir to allow importing & consuming the built modules,
+ // before Rollup writes them to the disk
+ const tmpDir = path.join(
+ viteConfig.root,
+ "node_modules",
+ "@preact/preset-vite",
+ "headless-prerender",
+ );
+ try {
+ await fs.rm(tmpDir, { recursive: true });
+ } catch (e: any) {
+ if (e.code !== "ENOENT") throw e;
+ }
+ await fs.mkdir(tmpDir, { recursive: true });
+
+ await fs.writeFile(
+ path.join(tmpDir, "package.json"),
+ JSON.stringify({ type: "module" }),
+ );
+
+ let prerenderEntry;
+ for (const output of Object.keys(bundle)) {
+ if (!/\.js$/.test(output) || bundle[output].type !== "chunk") continue;
+
+ await fs.writeFile(
+ path.join(tmpDir, path.basename(output)),
+ (bundle[output] as OutputChunk).code,
+ );
+
+ if ((bundle[output] as OutputChunk).exports?.includes("prerender")) {
+ prerenderEntry = bundle[output];
+ }
+ }
+ if (!prerenderEntry) {
+ this.error("Cannot detect module with `prerender` export");
+ }
+
+ let head: Head = { lang: "", title: "", elements: new Set() };
+
+ let prerender;
+ try {
+ const m = await import(
+ `file://${path.join(tmpDir, path.basename(prerenderEntry!.fileName))}`
+ );
+ prerender = m.prerender;
+ } catch (e) {
+ const isReferenceError = e instanceof ReferenceError;
+
+ const message = `
+ ${e}
+
+ This ${
+ isReferenceError ? "is most likely" : "could be"
+ } caused by using DOM/Web APIs which are not available
+ available to the prerendering process which runs in Node. Consider
+ wrapping the offending code in a window check like so:
+
+ if (typeof window !== "undefined") {
+ // do something in browsers only
+ }
+ `.replace(/^\t{5}/gm, "");
+
+ this.error(message);
+ }
+
+ if (typeof prerender !== "function") {
+ this.error("Detected `prerender` export, but it is not a function");
+ }
+
+ // We start by pre-rendering the home page.
+ // Links discovered during pre-rendering get pushed into the list of routes.
+ const seen = new Set(["/", ...additionalPrerenderRoutes!]);
+
+ let routes: PrerenderedRoute[] = [...seen].map(link => ({ url: link }));
+
+ for (const route of routes) {
+ if (!route.url) continue;
+
+ const outDir = route.url.replace(/(^\/|\/$)/g, "");
+ const assetName = path.join(
+ outDir,
+ outDir.endsWith(".html") ? "" : "index.html",
+ );
+
+ // Update `location` to current URL so routers can use things like `location.pathname`
+ const u = new URL(route.url, "http://localhost");
+ for (const i in u) {
+ try {
+ // @ts-ignore
+ globalThis.location[i] = String(u[i]);
+ } catch {}
+ }
+
+ const result = await prerender({ ssr: true, url: route.url, route });
+ if (result == null) continue;
+
+ // Reset HTML doc & head data
+ htmlDoc = htmlParse(tpl);
+ head = { lang: "", title: "", elements: new Set() };
+
+ // Add any discovered links to the list of routes to pre-render:
+ if (result.links) {
+ for (let url of result.links) {
+ const parsed = new URL(url, "http://localhost");
+ url = parsed.pathname;
+ // ignore external links and ones we've already picked up
+ if (seen.has(url) || parsed.origin !== "http://localhost") continue;
+ seen.add(url);
+ routes.push({ url, _discoveredBy: route });
+ }
+ }
+
+ let body;
+ if (result && typeof result === "object") {
+ if (result.html) body = result.html;
+ if (result.head) {
+ head = result.head;
+ }
+ } else {
+ body = result;
+ }
+
+ const htmlHead = htmlDoc.querySelector("head");
+ if (htmlHead) {
+ if (head.title) {
+ const htmlTitle = htmlHead.querySelector("title");
+ htmlTitle
+ ? htmlTitle.set_content(enc(head.title))
+ : htmlHead.insertAdjacentHTML(
+ "afterbegin",
+ `${enc(head.title)} `,
+ );
+ }
+
+ if (head.lang) {
+ htmlDoc.querySelector("html")!.setAttribute("lang", enc(head.lang));
+ }
+
+ if (head.elements) {
+ // Inject HTML links at the end of for any stylesheets injected during rendering of the page:
+ htmlHead.insertAdjacentHTML(
+ "beforeend",
+ Array.from(
+ new Set(Array.from(head.elements).map(serializeElement)),
+ ).join("\n"),
+ );
+ }
+ }
+
+ const target = htmlDoc.querySelector(renderTarget!);
+ if (!target)
+ this.error(
+ result.renderTarget == "body"
+ ? "`renderTarget` was not specified in plugin options and does not exist in input HTML template"
+ : `Unable to detect prerender renderTarget "${result.selector}" in input HTML template`,
+ );
+ target.insertAdjacentHTML("afterbegin", body);
+
+ // Add generated HTML to compilation:
+ if (route.url === "/")
+ (bundle["index.html"] as OutputAsset).source = htmlDoc.toString();
+ else
+ this.emitFile({
+ type: "asset",
+ fileName: assetName,
+ source: htmlDoc.toString(),
+ });
+ }
+ },
+ };
+}
diff --git a/test/build.test.mjs b/test/build.test.mjs
index 9be24cf..2977033 100644
--- a/test/build.test.mjs
+++ b/test/build.test.mjs
@@ -1,6 +1,8 @@
import { execFile } from "node:child_process";
import { test } from "node:test";
import { promisify } from "node:util";
+import { promises as fs } from "node:fs";
+import assert from "node:assert";
import { dir } from "./util.mjs";
const execFileAsync = promisify(execFile);
@@ -12,4 +14,18 @@ test("builds demo successfully", async () => {
[dir("node_modules/vite/bin/vite.js"), "build"],
{ cwd: dir("demo"), encoding: "utf8" },
);
+
+ const outputHtml = await fs.readFile(dir("demo/dist/index.html"), "utf-8");
+ assert.match(outputHtml, /Get Started building Vite-powered Preact Apps/);
+
+ // Head API
+ assert.match(outputHtml, //);
+ assert.match(outputHtml, /Prerendered Preact App<\/title>/);
+ assert.match(outputHtml, / /);
+
+ // Local Fetch
+ assert.match(outputHtml, /Local fetch works/);
+
+ // `additionalPrerenderRoutes` config option
+ assert.doesNotThrow(async () => await fs.access(dir("demo/dist/404/index.html")));
});