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 += ``; + } + 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, /<meta name="description" content="This is a prerendered Preact app">/); + + // Local Fetch + assert.match(outputHtml, /Local fetch works/); + + // `additionalPrerenderRoutes` config option + assert.doesNotThrow(async () => await fs.access(dir("demo/dist/404/index.html"))); });