├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── README.md ├── jsconfig.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── index.js └── test ├── helpers.js ├── it-emits-html-file ├── App.svelte └── expected.html ├── it-puts-styles-before-html-by-default ├── App.svelte └── expected.html ├── it-respects-configure-export-option ├── App.svelte └── expected.html ├── it-respects-preprocess-html-css-options ├── App.svelte └── expected.html ├── it-respects-props ├── App.svelte └── expected.html ├── it-skips-emit └── App.svelte ├── it-throws-error-for-non-cjs-format └── App.svelte ├── it-throws-error-if-no-filename-passed └── App.svelte ├── it-uses-filename-function └── App.svelte └── tests.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/**/dist 3 | dist 4 | examples -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .editorconfig 3 | .prettierrc 4 | jsconfig.jsconfig -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "singleQuote": false, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "lts/*" 5 | 6 | notifications: 7 | email: true 8 | 9 | install: 10 | - npm ci 11 | 12 | stages: 13 | - test 14 | - release 15 | 16 | jobs: 17 | include: 18 | - stage: test 19 | name: "tests" 20 | script: 21 | - npm run test 22 | - stage: release 23 | name: "release" 24 | if: branch = master 25 | script: 26 | - npm run build 27 | - npm run semantic-release 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.renderWhitespace": "all", 4 | "editor.rulers": [80, 100] 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rollup-plugin-svelte-ssr 2 | 3 | [![Build Status](https://travis-ci.org/akaSybe/rollup-plugin-svelte-ssr.svg?branch=master)](https://travis-ci.org/akaSybe/rollup-plugin-svelte-ssr) 4 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 5 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 6 | 7 | Server-side rendering of Svelte app at build-time using Rollup plugin 8 | 9 | ## Basic example 10 | 11 | Let's assume that we have basic svelte component `src/App.svelte`: 12 | 13 | ```svelte 14 | 17 | 18 |
{name}
19 | 20 | 25 | ``` 26 | 27 | Let's use `rollup-plugin-svelte-ssr` in `rollup.config.js`: 28 | 29 | ```js 30 | // ... other imports 31 | 32 | import ssr from "rollup-plugin-svelte-ssr"; 33 | 34 | export default { 35 | input: "src/App.svelte", 36 | output: { 37 | format: "cjs", 38 | file: "dist/ssr.js" 39 | }, 40 | plugins: [ 41 | svelte({ 42 | generate: "ssr" 43 | }), 44 | 45 | // ... other plugins 46 | 47 | ssr({ 48 | fileName: 'ssr.html', 49 | props: { 50 | name: 'Hello', 51 | } 52 | }) 53 | ] 54 | } 55 | ``` 56 | 57 | In `dist` directory we get `ssr.html` that contains SSR-ed app: 58 | 59 | ```html 60 |
Hello
61 | ``` 62 | 63 | ## Options 64 | 65 | ```js 66 | ssr({ 67 | // allow to set output file name 68 | fileName: 'ssr.html', 69 | // or 70 | // where entry is Rollup entry 71 | fileName: function(entry) { 72 | return "ssr.html" 73 | } 74 | // root component props 75 | props: { 76 | name: 'Hello', 77 | }, 78 | // allow to skip emit of js file 79 | skipEmit: false, 80 | // allow to preprocess html 81 | preprocessHtml: function(html) { 82 | return html; 83 | }, 84 | // allow to preprocess css 85 | preprocessCss: function(css) { 86 | return css; 87 | }, 88 | // customize output 89 | configureExport: function(html, css) { 90 | return `${html}`; 91 | } 92 | }) 93 | ``` 94 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | "include": ["jest"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-svelte-ssr", 3 | "version": "1.0.3", 4 | "description": "Server-side rendering of Svelte app at build-time using Rollup plugin", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rollup -c", 8 | "watch": "rollup -c -w", 9 | "test": "jest", 10 | "semantic-release": "semantic-release", 11 | "cz": "git-cz" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/akaSybe/rollup-plugin-svelte-ssr.git" 16 | }, 17 | "keywords": [ 18 | "svelte", 19 | "ssr", 20 | "rollup-plugin" 21 | ], 22 | "author": "Aleksandr Shestakov", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/akaSybe/rollup-plugin-svelte-ssr/issues" 26 | }, 27 | "homepage": "https://github.com/akaSybe/rollup-plugin-svelte-ssr#readme", 28 | "dependencies": { 29 | "@semantic-release/git": "^7.0.16" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.6.4", 33 | "@babel/plugin-transform-runtime": "^7.6.2", 34 | "@babel/preset-env": "^7.6.3", 35 | "@babel/runtime": "^7.6.3", 36 | "@types/jest": "^24.0.18", 37 | "cz-conventional-changelog": "^3.0.2", 38 | "del": "^5.1.0", 39 | "husky": "^3.0.9", 40 | "jest": "^24.9.0", 41 | "prettier": "^1.18.2", 42 | "rollup": "^1.23.1", 43 | "rollup-plugin-commonjs": "^10.1.0", 44 | "rollup-plugin-filesize": "^6.2.0", 45 | "rollup-plugin-node-resolve": "^5.2.0", 46 | "rollup-plugin-progress": "^1.1.1", 47 | "rollup-plugin-svelte": "^5.1.0", 48 | "semantic-release": "^15.13.24", 49 | "svelte": "^3.12.1" 50 | }, 51 | "files": [ 52 | "dist" 53 | ], 54 | "jest": { 55 | "testMatch": [ 56 | "/test/**/tests.js" 57 | ], 58 | "moduleFileExtensions": [ 59 | "js" 60 | ] 61 | }, 62 | "config": { 63 | "commitizen": { 64 | "path": "./node_modules/cz-conventional-changelog" 65 | } 66 | }, 67 | "husky": { 68 | "hooks": { 69 | "prepare-commit-msg": "exec < /dev/tty && git cz --hook" 70 | } 71 | }, 72 | "release": { 73 | "plugins": [ 74 | "@semantic-release/commit-analyzer", 75 | "@semantic-release/release-notes-generator", 76 | "@semantic-release/npm", 77 | "@semantic-release/github", 78 | [ 79 | "@semantic-release/git", 80 | { 81 | "assets": [ 82 | "package.json" 83 | ], 84 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 85 | } 86 | ] 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import filesize from "rollup-plugin-filesize"; 3 | import progress from "rollup-plugin-progress"; 4 | import commonjs from "rollup-plugin-commonjs"; 5 | 6 | export default { 7 | input: "src/index.js", 8 | output: { 9 | file: "dist/index.js", 10 | format: "cjs", 11 | }, 12 | external: ["fs", "path", "vm"], 13 | plugins: [progress(), resolve(), commonjs(), filesize()], 14 | }; 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import vm from "vm"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | function wrapModuleExports(code) { 6 | return ` 7 | function getModuleExports() { 8 | const module = {}; 9 | 10 | ${code} 11 | 12 | return module.exports; 13 | } 14 | `; 15 | } 16 | 17 | function defaultExport(html, css) { 18 | const style = css ? `` : ""; 19 | return `${style}${html}`; 20 | } 21 | 22 | const defaultOptions = { 23 | /** do not emit SSR bundle */ 24 | skipEmit: false, 25 | configureExport: defaultExport, 26 | }; 27 | 28 | /** */ 29 | export default function ssr(options = {}) { 30 | const pluginOptions = { 31 | ...defaultOptions, 32 | ...options, 33 | }; 34 | 35 | if (!pluginOptions.fileName) { 36 | throw new Error("options.fileName should be string or function"); 37 | } 38 | 39 | return { 40 | name: "svelte-ssr", 41 | async generateBundle(config, bundle, isWrite) { 42 | if (config.format !== "cjs") { 43 | throw new Error("rollup-plugin-svelte-ssr can only be used with 'cjs'-format"); 44 | } 45 | 46 | const destPath = path.relative("./", config.file); 47 | const destDir = destPath.slice(0, destPath.lastIndexOf(path.sep)); 48 | 49 | Object.keys(bundle).forEach(async key => { 50 | const entry = bundle[key]; 51 | 52 | const sandbox = { 53 | ssr: { 54 | html: "", 55 | css: "", 56 | }, 57 | }; 58 | 59 | try { 60 | const props = JSON.stringify(pluginOptions.props, null, 2); 61 | const generateSsrScript = ` 62 | ${wrapModuleExports(entry.code)} 63 | const App = getModuleExports(); 64 | const { html, css } = App.render(${props}); 65 | ssr.html = html; 66 | ssr.css = css.code; 67 | `; 68 | const script = new vm.Script(generateSsrScript); 69 | script.runInNewContext(sandbox); 70 | } catch (e) { 71 | throw e; 72 | } 73 | 74 | const html = 75 | typeof pluginOptions.preprocessHtml === "function" 76 | ? pluginOptions.preprocessHtml(sandbox.ssr.html) 77 | : sandbox.ssr.html; 78 | 79 | const css = 80 | typeof pluginOptions.preprocessCss === "function" 81 | ? pluginOptions.preprocessCss(sandbox.ssr.css) 82 | : sandbox.ssr.css; 83 | 84 | const fileName = 85 | typeof pluginOptions.fileName === "function" 86 | ? pluginOptions.fileName(entry) 87 | : pluginOptions.fileName; 88 | 89 | const destination = path.resolve(destDir, fileName); 90 | fs.mkdirSync(path.dirname(destination), { recursive: true }); 91 | fs.writeFileSync(destination, pluginOptions.configureExport(html, css)); 92 | 93 | if (pluginOptions.skipEmit) { 94 | // You can prevent files from being emitted by deleting them from the bundle object. 95 | delete bundle[key]; 96 | } 97 | }); 98 | }, 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import { rollup } from "rollup"; 4 | import svelte from "rollup-plugin-svelte"; 5 | import resolve from "rollup-plugin-node-resolve"; 6 | import commonjs from "rollup-plugin-commonjs"; 7 | 8 | export function resolvePath(testName, fileName) { 9 | return path.resolve(__dirname, testName, fileName); 10 | } 11 | 12 | export function resolveExpectedFile(testName) { 13 | return resolvePath(testName, "expected.html"); 14 | } 15 | 16 | export async function bundleWithRollup({ plugin, pluginOptions, testName, output, format = "cjs" }) { 17 | const bundle = await rollup({ 18 | input: resolvePath(testName, "App.svelte"), 19 | plugins: [ 20 | svelte({ 21 | generate: "ssr", 22 | }), 23 | resolve({ 24 | browser: true, 25 | dedupe: importee => importee === "svelte" || importee.startsWith("svelte/"), 26 | }), 27 | commonjs(), 28 | plugin(pluginOptions), 29 | ], 30 | }); 31 | 32 | await bundle.write({ 33 | format, 34 | file: output, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /test/it-emits-html-file/App.svelte: -------------------------------------------------------------------------------- 1 |
Hello
2 | -------------------------------------------------------------------------------- /test/it-emits-html-file/expected.html: -------------------------------------------------------------------------------- 1 |
Hello
2 | -------------------------------------------------------------------------------- /test/it-puts-styles-before-html-by-default/App.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
Hello
8 | -------------------------------------------------------------------------------- /test/it-puts-styles-before-html-by-default/expected.html: -------------------------------------------------------------------------------- 1 |
Hello
-------------------------------------------------------------------------------- /test/it-respects-configure-export-option/App.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
Hello
8 | -------------------------------------------------------------------------------- /test/it-respects-configure-export-option/expected.html: -------------------------------------------------------------------------------- 1 |
Hello
-------------------------------------------------------------------------------- /test/it-respects-preprocess-html-css-options/App.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
Hello
8 | -------------------------------------------------------------------------------- /test/it-respects-preprocess-html-css-options/expected.html: -------------------------------------------------------------------------------- 1 |
replaced
-------------------------------------------------------------------------------- /test/it-respects-props/App.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
Hello, {name}
6 | -------------------------------------------------------------------------------- /test/it-respects-props/expected.html: -------------------------------------------------------------------------------- 1 |
Hello, world
2 | -------------------------------------------------------------------------------- /test/it-skips-emit/App.svelte: -------------------------------------------------------------------------------- 1 |
Hello
2 | -------------------------------------------------------------------------------- /test/it-throws-error-for-non-cjs-format/App.svelte: -------------------------------------------------------------------------------- 1 |
Hello
2 | -------------------------------------------------------------------------------- /test/it-throws-error-if-no-filename-passed/App.svelte: -------------------------------------------------------------------------------- 1 |
Hello
2 | -------------------------------------------------------------------------------- /test/it-uses-filename-function/App.svelte: -------------------------------------------------------------------------------- 1 |
Hello
2 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import del from "del"; 3 | 4 | import { bundleWithRollup, resolvePath, resolveExpectedFile } from "./helpers"; 5 | 6 | import plugin from "../src"; 7 | 8 | function readFile(fileName) { 9 | return fs.readFileSync(fileName, { encoding: "utf-8" }); 10 | } 11 | 12 | function expectHtmlEqual(actual, expected) { 13 | expect(actual.trim()).toEqual(expected.trim()); 14 | } 15 | 16 | describe("plugin tests", () => { 17 | it("should emit html file", async () => { 18 | const testName = "it-emits-html-file"; 19 | const outputFileName = `dist/${testName}.html`; 20 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 21 | const ssrOutputFile = resolvePath(testName, outputFileName); 22 | 23 | const pluginOptions = { 24 | fileName: ssrOutputFile, 25 | }; 26 | 27 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 28 | 29 | expect(fs.existsSync(ssrBundleFile)).toBeTruthy(); 30 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 31 | 32 | const expected = readFile(resolveExpectedFile(testName)); 33 | const actual = readFile(ssrOutputFile); 34 | 35 | expectHtmlEqual(actual, expected); 36 | 37 | // cleanup 38 | await del(resolvePath(testName, "dist")); 39 | }); 40 | 41 | it("should respect props", async () => { 42 | const testName = "it-respects-props"; 43 | const outputFileName = `dist/${testName}.html`; 44 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 45 | const ssrOutputFile = resolvePath(testName, outputFileName); 46 | 47 | const pluginOptions = { 48 | fileName: ssrOutputFile, 49 | props: { 50 | name: "world", 51 | }, 52 | }; 53 | 54 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 55 | 56 | expect(fs.existsSync(ssrBundleFile)).toBeTruthy(); 57 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 58 | 59 | const expected = readFile(resolveExpectedFile(testName)); 60 | const actual = readFile(ssrOutputFile); 61 | 62 | expectHtmlEqual(actual, expected); 63 | 64 | // cleanup 65 | await del(resolvePath(testName, "dist")); 66 | }); 67 | 68 | it("should skip emit bundle if skipEmit=true", async () => { 69 | const testName = "it-skips-emit"; 70 | const outputFileName = `dist/${testName}.html`; 71 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 72 | const ssrOutputFile = resolvePath(testName, outputFileName); 73 | 74 | const pluginOptions = { 75 | fileName: ssrOutputFile, 76 | skipEmit: true, 77 | }; 78 | 79 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 80 | 81 | expect(fs.existsSync(ssrBundleFile)).toBeFalsy(); 82 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 83 | 84 | // cleanup 85 | await del(resolvePath(testName, "dist")); 86 | }); 87 | 88 | it("should use filename function option", async () => { 89 | const testName = "it-uses-filename-function"; 90 | const outputFileName = `dist/test.html`; 91 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 92 | const ssrOutputFile = resolvePath(testName, outputFileName); 93 | 94 | const pluginOptions = { 95 | fileName: function(file) { 96 | return ssrOutputFile; 97 | }, 98 | }; 99 | 100 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 101 | 102 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 103 | 104 | // cleanup 105 | await del(resolvePath(testName, "dist")); 106 | }); 107 | 108 | it("should place styles before markup by default", async () => { 109 | const testName = "it-puts-styles-before-html-by-default"; 110 | const outputFileName = `dist/test.html`; 111 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 112 | const ssrOutputFile = resolvePath(testName, outputFileName); 113 | 114 | const pluginOptions = { 115 | fileName: ssrOutputFile, 116 | }; 117 | 118 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 119 | 120 | expect(fs.existsSync(ssrBundleFile)).toBeTruthy(); 121 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 122 | 123 | const expected = readFile(resolveExpectedFile(testName)); 124 | const actual = readFile(ssrOutputFile); 125 | 126 | expectHtmlEqual(actual, expected); 127 | 128 | // cleanup 129 | await del(resolvePath(testName, "dist")); 130 | }); 131 | 132 | it("should use configureExport option", async () => { 133 | const testName = "it-respects-configure-export-option"; 134 | const outputFileName = `dist/test.html`; 135 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 136 | const ssrOutputFile = resolvePath(testName, outputFileName); 137 | 138 | const pluginOptions = { 139 | fileName: ssrOutputFile, 140 | configureExport: function(html, css) { 141 | return `${html}`; 142 | }, 143 | }; 144 | 145 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 146 | 147 | expect(fs.existsSync(ssrBundleFile)).toBeTruthy(); 148 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 149 | 150 | const expected = readFile(resolveExpectedFile(testName)); 151 | const actual = readFile(ssrOutputFile); 152 | 153 | expectHtmlEqual(actual, expected); 154 | 155 | // cleanup 156 | await del(resolvePath(testName, "dist")); 157 | }); 158 | 159 | it("should use preprocessHtml/preprocessCss options", async () => { 160 | const testName = "it-respects-preprocess-html-css-options"; 161 | const outputFileName = `dist/test.html`; 162 | const ssrBundleFile = resolvePath(testName, "dist/ssr.js"); 163 | const ssrOutputFile = resolvePath(testName, outputFileName); 164 | 165 | const pluginOptions = { 166 | fileName: ssrOutputFile, 167 | preprocessHtml: function(html) { 168 | return "
replaced
"; 169 | }, 170 | preprocessCss: function(css) { 171 | return "body { opacity: 0; }"; 172 | }, 173 | }; 174 | 175 | await bundleWithRollup({ plugin, pluginOptions, testName, output: ssrBundleFile }); 176 | 177 | expect(fs.existsSync(ssrBundleFile)).toBeTruthy(); 178 | expect(fs.existsSync(ssrOutputFile)).toBeTruthy(); 179 | 180 | const expected = readFile(resolveExpectedFile(testName)); 181 | const actual = readFile(ssrOutputFile); 182 | 183 | expectHtmlEqual(actual, expected); 184 | 185 | // cleanup 186 | await del(resolvePath(testName, "dist")); 187 | }); 188 | 189 | it("should throw error for non-cjs format", async () => { 190 | const pluginOptions = { 191 | fileName: "doesnt-matter.html", 192 | skipEmit: true, 193 | }; 194 | 195 | await expect( 196 | bundleWithRollup({ 197 | format: "esm", 198 | plugin, 199 | pluginOptions, 200 | testName: "it-throws-error-for-non-cjs-format", 201 | output: "doesnt-matter.html", 202 | }), 203 | ).rejects.toThrow(new Error("rollup-plugin-svelte-ssr can only be used with 'cjs'-format")); 204 | }); 205 | 206 | it("should throw error if no filename option has been passed", async () => { 207 | const pluginOptions = { 208 | skipEmit: true, 209 | }; 210 | 211 | let error; 212 | try { 213 | await bundleWithRollup({ 214 | plugin, 215 | pluginOptions, 216 | testName: "it-throws-error-if-no-filename-passed", 217 | output: "doesnt-matter.html", 218 | }); 219 | } catch (e) { 220 | error = e; 221 | } 222 | expect(error).toEqual(new Error("options.fileName should be string or function")); 223 | }); 224 | }); 225 | --------------------------------------------------------------------------------