├── .eslintignore ├── .eslintrc.cjs ├── .github ├── .gitignore ├── build-examples.js ├── dependabot.yml ├── pages │ └── .nojekyll └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── examples ├── cloudflare │ ├── .gitignore │ ├── .nvmrc │ ├── index.html │ ├── package.json │ ├── src │ │ ├── About.tsx │ │ ├── App.css │ │ ├── App.tsx │ │ ├── Counter.island.tsx │ │ ├── Expandable.island.tsx │ │ ├── Home.tsx │ │ ├── MediaQuery.island.tsx │ │ ├── Preview.tsx │ │ ├── ServerContent.tsx │ │ ├── StaticContent.lagoon.tsx │ │ ├── capri.svg │ │ ├── main.server.tsx │ │ └── main.tsx │ ├── tsconfig.json │ └── vite.config.ts ├── preact │ ├── .gitignore │ ├── capri.svg │ ├── index.html │ ├── package.json │ ├── src │ │ ├── About.tsx │ │ ├── App.css │ │ ├── App.tsx │ │ ├── AsyncData.tsx │ │ ├── Counter.island.tsx │ │ ├── Expandable.island.tsx │ │ ├── Home.tsx │ │ ├── MediaQuery.island.tsx │ │ ├── Preview.tsx │ │ ├── ServerContent.tsx │ │ ├── StaticContent.lagoon.tsx │ │ ├── capri.svg │ │ ├── main.server.tsx │ │ ├── main.tsx │ │ └── useFetch.tsx │ ├── tsconfig.json │ └── vite.config.ts ├── react │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src │ │ ├── About.tsx │ │ ├── AsyncData.tsx │ │ ├── Counter.island.tsx │ │ ├── Expandable.island.tsx │ │ ├── Home.tsx │ │ ├── MediaQuery.island.tsx │ │ ├── NotFound.tsx │ │ ├── Preview.tsx │ │ ├── ServerContent.tsx │ │ ├── StaticContent.lagoon.tsx │ │ ├── capri.svg │ │ ├── main.css │ │ ├── main.server.tsx │ │ ├── main.tsx │ │ └── routes.tsx │ ├── tsconfig.json │ └── vite.config.ts ├── solid │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src │ │ ├── About.tsx │ │ ├── App.css │ │ ├── App.tsx │ │ ├── AsyncData.tsx │ │ ├── Counter.island.tsx │ │ ├── Expandable.island.tsx │ │ ├── Home.tsx │ │ ├── MediaQuery.island.tsx │ │ ├── ServerContent.tsx │ │ ├── StaticContent.lagoon.tsx │ │ ├── capri.svg │ │ ├── main.server.tsx │ │ ├── main.tsx │ │ └── vite.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── svelte │ ├── .gitignore │ ├── capri.svg │ ├── index.html │ ├── package.json │ ├── src │ │ ├── About.svelte │ │ ├── Counter.island.svelte │ │ ├── Expandable.island.svelte │ │ ├── Home.svelte │ │ ├── MediaQuery.island.svelte │ │ ├── ServerContent.svelte │ │ ├── StaticContent.lagoon.svelte │ │ ├── capri.svg │ │ ├── global.css │ │ ├── main.server.ts │ │ ├── main.ts │ │ ├── router.ts │ │ └── svelte.d.ts │ ├── tsconfig.json │ └── vite.config.js ├── vercel │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src │ │ ├── About.tsx │ │ ├── App.css │ │ ├── App.tsx │ │ ├── Counter.island.tsx │ │ ├── Expandable.island.tsx │ │ ├── Home.tsx │ │ ├── MediaQuery.island.tsx │ │ ├── Preview.tsx │ │ ├── ServerContent.tsx │ │ ├── StaticContent.lagoon.tsx │ │ ├── capri.svg │ │ ├── main.server.tsx │ │ └── main.tsx │ ├── tsconfig.json │ ├── vercel.json │ └── vite.config.ts └── vue │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src │ ├── About.vue │ ├── App.vue │ ├── AsyncData.vue │ ├── Counter.island.vue │ ├── Expandable.island.vue │ ├── Home.vue │ ├── MediaQuery.island.vue │ ├── PreviewApp.vue │ ├── ServerContent.vue │ ├── StaticContent.lagoon.vue │ ├── capri.svg │ ├── global.css │ ├── main.server.ts │ ├── main.ts │ ├── router.ts │ └── vue.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── logo.svg ├── package-lock.json ├── package.json ├── packages ├── capri │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Template.test.ts │ │ ├── Template.ts │ │ ├── assets.ts │ │ ├── bundle.ts │ │ ├── context.ts │ │ ├── dev.ts │ │ ├── entry.ts │ │ ├── fsutils.ts │ │ ├── html.ts │ │ ├── index.ts │ │ ├── options.ts │ │ ├── polyfills.ts │ │ ├── prerender.ts │ │ ├── render.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ ├── virtual │ │ │ ├── client.ts │ │ │ ├── hydration.ts │ │ │ ├── ssr.ts │ │ │ └── virtual.d.ts │ │ ├── vite-plugin.ts │ │ ├── vite.d.ts │ │ └── wrapper.ts │ ├── ssr.d.ts │ └── tsconfig.json ├── cloudflare │ ├── README.md │ ├── files │ │ └── 404.html │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── middleware.ts │ │ ├── polyfill.ts │ │ └── worker.ts │ └── tsconfig.json ├── create │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── create-capri.ts │ │ ├── helpers │ │ │ ├── examples.ts │ │ │ ├── get-pkg-manager.ts │ │ │ ├── git.ts │ │ │ ├── install.ts │ │ │ ├── is-folder-empty.ts │ │ │ ├── is-online.ts │ │ │ ├── is-writeable.ts │ │ │ ├── make-dir.ts │ │ │ └── validate-pkg.ts │ │ └── index.ts │ └── tsconfig.json ├── preact │ ├── README.md │ ├── package.json │ ├── src │ │ ├── capri.d.ts │ │ ├── hydrate.ts │ │ ├── index.ts │ │ ├── island.server.jsx │ │ ├── lagoon.client.jsx │ │ ├── lagoon.server.jsx │ │ └── server.ts │ └── tsconfig.json ├── react-render-to-string │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── node.ts │ └── tsconfig.json ├── react │ ├── README.md │ ├── package.json │ ├── src │ │ ├── capri.d.ts │ │ ├── hydrate.ts │ │ ├── index.ts │ │ ├── island.server.jsx │ │ ├── lagoon.client.jsx │ │ ├── lagoon.server.jsx │ │ └── server.ts │ └── tsconfig.json ├── solid │ ├── README.md │ ├── package.json │ ├── src │ │ ├── capri.d.ts │ │ ├── hydrate.tsx │ │ ├── index.ts │ │ ├── island.server.jsx │ │ ├── jsx.d.ts │ │ ├── lagoon.client.jsx │ │ └── server.ts │ └── tsconfig.json ├── svelte │ ├── README.md │ ├── package.json │ ├── src │ │ ├── capri.d.ts │ │ ├── hydrate.ts │ │ ├── index.ts │ │ ├── island.server.js │ │ ├── lagoon.client.js │ │ ├── lagoon.server.js │ │ └── server.ts │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json ├── vercel │ ├── README.md │ ├── package.json │ ├── src │ │ ├── edge.ts │ │ ├── index.ts │ │ ├── isg.ts │ │ └── serverless.ts │ └── tsconfig.json └── vue │ ├── README.md │ ├── package.json │ ├── src │ ├── capri.d.ts │ ├── hydrate.ts │ ├── index.ts │ ├── island.server.js │ ├── lagoon.client.jsx │ ├── lagoon.server.jsx │ └── server.ts │ └── tsconfig.json ├── test ├── dom.ts ├── e2e.test.ts └── setup.ts ├── tsconfig.json └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib 3 | .vercel -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | worker: true, 7 | }, 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | ecmaVersion: 12, 11 | sourceType: "module", 12 | }, 13 | settings: { 14 | "import/parsers": { 15 | "@typescript-eslint/parser": [".ts", ".tsx"], 16 | }, 17 | }, 18 | extends: [ 19 | "eslint:recommended", 20 | "plugin:@typescript-eslint/recommended", 21 | "plugin:import/recommended", 22 | "plugin:import/typescript", 23 | ], 24 | plugins: ["@typescript-eslint", "simple-import-sort"], 25 | rules: { 26 | "@typescript-eslint/no-explicit-any": "off", // Sometimes it's okay to take shortcuts. Use responsibly! 27 | "@typescript-eslint/no-non-null-assertion": "off", // Sometimes we know more than the compiler. Use responsibly! 28 | "@typescript-eslint/no-unused-vars": [ 29 | "warn", 30 | { 31 | args: "none", // Unused args are fine. Helps future devs to know that they are there. 32 | ignoreRestSiblings: true, // This is a useful pattern to exclude properties from an object so we allow it. 33 | }, 34 | ], 35 | "@typescript-eslint/triple-slash-reference": "off", // We need them to surface our ambient module declarations. 36 | 37 | // Enforce extensions for all imports except for jsx/tsx as they will be handled by Vite anyways. 38 | // Unfortunately this doesn't work as we'd like: https://github.com/import-js/eslint-plugin-import/issues/2111 39 | "import/extensions": [ 40 | "error", 41 | "ignorePackages", 42 | { 43 | tsx: "never", 44 | jsx: "never", 45 | }, 46 | ], 47 | "import/no-unresolved": "off", // Also see https://github.com/import-js/eslint-plugin-import/issues/2111 48 | 49 | "simple-import-sort/imports": "error", // Set to "error" so that --fix will do its magic 50 | "simple-import-sort/exports": "error", // Set to "error" so that --fix will do its magic 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /.github/.gitignore: -------------------------------------------------------------------------------- 1 | pages/* 2 | !pages/.* -------------------------------------------------------------------------------- /.github/build-examples.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs/promises"); 2 | const path = require("path"); 3 | const { spawn } = require("child_process"); 4 | 5 | async function build() { 6 | //const dirs = await fs.readdir(path.resolve(__dirname, "../examples")); 7 | const dirs = ["react", "preact", "solid", "svelte", "vue"]; 8 | 9 | await Promise.all( 10 | dirs.map( 11 | (dir) => 12 | new Promise(async (resolve, reject) => { 13 | const cwd = path.resolve(__dirname, "../examples", dir); 14 | const src = path.join(cwd, "dist"); 15 | const dest = path.resolve(__dirname, "pages", dir); 16 | 17 | await fs.rm(dest, { recursive: true, force: true }); 18 | const child = spawn("npm", ["run", "build"], { 19 | cwd, 20 | stdio: "inherit", 21 | env: { 22 | ...process.env, 23 | BASE_URL: `/capri/${dir}/`, 24 | }, 25 | }); 26 | child.once("exit", async (code) => { 27 | if (code) reject(code); 28 | else { 29 | await fs.rename(src, dest); 30 | resolve(); 31 | } 32 | }); 33 | }), 34 | ), 35 | ); 36 | } 37 | 38 | build() 39 | .then(() => console.log("done.")) 40 | .catch(console.error); 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | target-branch: "next" 6 | # Disable version updates for npm dependencies 7 | open-pull-requests-limit: 0 8 | schedule: 9 | interval: "daily" 10 | - package-ecosystem: "npm" 11 | directory: "/packages/capri" 12 | target-branch: "next" 13 | open-pull-requests-limit: 0 14 | schedule: 15 | interval: "daily" 16 | - package-ecosystem: "npm" 17 | directory: "/packages/create" 18 | target-branch: "next" 19 | open-pull-requests-limit: 0 20 | schedule: 21 | interval: "daily" 22 | - package-ecosystem: "npm" 23 | directory: "/packages/preact" 24 | target-branch: "next" 25 | open-pull-requests-limit: 0 26 | schedule: 27 | interval: "daily" 28 | - package-ecosystem: "npm" 29 | directory: "/packages/react" 30 | target-branch: "next" 31 | open-pull-requests-limit: 0 32 | schedule: 33 | interval: "daily" 34 | - package-ecosystem: "npm" 35 | directory: "/packages/react-render-to-string" 36 | target-branch: "next" 37 | open-pull-requests-limit: 0 38 | schedule: 39 | interval: "daily" 40 | - package-ecosystem: "npm" 41 | directory: "/packages/solid" 42 | target-branch: "next" 43 | open-pull-requests-limit: 0 44 | schedule: 45 | interval: "daily" 46 | - package-ecosystem: "npm" 47 | directory: "/packages/svelte" 48 | target-branch: "next" 49 | open-pull-requests-limit: 0 50 | schedule: 51 | interval: "daily" 52 | - package-ecosystem: "npm" 53 | directory: "/examples/preact" 54 | target-branch: "next" 55 | open-pull-requests-limit: 0 56 | schedule: 57 | interval: "daily" 58 | - package-ecosystem: "npm" 59 | directory: "/examples/react" 60 | target-branch: "next" 61 | open-pull-requests-limit: 0 62 | schedule: 63 | interval: "daily" 64 | - package-ecosystem: "npm" 65 | directory: "/examples/solid" 66 | target-branch: "next" 67 | open-pull-requests-limit: 0 68 | schedule: 69 | interval: "daily" 70 | - package-ecosystem: "npm" 71 | directory: "/examples/svelte" 72 | target-branch: "next" 73 | open-pull-requests-limit: 0 74 | schedule: 75 | interval: "daily" 76 | -------------------------------------------------------------------------------- /.github/pages/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/capri-js/capri/aebc1391fc65eea0c3450e0976dff1a970c2e1d9/.github/pages/.nojekyll -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main, next] 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: "20.x" 16 | cache: "npm" 17 | - uses: actions/cache@v3 18 | with: 19 | path: ~/.npm 20 | key: npm-${{ hashFiles('**/package-lock.json') }} 21 | - run: npm install 22 | - run: npm run lint 23 | - run: npm run build 24 | - run: npm test 25 | - run: npm run examples 26 | - run: npm run release 27 | env: 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | - name: deploy examples 30 | if: github.ref == 'refs/heads/main' 31 | uses: JamesIves/github-pages-deploy-action@v4.3.3 32 | with: 33 | branch: gh-pages 34 | folder: .github/pages 35 | clean: true 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | lib/ 4 | dist/ 5 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | access=public -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | CHANGELOG.md 5 | .github/pages 6 | .vercel -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.preferences.importModuleSpecifierEnding": "js" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Felix Gnass 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Capri](logo.svg) 2 | 3 | Capri allows you to build static websites using a frontend framework of your choice ([React/Preact/Vue/Solid/Svelte](https://capri.build/docs/frameworks/)). 4 | 5 | ## Carbon-friendly 🌱 6 | 7 | By default, **zero KB** of JavaScript is shipped to the browser. 8 | 9 | You can sprinkle in client-side interactivity by turning some of your components into [islands](https://jasonformat.com/islands-architecture/). Capri will make sure, that only that part of your JavaScript is sent down the wire that is required to let these islands become interactive. 10 | 11 | ## Use what you already know 🎓 12 | 13 | With Capri, you don't have to learn any new APIs. In fact, Capri doesn't even have an API! Use your framework's regular ecosystem as if you were building a single page app and follow these two rules: 14 | 15 | 1. 📍 Pick a router that supports server-side rendering (pretty much all popular routing libraries do this). 16 | 2. 🏝️ If a component needs to become interactive, name it `*.island.*`. Capri will take care of the rest. 17 | 18 | 👉 Visit https://capri.build to get started. 19 | 20 | ## No lock-in 🔓 21 | 22 | Should you ever decide to remove Capri from your project, you will be left with a 100% working [Vite](https://vitejs.dev/) app. Of course, instead of pre-rendered static pages, the output will then be a regular SPA. 23 | 24 | ## Bonus: Live CMS previews 🔮 25 | 26 | When you [connect](https://capri.build/docs/integrations/) your Capri website to a headless CMS, you can take further advantage of Capri's architecture, as it allows you to generate a separate SPA version of your site that can be used to live-preview any content changes without requiring a build-step or server-side rendering. You can use cheap and energy efficient static file hosting and still get real-time previews right inside your CMS. 27 | 28 | # License 29 | 30 | MIT 31 | -------------------------------------------------------------------------------- /examples/cloudflare/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | functions/_middleware.js 5 | functions/_ssr.js -------------------------------------------------------------------------------- /examples/cloudflare/.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /examples/cloudflare/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/cloudflare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-react-cloudflare-site", 3 | "private": true, 4 | "description": "Capri on Cloudflare Pages example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@vitejs/plugin-react": "^4.2.1", 15 | "react": "^18.1.0", 16 | "react-dom": "^18.1.0", 17 | "react-render-to-string": "*", 18 | "react-router-dom": "^6.7.0" 19 | }, 20 | "devDependencies": { 21 | "@capri-js/cloudflare": "^1.2.0", 22 | "@capri-js/react": "^5.2.0", 23 | "@types/react": "^18.0.9", 24 | "@types/react-dom": "^18.0.4", 25 | "vite": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/cloudflare/src/About.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | export function About() { 4 | return ( 5 |
6 |

This page was server-rendered on {new Date().toLocaleString()}

7 |
8 | An since it does not contain any interactive islands, no JavaScript is 9 | shipped to the browser. 10 |
11 | Home 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /examples/cloudflare/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | button { 41 | font-family: inherit; 42 | font-size: 0.7em; 43 | padding: 0.5em 1em; 44 | background: #15992b; 45 | color: #fff; 46 | border: none; 47 | border-radius: 4px; 48 | } 49 | button:hover { 50 | background: #56c13f; 51 | } 52 | 53 | .counter { 54 | display: inline-flex; 55 | align-items: center; 56 | gap: 0.2em; 57 | } 58 | 59 | .expandable button::after { 60 | display: inline-block; 61 | margin-left: 0.5em; 62 | content: ">"; 63 | transition: all 0.2s ease-in-out; 64 | } 65 | .expandable[data-expanded="true"] > button::after { 66 | transform: rotateZ(90deg); 67 | } 68 | 69 | .expandable-content { 70 | overflow: hidden; 71 | margin: 0.5em 0; 72 | } 73 | [data-expanded="false"] > .expandable-content { 74 | height: 0; 75 | } 76 | 77 | a { 78 | color: inherit; 79 | } 80 | 81 | .box { 82 | padding: 1em; 83 | border: 1px solid #aaa; 84 | border-radius: 6px; 85 | margin: 1em 0; 86 | } 87 | 88 | .banner { 89 | background: #ff0f7f; 90 | color: #fff; 91 | padding: 0.5rem; 92 | text-transform: uppercase; 93 | font-size: 0.8rem; 94 | } 95 | -------------------------------------------------------------------------------- /examples/cloudflare/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | import { Suspense } from "react"; 4 | import { Route, Routes } from "react-router-dom"; 5 | 6 | import { About } from "./About"; 7 | import { Home } from "./Home"; 8 | import { Preview } from "./Preview.jsx"; 9 | 10 | export function App() { 11 | return ( 12 | 13 | 14 | } /> 15 | } /> 16 | } /> 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/cloudflare/src/Counter.island.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | type Props = { 4 | start?: number; 5 | }; 6 | 7 | export default function Counter({ start = 0 }: Props) { 8 | const [counter, setCounter] = useState(start); 9 | return ( 10 |
11 | 12 | {counter} 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/cloudflare/src/Expandable.island.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | 3 | import StaticContent from "./StaticContent.lagoon.jsx"; 4 | 5 | type Props = { 6 | title: string; 7 | children?: ReactNode; 8 | }; 9 | export default function Expandable({ title, children }: Props) { 10 | const [expanded, setExpanded] = useState(false); 11 | return ( 12 |
13 | 14 | This is static content inside an island. We call this a lagoon. 15 | 16 | 17 |
18 | 19 | This a second lagoon. Below you see the children that were passed to 20 | the Expandable island: 21 | 22 | {children} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /examples/cloudflare/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import CounterIsland from "./Counter.island.jsx"; 4 | import ExpandableIsland from "./Expandable.island.jsx"; 5 | import MediaQueryIsland from "./MediaQuery.island.jsx"; 6 | import { ServerContent } from "./ServerContent"; 7 | 8 | export function Home() { 9 | return ( 10 |
11 |

12 | Partial hydration with React and Capri 13 |

14 |
15 | This page was server-rendered on {new Date().toLocaleString()} 16 |
17 |
18 | Here is a simple counter: 19 |
20 |
21 | And here is another one, independent from the one above:{" "} 22 | 23 |
24 | 25 | This island receives children as prop. They are only rendered upon build 26 | time. 27 | 28 | The code for ServerContent won't show up in the client 29 | bundle. 30 | 31 | 32 | 33 | Link to another page 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /examples/cloudflare/src/MediaQuery.island.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const options = { 4 | media: "(max-width:500px)", 5 | }; 6 | 7 | export default function MediaQuery() { 8 | const [content, setContent] = useState( 9 | "Resize your browser below 500px to hydrate this island.", 10 | ); 11 | useEffect(() => { 12 | setContent("The island has been hydrated."); 13 | }, []); 14 | return
{content}
; 15 | } 16 | -------------------------------------------------------------------------------- /examples/cloudflare/src/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "react-router-dom"; 2 | 3 | /** 4 | * Handle preview requests like `/preview?slug=/about` by redirecting 5 | * to the given slug parameter. 6 | */ 7 | export function Preview() { 8 | const url = new URL(window.location.href); 9 | const slug = url.searchParams.get("slug") ?? "/"; 10 | return ; 11 | } 12 | 13 | /** 14 | * Component to display a banner when the site is viewed as SPA. 15 | */ 16 | export function PreviewBanner() { 17 | return
Preview Mode
; 18 | } 19 | -------------------------------------------------------------------------------- /examples/cloudflare/src/ServerContent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type Props = { 4 | children: ReactNode; 5 | }; 6 | 7 | export function ServerContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/cloudflare/src/StaticContent.lagoon.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type Props = { 4 | children?: ReactNode; 5 | }; 6 | 7 | export default function StaticContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/cloudflare/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/cloudflare/src/main.server.tsx: -------------------------------------------------------------------------------- 1 | import { RenderFunction, renderToString } from "@capri-js/react/server"; 2 | import { StrictMode } from "react"; 3 | import { StaticRouter } from "react-router-dom/server.js"; 4 | 5 | import { App } from "./App"; 6 | 7 | export const render: RenderFunction = async (url, context) => { 8 | context.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate=60"); 9 | return { 10 | "#app": await renderToString( 11 | 12 | 13 | 14 | 15 | , 16 | ), 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /examples/cloudflare/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | 5 | import { App } from "./App"; 6 | import { PreviewBanner } from "./Preview.jsx"; 7 | 8 | ReactDOM.createRoot(document.getElementById("app")!).render( 9 | 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /examples/cloudflare/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "module": "ES2020", 6 | "moduleResolution": "Node", 7 | "target": "ES2017", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": ["vite/client"], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "jsx": "preserve" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/cloudflare/vite.config.ts: -------------------------------------------------------------------------------- 1 | import cloudflare from "@capri-js/cloudflare"; 2 | import capri from "@capri-js/react"; 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig } from "vite"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | capri({ 10 | spa: "/preview", 11 | prerender: false, 12 | followLinks: false, 13 | target: cloudflare({ webStreamsPolyfill: true }), 14 | }), 15 | ], 16 | }); 17 | -------------------------------------------------------------------------------- /examples/preact/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /examples/preact/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/preact/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/preact/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-preact-site", 3 | "private": true, 4 | "description": "Capri Preact example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "preact": "^10.8.2", 15 | "wouter-preact": "^2.8.1" 16 | }, 17 | "devDependencies": { 18 | "@capri-js/preact": "^5.1.1", 19 | "@preact/preset-vite": "^2.8.1", 20 | "vite": "^4.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/preact/src/About.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "preact/compat"; 2 | import { Link } from "wouter-preact"; 3 | 4 | import { AsyncData } from "./AsyncData.jsx"; 5 | 6 | export function About() { 7 | return ( 8 |
9 |

10 | This page is completely static. Async data: 11 | loading...}> 12 | 13 | 14 |

15 |
16 | An since it does not contain any interactive islands, no JavaScript is 17 | shipped to the browser. 18 |
19 | 20 | Home 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /examples/preact/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | button { 41 | font-family: inherit; 42 | font-size: 0.7em; 43 | padding: 0.5em 1em; 44 | background: #15992b; 45 | color: #fff; 46 | border: none; 47 | border-radius: 4px; 48 | } 49 | button:hover { 50 | background: #56c13f; 51 | } 52 | 53 | .counter { 54 | display: inline-flex; 55 | align-items: center; 56 | gap: 0.2em; 57 | } 58 | 59 | .expandable button::after { 60 | display: inline-block; 61 | margin-left: 0.5em; 62 | content: ">"; 63 | transition: all 0.2s ease-in-out; 64 | } 65 | .expandable[data-expanded="true"] > button::after { 66 | transform: rotateZ(90deg); 67 | } 68 | 69 | .expandable-content { 70 | overflow: hidden; 71 | margin: 0.5em 0; 72 | } 73 | [data-expanded="false"] > .expandable-content { 74 | height: 0; 75 | } 76 | 77 | a { 78 | color: inherit; 79 | } 80 | 81 | .box { 82 | padding: 1em; 83 | border: 1px solid #aaa; 84 | border-radius: 6px; 85 | margin: 1em 0; 86 | } 87 | 88 | .banner { 89 | background: #ff0f7f; 90 | color: #fff; 91 | padding: 0.5rem; 92 | text-transform: uppercase; 93 | font-size: 0.8rem; 94 | } 95 | -------------------------------------------------------------------------------- /examples/preact/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | import { Route } from "wouter-preact"; 4 | 5 | import { About } from "./About"; 6 | import { Home } from "./Home"; 7 | import { Preview } from "./Preview"; 8 | 9 | export function App() { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /examples/preact/src/AsyncData.tsx: -------------------------------------------------------------------------------- 1 | import { useFetch } from "./useFetch.jsx"; 2 | 3 | export function AsyncData() { 4 | const data = useFetch("/data"); 5 | return {data}; 6 | } 7 | -------------------------------------------------------------------------------- /examples/preact/src/Counter.island.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | 3 | export default function Counter({ start = 0 }: { start?: number }) { 4 | const [counter, setCounter] = useState(start); 5 | return ( 6 |
7 | 8 | {counter} 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/preact/src/Expandable.island.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren } from "preact"; 2 | import { useState } from "preact/hooks"; 3 | 4 | import StaticContent from "./StaticContent.lagoon.jsx"; 5 | 6 | type Props = { 7 | title: string; 8 | children?: ComponentChildren; 9 | }; 10 | export default function Expandable({ title, children }: Props) { 11 | const [expanded, setExpanded] = useState(false); 12 | return ( 13 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /examples/preact/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "wouter-preact"; 2 | 3 | import CounterIsland from "./Counter.island.jsx"; 4 | import ExpandableIsland from "./Expandable.island.jsx"; 5 | import MediaQueryIsland from "./MediaQuery.island.jsx"; 6 | import { ServerContent } from "./ServerContent.jsx"; 7 | 8 | export function Home() { 9 | return ( 10 |
11 |

12 | Partial hydration with Preact and Capri 13 |

14 |
This page is static, but contains some dynamic parts.
15 |
16 | Here is a simple counter: 17 |
18 |
19 | And here is another one, independent from the one above:{" "} 20 | 21 |
22 | 23 | This island receives children as prop. They are only rendered upon build 24 | time. 25 | 26 | The code for ServerContent won't show up in the client 27 | bundle. 28 | 29 | 30 | 31 | 32 | Link to another page 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /examples/preact/src/MediaQuery.island.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | 3 | export const options = { 4 | media: "(max-width:500px)", 5 | }; 6 | 7 | export default function MediaQuery() { 8 | const [content, setContent] = useState( 9 | "Resize your browser below 500px to hydrate this island.", 10 | ); 11 | useEffect(() => { 12 | setContent("The island has been hydrated."); 13 | }, []); 14 | return
{content}
; 15 | } 16 | -------------------------------------------------------------------------------- /examples/preact/src/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect } from "wouter-preact"; 2 | 3 | /** 4 | * Handle preview requests like `/preview?slug=/about` by redirecting 5 | * to the given slug parameter. 6 | */ 7 | export function Preview() { 8 | const url = new URL(window.location.href); 9 | const slug = url.searchParams.get("slug") ?? "/"; 10 | return ; 11 | } 12 | 13 | /** 14 | * Component to display a banner when the site is viewed as SPA. 15 | */ 16 | export function PreviewBanner() { 17 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /examples/preact/src/ServerContent.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren } from "preact"; 2 | 3 | type Props = { 4 | children: ComponentChildren; 5 | }; 6 | 7 | export function ServerContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/preact/src/StaticContent.lagoon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren } from "preact"; 2 | 3 | type Props = { 4 | children?: ComponentChildren; 5 | }; 6 | 7 | export default function StaticContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/preact/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/preact/src/main.server.tsx: -------------------------------------------------------------------------------- 1 | import { RenderFunction, renderToString } from "@capri-js/preact/server"; 2 | import { Router } from "wouter-preact"; 3 | import staticLocationHook from "wouter-preact/static-location"; 4 | 5 | import { App } from "./App"; 6 | 7 | // Provide a base path. You only need this if you want to deploy your 8 | // site to a non-root directory. 9 | const base = import.meta.env.BASE_URL.slice(0, -1); 10 | 11 | export const render: RenderFunction = async (url: string) => { 12 | const hook = staticLocationHook(url.slice(base.length)); 13 | const html = await renderToString( 14 | 15 | 16 | , 17 | ); 18 | return { 19 | "#app": html, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /examples/preact/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import { Router } from "wouter-preact"; 3 | 4 | import { App } from "./App"; 5 | import { PreviewBanner } from "./Preview.jsx"; 6 | 7 | const base = import.meta.env.BASE_URL.slice(0, -1); 8 | 9 | render( 10 | 11 | 12 | 13 | , 14 | document.body, 15 | ); 16 | -------------------------------------------------------------------------------- /examples/preact/src/useFetch.tsx: -------------------------------------------------------------------------------- 1 | const promises = new Map(); 2 | const response = new Map(); 3 | 4 | const mockData: Record = { 5 | "/data": "loaded!", 6 | }; 7 | 8 | function mockFetch(url: string) { 9 | return new Promise((resolve) => 10 | setTimeout(() => resolve(mockData[url]), 100), 11 | ); 12 | } 13 | 14 | export function useFetch(url: string) { 15 | const data = response.get(url); 16 | if (data) return data; 17 | let promise = promises.get(url); 18 | if (!promise) { 19 | promise = mockFetch(url).then((res) => response.set(url, res)); 20 | promises.set(url, promise); 21 | } 22 | throw promise; 23 | } 24 | -------------------------------------------------------------------------------- /examples/preact/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "target": "ES2017", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": ["vite/client"], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "jsx": "preserve", 13 | "jsxImportSource": "preact" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/preact/vite.config.ts: -------------------------------------------------------------------------------- 1 | import capri from "@capri-js/preact"; 2 | import preact from "@preact/preset-vite"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | preact(), 8 | capri({ 9 | spa: "/preview", 10 | }), 11 | ], 12 | }); 13 | -------------------------------------------------------------------------------- /examples/react/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /examples/react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-react-site", 3 | "private": true, 4 | "description": "Capri react example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@vitejs/plugin-react": "^4.2.1", 15 | "react": "^18.1.0", 16 | "react-dom": "^18.1.0", 17 | "react-router-dom": "^6.7.0" 18 | }, 19 | "devDependencies": { 20 | "@capri-js/react": "^5.2.0", 21 | "@types/react": "^18.2.46", 22 | "@types/react-dom": "^18.0.4", 23 | "vite": "^4.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/react/src/About.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import { AsyncData } from "./AsyncData.jsx"; 5 | 6 | export function About() { 7 | return ( 8 |
9 |

10 | This page is completely static. Async data:{" "} 11 | loading...}> 12 | 13 | 14 |

15 |
16 | An since it does not contain any interactive islands, no JavaScript is 17 | shipped to the browser. 18 |
19 | Home 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /examples/react/src/AsyncData.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "react-router-dom"; 2 | 3 | export function AsyncData() { 4 | const data = useLoaderData() as string; 5 | console.log("Data from loader", data); 6 | return {data}; 7 | } 8 | -------------------------------------------------------------------------------- /examples/react/src/Counter.island.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | type Props = { 4 | start?: number; 5 | }; 6 | 7 | export default function Counter({ start = 0 }: Props) { 8 | const [counter, setCounter] = useState(start); 9 | return ( 10 |
11 | 12 | {counter} 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/react/src/Expandable.island.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | 3 | import StaticContent from "./StaticContent.lagoon.jsx"; 4 | 5 | type Props = { 6 | title: string; 7 | children?: ReactNode; 8 | }; 9 | export default function Expandable({ title, children }: Props) { 10 | const [expanded, setExpanded] = useState(false); 11 | return ( 12 |
13 | 14 | This is static content inside an island. We call this a lagoon. 15 | 16 | 17 |
18 | 19 | This a second lagoon. Below you see the children that were passed to 20 | the Expandable island: 21 | 22 | {children} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /examples/react/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import CounterIsland from "./Counter.island.jsx"; 4 | import ExpandableIsland from "./Expandable.island.jsx"; 5 | import MediaQueryIsland from "./MediaQuery.island.jsx"; 6 | import { ServerContent } from "./ServerContent"; 7 | 8 | export function Home() { 9 | return ( 10 |
11 |

12 | Partial hydration with React and Capri 13 |

14 |
This page is static, but contains some dynamic parts.
15 |
16 | Here is a simple counter: 17 |
18 |
19 | And here is another one, independent from the one above:{" "} 20 | 21 |
22 | 23 | This island receives children as prop. They are only rendered upon build 24 | time. 25 | 26 | The code for ServerContent won't show up in the client 27 | bundle. 28 | 29 | 30 | 31 | Link to another page 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/react/src/MediaQuery.island.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const options = { 4 | media: "(max-width:500px)", 5 | }; 6 | 7 | export default function MediaQuery() { 8 | const [content, setContent] = useState( 9 | "Resize your browser below 500px to hydrate this island.", 10 | ); 11 | useEffect(() => { 12 | setContent("The island has been hydrated."); 13 | }, []); 14 | return
{content}
; 15 | } 16 | -------------------------------------------------------------------------------- /examples/react/src/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { useStatus } from "@capri-js/react/server"; 2 | 3 | export function NotFound() { 4 | useStatus(404); 5 | return ( 6 |
7 |

404 - Not found.

8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /examples/react/src/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "react-router-dom"; 2 | 3 | /** 4 | * Handle preview requests like `/preview?slug=/about` by redirecting 5 | * to the given slug parameter. 6 | */ 7 | export function Preview() { 8 | const url = new URL(window.location.href); 9 | const slug = url.searchParams.get("slug") ?? "/"; 10 | return ; 11 | } 12 | 13 | /** 14 | * Component to display a banner when the site is viewed as SPA. 15 | */ 16 | export function PreviewBanner() { 17 | return
Preview Mode
; 18 | } 19 | -------------------------------------------------------------------------------- /examples/react/src/ServerContent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type Props = { 4 | children: ReactNode; 5 | }; 6 | 7 | export function ServerContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/react/src/StaticContent.lagoon.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type Props = { 4 | children?: ReactNode; 5 | }; 6 | 7 | export default function StaticContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/react/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react/src/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | button { 41 | font-family: inherit; 42 | font-size: 0.7em; 43 | padding: 0.5em 1em; 44 | background: #15992b; 45 | color: #fff; 46 | border: none; 47 | border-radius: 4px; 48 | } 49 | button:hover { 50 | background: #56c13f; 51 | } 52 | 53 | .counter { 54 | display: inline-flex; 55 | align-items: center; 56 | gap: 0.2em; 57 | } 58 | 59 | .expandable button::after { 60 | display: inline-block; 61 | margin-left: 0.5em; 62 | content: ">"; 63 | transition: all 0.2s ease-in-out; 64 | } 65 | .expandable[data-expanded="true"] > button::after { 66 | transform: rotateZ(90deg); 67 | } 68 | 69 | .expandable-content { 70 | overflow: hidden; 71 | margin: 0.5em 0; 72 | } 73 | [data-expanded="false"] > .expandable-content { 74 | height: 0; 75 | } 76 | 77 | a { 78 | color: inherit; 79 | } 80 | 81 | .box { 82 | padding: 1em; 83 | border: 1px solid #aaa; 84 | border-radius: 6px; 85 | margin: 1em 0; 86 | } 87 | 88 | .banner { 89 | background: #ff0f7f; 90 | color: #fff; 91 | padding: 0.5rem; 92 | text-transform: uppercase; 93 | font-size: 0.8rem; 94 | } 95 | -------------------------------------------------------------------------------- /examples/react/src/main.server.tsx: -------------------------------------------------------------------------------- 1 | import "./main.css"; 2 | 3 | import { RenderFunction, renderToString } from "@capri-js/react/server"; 4 | import { StrictMode } from "react"; 5 | import { createMemoryRouter, RouterProvider } from "react-router-dom"; 6 | 7 | import { routes } from "./routes.jsx"; 8 | 9 | export const render: RenderFunction = async (url, context) => { 10 | const router = createMemoryRouter(routes, { 11 | basename: import.meta.env.BASE_URL, 12 | initialEntries: [url], 13 | }); 14 | 15 | // Wait until the data is loaded ... 16 | await isInitialized(router); 17 | 18 | const root = ( 19 | 20 | 21 | 22 | ); 23 | return { 24 | "#app": renderToString(root, context), 25 | }; 26 | }; 27 | 28 | function isInitialized(router: ReturnType) { 29 | return new Promise((resolve) => { 30 | if (router.state.initialized) return resolve(); 31 | router.subscribe((state) => { 32 | if (state.initialized) resolve(); 33 | }); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /examples/react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./main.css"; 2 | 3 | import { StrictMode } from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 6 | 7 | import { PreviewBanner } from "./Preview.jsx"; 8 | import { routes } from "./routes.jsx"; 9 | 10 | const router = createBrowserRouter(routes, { 11 | basename: import.meta.env.BASE_URL, 12 | }); 13 | 14 | function App() { 15 | return ( 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | ReactDOM.createRoot(document.getElementById("app")!).render(); 24 | -------------------------------------------------------------------------------- /examples/react/src/routes.tsx: -------------------------------------------------------------------------------- 1 | import { RouteObject } from "react-router-dom"; 2 | 3 | import { About } from "./About.jsx"; 4 | import { Home } from "./Home.jsx"; 5 | import { NotFound } from "./NotFound.jsx"; 6 | import { Preview } from "./Preview.jsx"; 7 | 8 | export const routes: RouteObject[] = [ 9 | { path: "/", index: true, element: }, 10 | { 11 | path: "/about", 12 | element: , 13 | loader: () => 14 | new Promise((resolve) => { 15 | setTimeout(() => resolve("Loaded."), 250); 16 | }), 17 | }, 18 | { path: "/preview", element: }, 19 | { path: "*", element: }, 20 | ]; 21 | -------------------------------------------------------------------------------- /examples/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "module": "ES2020", 6 | "moduleResolution": "Node", 7 | "target": "ES2017", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": ["vite/client"], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "jsx": "preserve" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import capri from "@capri-js/react"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react(), 8 | capri({ 9 | spa: "/preview", 10 | }), 11 | ], 12 | }); 13 | -------------------------------------------------------------------------------- /examples/solid/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /examples/solid/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/solid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-solid-site", 3 | "private": true, 4 | "description": "Capri SolidJS example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@solidjs/router": "^0.5.1", 15 | "solid-js": "^1.6.5" 16 | }, 17 | "devDependencies": { 18 | "@capri-js/solid": "^5.1.1", 19 | "vite": "^4.0.0", 20 | "vite-plugin-solid": "^2.5.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/solid/src/About.tsx: -------------------------------------------------------------------------------- 1 | import { A } from "@solidjs/router"; 2 | import { Suspense } from "solid-js"; 3 | 4 | import { AsyncData } from "./AsyncData.jsx"; 5 | 6 | export function About() { 7 | return ( 8 |
9 |

10 | This page is completely static. Async data: 11 | loading...}> 12 | 13 | 14 |

15 |
16 | An since it does not contain any interactive islands, no JavaScript is 17 | shipped to the browser. 18 |
19 | Home 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /examples/solid/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | button { 41 | font-family: inherit; 42 | font-size: 0.7em; 43 | padding: 0.5em 1em; 44 | background: #15992b; 45 | color: #fff; 46 | border: none; 47 | border-radius: 4px; 48 | } 49 | button:hover { 50 | background: #56c13f; 51 | } 52 | 53 | .counter { 54 | display: inline-flex; 55 | align-items: center; 56 | gap: 0.2em; 57 | } 58 | 59 | .expandable button::after { 60 | display: inline-block; 61 | margin-left: 0.5em; 62 | content: ">"; 63 | transition: all 0.2s ease-in-out; 64 | } 65 | .expandable[data-expanded="true"] > button::after { 66 | transform: rotateZ(90deg); 67 | } 68 | 69 | .expandable-content { 70 | overflow: hidden; 71 | margin: 0.5em 0; 72 | } 73 | [data-expanded="false"] > .expandable-content { 74 | height: 0; 75 | } 76 | 77 | a { 78 | color: inherit; 79 | } 80 | 81 | .box { 82 | padding: 1em; 83 | border: 1px solid #aaa; 84 | border-radius: 6px; 85 | margin: 1em 0; 86 | } 87 | 88 | .banner { 89 | background: #ff0f7f; 90 | color: #fff; 91 | padding: 0.5rem; 92 | text-transform: uppercase; 93 | font-size: 0.8rem; 94 | } 95 | -------------------------------------------------------------------------------- /examples/solid/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | import { Route, Routes } from "@solidjs/router"; 4 | import { NoHydration } from "solid-js/web"; 5 | 6 | import { About } from "./About"; 7 | import { Home } from "./Home"; 8 | 9 | export function App() { 10 | return ( 11 | 12 | 13 | } /> 14 | } /> 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /examples/solid/src/AsyncData.tsx: -------------------------------------------------------------------------------- 1 | import { createResource } from "solid-js"; 2 | 3 | function fetchData() { 4 | return new Promise((resolve) => 5 | setTimeout(() => resolve("loaded!"), 500), 6 | ); 7 | } 8 | 9 | export function AsyncData() { 10 | const [data] = createResource(fetchData); 11 | return {data}; 12 | } 13 | -------------------------------------------------------------------------------- /examples/solid/src/Counter.island.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js"; 2 | 3 | type Props = { 4 | start?: number; 5 | }; 6 | 7 | export default function Counter({ start = 0 }: Props) { 8 | const [counter, setCounter] = createSignal(start); 9 | return ( 10 |
11 | 12 | {counter()} 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/solid/src/Expandable.island.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from "solid-js"; 2 | 3 | import StaticContent from "./StaticContent.lagoon.jsx"; 4 | 5 | type Props = { 6 | title: string; 7 | children?: any; 8 | }; 9 | 10 | export default function Expandable({ title, children }: Props) { 11 | const [expanded, setExpanded] = createSignal(false); 12 | return ( 13 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /examples/solid/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { A } from "@solidjs/router"; 2 | 3 | import CounterIsland from "./Counter.island"; 4 | import ExpandableIsland from "./Expandable.island.jsx"; 5 | import MediaQueryIsland from "./MediaQuery.island.jsx"; 6 | import { ServerContent } from "./ServerContent.jsx"; 7 | 8 | export function Home() { 9 | return ( 10 |
11 |

12 | Partial hydration with SolidJS and Capri 13 |

14 |
This page is static, but contains some dynamic parts.
15 |
16 | This counter is an interactive island: 17 |
18 |
19 | And here is another one, independent from the one above:{" "} 20 | 21 |
22 | 23 | This island receives children as prop. They are only rendered upon build 24 | time. 25 | 26 | The code for ServerContent won't show up in the client 27 | bundle. 28 | 29 | 30 | 31 | Link to another page 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/solid/src/MediaQuery.island.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, onMount } from "solid-js"; 2 | 3 | export const options = { 4 | media: "(max-width:500px)", 5 | }; 6 | 7 | export default function MediaQuery() { 8 | const [content, setContent] = createSignal( 9 | "Resize your browser below 500px to hydrate this island.", 10 | ); 11 | onMount(() => { 12 | setContent("The island has been hydrated."); 13 | }); 14 | return
{content}
; 15 | } 16 | -------------------------------------------------------------------------------- /examples/solid/src/ServerContent.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: any; 3 | }; 4 | 5 | export function ServerContent({ children }: Props) { 6 | if (!children) { 7 | throw new Error("TEST: THIS CODE MUST NOT SHOW UP IN THE CLIENT BUNDLE"); 8 | } 9 | return
{children}
; 10 | } 11 | -------------------------------------------------------------------------------- /examples/solid/src/StaticContent.lagoon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children?: any; 3 | }; 4 | 5 | export default function StaticContent({ children }: Props) { 6 | return
{children}
; 7 | } 8 | -------------------------------------------------------------------------------- /examples/solid/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/solid/src/main.server.tsx: -------------------------------------------------------------------------------- 1 | import { RenderFunction, renderToString } from "@capri-js/solid/server"; 2 | import { Router } from "@solidjs/router"; 3 | import { generateHydrationScript } from "solid-js/web"; 4 | 5 | import { App } from "./App"; 6 | 7 | export const render: RenderFunction = async (url: string) => { 8 | const html = await renderToString(() => ( 9 | 10 | 11 | 12 | )); 13 | return { 14 | "#app": html, 15 | body: generateHydrationScript(), 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /examples/solid/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { Router } from "@solidjs/router"; 2 | import { render } from "solid-js/web"; 3 | 4 | import { App } from "./App"; 5 | 6 | render( 7 | () => ( 8 | 9 | 10 | 11 | ), 12 | document.getElementById("app")!, 13 | ); 14 | -------------------------------------------------------------------------------- /examples/solid/src/vite.d.ts: -------------------------------------------------------------------------------- 1 | import "vite"; 2 | 3 | declare module "vite" { 4 | export interface UserConfig { 5 | ssr?: SSROptions; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/solid/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "jsxImportSource": "solid-js", 11 | "types": ["vite/client"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/solid/vite.config.ts: -------------------------------------------------------------------------------- 1 | import capri from "@capri-js/solid"; 2 | import { defineConfig } from "vite"; 3 | import solid from "vite-plugin-solid"; 4 | 5 | export default defineConfig({ 6 | ssr: { 7 | // In order to make solid-app-router work in SSR mode we have to 8 | // prevent it from being externalized ... 9 | noExternal: ["solid-app-router"], 10 | }, 11 | plugins: [ 12 | solid({ 13 | ssr: true, 14 | }), 15 | capri({ 16 | spa: "/preview", 17 | }), 18 | ], 19 | }); 20 | -------------------------------------------------------------------------------- /examples/svelte/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /examples/svelte/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/svelte/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-svelte-site", 3 | "private": true, 4 | "description": "Capri Svelte example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@capri-js/svelte": "^2.1.1", 15 | "svelte-pilot": "^0.3.1", 16 | "svelte-preprocess": "^4.10.7" 17 | }, 18 | "devDependencies": { 19 | "@sveltejs/vite-plugin-svelte": "^2.0.2", 20 | "svelte": "^3.48.0", 21 | "vite": "^4.0.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/svelte/src/About.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | 30 |
31 |

This page is completely static.

32 |
33 | An since it does not contain any interactive islands, no JavaScript is 34 | shipped to the browser. Async data: {ssrState} 35 |
36 | Home 37 |
38 | -------------------------------------------------------------------------------- /examples/svelte/src/Counter.island.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 | {counter} 8 | 9 |
10 | 11 | 17 | -------------------------------------------------------------------------------- /examples/svelte/src/Expandable.island.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 | 12 |
13 | 14 | This a second lagoon. Below you see the body slot that was passed to the 15 | Expandable island: 16 | 17 | 18 |
19 |
20 | 21 | 42 | -------------------------------------------------------------------------------- /examples/svelte/src/Home.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |

11 | Partial hydration with Svelte and Capri 12 |

13 |
This page is static, but contains some dynamic parts.
14 |
15 | Here is a simple counter: 16 |
17 |
18 | And here is another one, independent from the one above: 21 |
22 | 23 | Click to expand 24 |
25 | 26 | The code for ServerContent won't show up in the client bundle. 27 | 28 |
29 |
30 | 31 | Link to another page 32 |
33 | -------------------------------------------------------------------------------- /examples/svelte/src/MediaQuery.island.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 |
16 | {content} 17 |
18 | -------------------------------------------------------------------------------- /examples/svelte/src/ServerContent.svelte: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /examples/svelte/src/StaticContent.lagoon.svelte: -------------------------------------------------------------------------------- 1 |
2 | This is static content inside an island. We call this a lagoon. 3 |
4 | -------------------------------------------------------------------------------- /examples/svelte/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/svelte/src/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | button { 41 | font-family: inherit; 42 | font-size: 0.7em; 43 | padding: 0.5em 1em; 44 | background: #15992b; 45 | color: #fff; 46 | border: none; 47 | border-radius: 4px; 48 | } 49 | button:hover { 50 | background: #56c13f; 51 | } 52 | 53 | a { 54 | color: inherit; 55 | } 56 | 57 | .box { 58 | padding: 1em; 59 | border: 1px solid #aaa; 60 | border-radius: 6px; 61 | margin: 1em 0; 62 | } 63 | 64 | .banner { 65 | background: #ff0f7f; 66 | color: #fff; 67 | padding: 0.5rem; 68 | text-transform: uppercase; 69 | font-size: 0.8rem; 70 | } 71 | -------------------------------------------------------------------------------- /examples/svelte/src/main.server.ts: -------------------------------------------------------------------------------- 1 | import { RenderFunction } from "@capri-js/svelte/server"; 2 | import { ServerApp } from "svelte-pilot"; 3 | 4 | import router from "./router.js"; 5 | 6 | export const render: RenderFunction = async (url: string) => { 7 | const matched = await router.handle(`http://127.0.0.1${url}`); 8 | if (!matched) throw new Error(`No matching route: ${url}`); 9 | const { route, ssrState } = matched; 10 | const { head, html } = ServerApp.render({ router, route, ssrState }); 11 | return { 12 | head, 13 | body: html, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/svelte/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | 3 | import { ClientApp } from "svelte-pilot"; 4 | 5 | import router from "./router.js"; 6 | 7 | new ClientApp({ 8 | target: document.body, 9 | props: { 10 | router, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /examples/svelte/src/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "svelte-pilot"; 2 | 3 | import * as About from "./About.svelte"; 4 | import * as Home from "./Home.svelte"; 5 | 6 | const routes = [ 7 | { 8 | path: "/", 9 | component: Home, 10 | }, 11 | 12 | { 13 | path: "/about", 14 | component: About, 15 | }, 16 | ]; 17 | 18 | export default new Router({ 19 | routes, 20 | base: import.meta.env.BASE_URL, 21 | mode: import.meta.env.SSR ? "server" : "client", 22 | }); 23 | -------------------------------------------------------------------------------- /examples/svelte/src/svelte.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svelte" { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /examples/svelte/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "target": "ES2017", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": ["vite/client"], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "jsx": "preserve", 13 | "jsxImportSource": "preact" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/svelte/vite.config.js: -------------------------------------------------------------------------------- 1 | import capri from "@capri-js/svelte"; 2 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 | import sveltePreprocess from "svelte-preprocess"; 4 | import { defineConfig } from "vite"; 5 | 6 | export default defineConfig((env) => ({ 7 | ssr: { 8 | noExternal: ["svelte-pilot"], 9 | }, 10 | plugins: [ 11 | svelte({ 12 | preprocess: [ 13 | sveltePreprocess({ 14 | preserve: ["json"], 15 | }), 16 | ], 17 | compilerOptions: { 18 | hydratable: true, 19 | }, 20 | }), 21 | capri(), 22 | ], 23 | })); 24 | -------------------------------------------------------------------------------- /examples/vercel/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .vercel 4 | -------------------------------------------------------------------------------- /examples/vercel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/vercel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-react-vercel-site", 3 | "private": true, 4 | "description": "Capri React on Vercel example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@vitejs/plugin-react": "^3.0.0", 15 | "react": "^18.1.0", 16 | "react-dom": "^18.1.0", 17 | "react-router-dom": "^6.3.0" 18 | }, 19 | "devDependencies": { 20 | "@capri-js/react": "^5.2.0", 21 | "@capri-js/vercel": "^1.1.0", 22 | "@types/react": "^18.0.9", 23 | "@types/react-dom": "^18.0.4", 24 | "vite": "^4.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/vercel/src/About.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | export function About() { 4 | return ( 5 |
6 |

This page was server-rendered on {new Date().toLocaleString()}

7 |
8 | An since it does not contain any interactive islands, no JavaScript is 9 | shipped to the browser. 10 |
11 | Home 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /examples/vercel/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | button { 41 | font-family: inherit; 42 | font-size: 0.7em; 43 | padding: 0.5em 1em; 44 | background: #15992b; 45 | color: #fff; 46 | border: none; 47 | border-radius: 4px; 48 | } 49 | button:hover { 50 | background: #56c13f; 51 | } 52 | 53 | .counter { 54 | display: inline-flex; 55 | align-items: center; 56 | gap: 0.2em; 57 | } 58 | 59 | .expandable button::after { 60 | display: inline-block; 61 | margin-left: 0.5em; 62 | content: ">"; 63 | transition: all 0.2s ease-in-out; 64 | } 65 | .expandable[data-expanded="true"] > button::after { 66 | transform: rotateZ(90deg); 67 | } 68 | 69 | .expandable-content { 70 | overflow: hidden; 71 | margin: 0.5em 0; 72 | } 73 | [data-expanded="false"] > .expandable-content { 74 | height: 0; 75 | } 76 | 77 | a { 78 | color: inherit; 79 | } 80 | 81 | .box { 82 | padding: 1em; 83 | border: 1px solid #aaa; 84 | border-radius: 6px; 85 | margin: 1em 0; 86 | } 87 | 88 | .banner { 89 | background: #ff0f7f; 90 | color: #fff; 91 | padding: 0.5rem; 92 | text-transform: uppercase; 93 | font-size: 0.8rem; 94 | } 95 | -------------------------------------------------------------------------------- /examples/vercel/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | import { Suspense } from "react"; 4 | import { Route, Routes } from "react-router-dom"; 5 | 6 | import { About } from "./About"; 7 | import { Home } from "./Home"; 8 | import { Preview } from "./Preview.jsx"; 9 | 10 | export function App() { 11 | return ( 12 | 13 | 14 | } /> 15 | } /> 16 | } /> 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/vercel/src/Counter.island.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | type Props = { 4 | start?: number; 5 | }; 6 | 7 | export default function Counter({ start = 0 }: Props) { 8 | const [counter, setCounter] = useState(start); 9 | return ( 10 |
11 | 12 | {counter} 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/vercel/src/Expandable.island.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | 3 | import StaticContent from "./StaticContent.lagoon.jsx"; 4 | 5 | type Props = { 6 | title: string; 7 | children?: ReactNode; 8 | }; 9 | export default function Expandable({ title, children }: Props) { 10 | const [expanded, setExpanded] = useState(false); 11 | return ( 12 |
13 | 14 | This is static content inside an island. We call this a lagoon. 15 | 16 | 17 |
18 | 19 | This a second lagoon. Below you see the children that were passed to 20 | the Expandable island: 21 | 22 | {children} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /examples/vercel/src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import CounterIsland from "./Counter.island.jsx"; 4 | import ExpandableIsland from "./Expandable.island.jsx"; 5 | import MediaQueryIsland from "./MediaQuery.island.jsx"; 6 | import { ServerContent } from "./ServerContent"; 7 | 8 | export function Home() { 9 | return ( 10 |
11 |

12 | Partial hydration with React and Capri 13 |

14 |
15 | This page was server-rendered on {new Date().toLocaleString()} 16 |
17 |
18 | Here is a simple counter: 19 |
20 |
21 | And here is another one, independent from the one above:{" "} 22 | 23 |
24 | 25 | This island receives children as prop. They are only rendered upon build 26 | time. 27 | 28 | The code for ServerContent won't show up in the client 29 | bundle. 30 | 31 | 32 | 33 | Link to another page 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /examples/vercel/src/MediaQuery.island.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const options = { 4 | media: "(max-width:500px)", 5 | }; 6 | 7 | export default function MediaQuery() { 8 | const [content, setContent] = useState( 9 | "Resize your browser below 500px to hydrate this island.", 10 | ); 11 | useEffect(() => { 12 | setContent("The island has been hydrated."); 13 | }, []); 14 | return
{content}
; 15 | } 16 | -------------------------------------------------------------------------------- /examples/vercel/src/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "react-router-dom"; 2 | 3 | /** 4 | * Handle preview requests like `/preview?slug=/about` by redirecting 5 | * to the given slug parameter. 6 | */ 7 | export function Preview() { 8 | const url = new URL(window.location.href); 9 | const slug = url.searchParams.get("slug") ?? "/"; 10 | return ; 11 | } 12 | 13 | /** 14 | * Component to display a banner when the site is viewed as SPA. 15 | */ 16 | export function PreviewBanner() { 17 | return
Preview Mode
; 18 | } 19 | -------------------------------------------------------------------------------- /examples/vercel/src/ServerContent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type Props = { 4 | children: ReactNode; 5 | }; 6 | 7 | export function ServerContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/vercel/src/StaticContent.lagoon.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type Props = { 4 | children?: ReactNode; 5 | }; 6 | 7 | export default function StaticContent({ children }: Props) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /examples/vercel/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/vercel/src/main.server.tsx: -------------------------------------------------------------------------------- 1 | import { RenderFunction, renderToString } from "@capri-js/react/server"; 2 | import { StrictMode } from "react"; 3 | import { StaticRouter } from "react-router-dom/server.js"; 4 | 5 | import { App } from "./App"; 6 | 7 | export const render: RenderFunction = async (url, context) => { 8 | context.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate=60"); 9 | return { 10 | "#app": await renderToString( 11 | 12 | 13 | 14 | 15 | , 16 | ), 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /examples/vercel/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | 5 | import { App } from "./App"; 6 | import { PreviewBanner } from "./Preview.jsx"; 7 | 8 | ReactDOM.createRoot(document.getElementById("app")!).render( 9 | 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /examples/vercel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "module": "ES2020", 6 | "moduleResolution": "Node", 7 | "target": "ES2017", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": ["vite/client"], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "jsx": "preserve" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/vercel/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "env": { 4 | "ENABLE_VC_BUILD": "1" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/vercel/vite.config.ts: -------------------------------------------------------------------------------- 1 | import capri from "@capri-js/react"; 2 | import vercel from "@capri-js/vercel"; 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig } from "vite"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | capri({ 10 | spa: "/preview", 11 | prerender: false, 12 | target: vercel({ 13 | isg: { 14 | expiration: 60, 15 | }, 16 | }), 17 | }), 18 | ], 19 | }); 20 | -------------------------------------------------------------------------------- /examples/vue/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /examples/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-capri-vue-site", 3 | "private": true, 4 | "description": "Capri Vue example", 5 | "version": "1.0.0", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build && vite build --ssr", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "vue": "^3.2.37", 15 | "vue-router": "^4.1.6" 16 | }, 17 | "devDependencies": { 18 | "@capri-js/vue": "^2.1.1", 19 | "@vitejs/plugin-vue": "^4.0.0", 20 | "typescript": "^4.7.4", 21 | "vite": "^4.0.0", 22 | "vue-tsc": "^0.38.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/vue/src/About.vue: -------------------------------------------------------------------------------- 1 | 4 | 18 | -------------------------------------------------------------------------------- /examples/vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /examples/vue/src/AsyncData.vue: -------------------------------------------------------------------------------- 1 | 10 | 13 | -------------------------------------------------------------------------------- /examples/vue/src/Counter.island.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 37 | -------------------------------------------------------------------------------- /examples/vue/src/Expandable.island.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | 23 | 56 | -------------------------------------------------------------------------------- /examples/vue/src/Home.vue: -------------------------------------------------------------------------------- 1 | 7 | 29 | -------------------------------------------------------------------------------- /examples/vue/src/MediaQuery.island.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /examples/vue/src/PreviewApp.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | -------------------------------------------------------------------------------- /examples/vue/src/ServerContent.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /examples/vue/src/StaticContent.lagoon.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /examples/vue/src/capri.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/vue/src/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #fafafa; 7 | font-family: "Helvetica Neue", arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | } 11 | 12 | main { 13 | max-width: 60ch; 14 | margin: auto; 15 | font-size: 1.5em; 16 | line-height: 1.5; 17 | padding: 2em; 18 | } 19 | 20 | h1 { 21 | line-height: 1.1; 22 | } 23 | h1 i { 24 | font-style: normal; 25 | } 26 | h1 i::after { 27 | content: ""; 28 | display: inline-block; 29 | width: 0.8em; 30 | height: 0.8em; 31 | margin-left: 0.25ch; 32 | background: url("./capri.svg") no-repeat; 33 | background-size: contain; 34 | } 35 | 36 | section { 37 | margin-bottom: 1em; 38 | } 39 | 40 | a { 41 | color: inherit; 42 | } 43 | 44 | .box { 45 | padding: 1em; 46 | border: 1px solid #aaa; 47 | border-radius: 6px; 48 | margin: 1em 0; 49 | } 50 | -------------------------------------------------------------------------------- /examples/vue/src/main.server.ts: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | 3 | import { RenderFunction } from "@capri-js/vue/server"; 4 | import { createSSRApp } from "vue"; 5 | import { renderToString } from "vue/server-renderer"; 6 | 7 | import App from "./App.vue"; 8 | import createRouter from "./router.js"; 9 | 10 | export const render: RenderFunction = async (url, context) => { 11 | const app = createSSRApp(App); 12 | const router = createRouter(); 13 | app.use(router); 14 | 15 | // Note: vue-router's MemoryHistory does not strip the base, so we have to do 16 | // it manually. If you don't use a BASE_URL you can skip this step: 17 | const relativeUrl = url.slice(import.meta.env.BASE_URL.length); 18 | 19 | router.push(relativeUrl); 20 | 21 | await router.isReady(); 22 | const { matched } = router.currentRoute.value; 23 | if (matched.length) { 24 | const html = await renderToString(app); 25 | return { 26 | "#app": html, 27 | }; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /examples/vue/src/main.ts: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | 3 | import { createApp } from "vue"; 4 | 5 | import PreviewApp from "./PreviewApp.vue"; 6 | import createRouter from "./router.js"; 7 | 8 | const app = createApp(PreviewApp); 9 | app.use(createRouter()); 10 | 11 | app.mount("#app"); 12 | -------------------------------------------------------------------------------- /examples/vue/src/router.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createMemoryHistory, 3 | createRouter, 4 | createWebHistory, 5 | RouteRecordRaw, 6 | } from "vue-router"; 7 | 8 | import About from "./About.vue"; 9 | import Home from "./Home.vue"; 10 | 11 | const history = import.meta.env.SSR 12 | ? createMemoryHistory(import.meta.env.BASE_URL) 13 | : createWebHistory(import.meta.env.BASE_URL); 14 | 15 | const routes: RouteRecordRaw[] = [ 16 | { path: "/", name: "home", component: Home }, 17 | { path: "/about", name: "about", component: About }, 18 | { 19 | path: "/preview", 20 | redirect: (to) => { 21 | const slug = to.query["slug"]; 22 | return { path: typeof slug === "string" ? slug : "/" }; 23 | }, 24 | }, 25 | ]; 26 | 27 | export default () => createRouter({ routes, history }); 28 | -------------------------------------------------------------------------------- /examples/vue/src/vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import type { DefineComponent } from "vue"; 3 | const component: DefineComponent< 4 | Record, 5 | Record, 6 | unknown 7 | >; 8 | export default component; 9 | } 10 | -------------------------------------------------------------------------------- /examples/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "allowJs": true, 6 | "module": "ES2020", 7 | "moduleResolution": "Node", 8 | "target": "ES2017", 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "types": ["vite/client"], 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "jsx": "preserve" 14 | }, 15 | "include": ["vite.config.ts", "src"] 16 | } 17 | -------------------------------------------------------------------------------- /examples/vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import capri from "@capri-js/vue"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | vue(), 8 | capri({ 9 | spa: "/preview", 10 | }), 11 | ], 12 | }); 13 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@capri-js/monorepo", 3 | "private": true, 4 | "scripts": { 5 | "dev": "tsc --build packages --watch", 6 | "clean": "tsc --build packages --clean", 7 | "build": "tsc --build packages", 8 | "lint": "eslint . && prettier --check .", 9 | "lint:fix": "eslint --fix . && prettier --write .", 10 | "test": "vitest --run", 11 | "examples": "node .github/build-examples", 12 | "release": "multi-semantic-release --sequential-init --deps.prefix ^" 13 | }, 14 | "devDependencies": { 15 | "@qiwi/multi-semantic-release": "^7.1.1", 16 | "@semantic-release/commit-analyzer": "^11.1.0", 17 | "@semantic-release/git": "^10.0.1", 18 | "@semantic-release/npm": "^11.0.2", 19 | "@testing-library/dom": "^9.3.3", 20 | "@testing-library/jest-dom": "^6.2.0", 21 | "@testing-library/user-event": "^14.5.2", 22 | "@types/jsdom": "^21.1.6", 23 | "@types/node": "^20.10.6", 24 | "@typescript-eslint/eslint-plugin": "^6.17.0", 25 | "@typescript-eslint/parser": "^6.17.0", 26 | "@vitejs/plugin-legacy": "^3.0.1", 27 | "eslint": "^8.56.0", 28 | "eslint-plugin-import": "^2.29.1", 29 | "eslint-plugin-simple-import-sort": "^10.0.0", 30 | "jsdom": "^23.0.1", 31 | "prettier": "^3.1.1", 32 | "typescript": "^4.9.4", 33 | "vitest": "^0.32.0" 34 | }, 35 | "workspaces": [ 36 | "packages/*", 37 | "examples/*" 38 | ], 39 | "release": { 40 | "branches": [ 41 | "main", 42 | { 43 | "name": "next", 44 | "prerelease": true 45 | } 46 | ], 47 | "plugins": [ 48 | "@semantic-release/commit-analyzer", 49 | "@semantic-release/npm", 50 | "@semantic-release/git" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/capri/README.md: -------------------------------------------------------------------------------- 1 | # Capri 🍋 2 | 3 | Capri is a static site generator that supports partial hydration. 4 | 5 | Instead of using this package directly, please pick one of the framework adapters: 6 | 7 | - @capri-js/react 8 | - @capri-js/preact 9 | - @capri-js/solid 10 | - @capri-js/vue 11 | - @capri-js/svelte 12 | -------------------------------------------------------------------------------- /packages/capri/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "capri", 3 | "version": "5.2.3", 4 | "description": "Static site generator for Vite", 5 | "author": "Felix Gnass ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/capri-js/capri" 9 | }, 10 | "license": "MIT", 11 | "type": "module", 12 | "main": "./lib/index.js", 13 | "files": [ 14 | "lib", 15 | "ssr.d.ts" 16 | ], 17 | "exports": { 18 | ".": { 19 | "default": "./lib/index.js" 20 | }, 21 | "./context": { 22 | "default": "./lib/context.js" 23 | }, 24 | "./vite-plugin": { 25 | "default": "./lib/vite-plugin.js" 26 | } 27 | }, 28 | "typesVersions": { 29 | "*": { 30 | "index.d.ts": [ 31 | "lib/index.d.ts" 32 | ], 33 | "context": [ 34 | "lib/context.d.ts" 35 | ], 36 | "vite-plugin": [ 37 | "lib/vite-plugin.d.ts" 38 | ], 39 | "ssr": [ 40 | "ssr.d.ts" 41 | ] 42 | } 43 | }, 44 | "dependencies": { 45 | "cheerio": "^1.0.0-rc.11", 46 | "esbuild": "^0.19.11", 47 | "micromatch": "^4.0.5", 48 | "node-fetch": "^3.3.2", 49 | "url-join": "^5.0.0", 50 | "web-streams-polyfill": "^3.3.2" 51 | }, 52 | "devDependencies": { 53 | "@types/micromatch": "^4.0.6", 54 | "vite": "^4.0.0" 55 | }, 56 | "peerDependencies": { 57 | "vite": "^4.0.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/capri/src/Template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Template to analyze and modify the HTML. 3 | */ 4 | export class Template { 5 | private html: string; 6 | 7 | constructor(html: string) { 8 | this.html = html; 9 | } 10 | 11 | /** 12 | * Find all hydration scripts and return their data-island attribute and 13 | * their text content. 14 | */ 15 | getIslands() { 16 | return [ 17 | ...this.html.matchAll( 18 | /]+data-island="(.+?)"[^>]*>([\s\S]+?)<\/script>/gi, 19 | ), 20 | ].map(([, island, json]) => ({ island, json })); 21 | } 22 | 23 | /** 24 | * Remove all script tags with matching src or text. 25 | */ 26 | removeScripts(test: { src?: RegExp; text?: RegExp }) { 27 | this.html = this.html.replace( 28 | /<\s*script(.*?)>([\s\S]*?)<\s*\/\s*script\s*>\s*/gi, 29 | (match, attrs, text) => { 30 | if (test.src) { 31 | const [, src] = /\bsrc\s*=\s*"(.+?)"/.exec(attrs) ?? []; 32 | if (src && src.match(test.src)) return ""; 33 | } 34 | if (test.text && text.match(test.text)) { 35 | return ""; 36 | } 37 | return match; 38 | }, 39 | ); 40 | } 41 | 42 | /** 43 | * Insert markup into the html with the keys being CSS selectors. 44 | */ 45 | insertMarkup(markup: Record) { 46 | for (const [selector, insert] of Object.entries(markup)) { 47 | if (insert) { 48 | if (!selector.match(/^#?[\w]+$/)) { 49 | throw new Error(`Unsupported selector: ${selector}`); 50 | } 51 | if (selector.startsWith("#")) { 52 | // id selector - insert after the opening tag 53 | this.html = this.html.replace( 54 | new RegExp(`\\bid\\s*=\\s*"${selector.slice(1)}"[^>]*>`), 55 | `$&${insert}`, 56 | ); 57 | } else { 58 | // type selector - insert before the closing tag 59 | this.html = this.html.replace( 60 | new RegExp(`<\\s*/\\s*${selector}[^>]*>`), 61 | `${insert}$&`, 62 | ); 63 | } 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Return the HTML. 70 | */ 71 | toString() { 72 | return this.html; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/capri/src/assets.ts: -------------------------------------------------------------------------------- 1 | import type { OutputAsset, OutputBundle, OutputChunk } from "rollup"; 2 | import type { Plugin } from "vite"; 3 | 4 | export function serverAssetsPlugins(): Plugin[] { 5 | let ssrBuild = false; 6 | let base: string; 7 | 8 | const serverAssets: OutputBundle = {}; 9 | 10 | return [ 11 | /** 12 | * Vite does not emit assets for SSR builds, assuming that they are emitted 13 | * by the client build. For Capri, this is only the case if we are building 14 | * the preview SPA, which is optional. Normally, the client build will only 15 | * include the islands and the hydration code. We therefore add this plugin 16 | * to save the server assets so that we can restore them later on (see next 17 | * plugin). 18 | */ 19 | { 20 | enforce: "pre", 21 | name: "capri-save-assets", 22 | config(cfg) { 23 | ssrBuild = !!cfg.build?.ssr; 24 | base = cfg.base ?? process.env.BASE_URL ?? "/"; 25 | }, 26 | generateBundle(options, bundle) { 27 | if (ssrBuild) { 28 | for (const file in bundle) { 29 | const chunk = bundle[file]; 30 | if (chunk.type === "asset" && !file.includes("ssr-manifest.json")) { 31 | serverAssets[file] = chunk; 32 | } 33 | } 34 | } 35 | }, 36 | }, 37 | 38 | /** 39 | * This plugin restores the assets that were saved by the previous one and 40 | * were meanwhile deleted by Vite's asset plugin: 41 | * https://github.com/vitejs/vite/blob/f12a1ab/packages/vite/src/node/plugins/asset.ts#L189 42 | */ 43 | { 44 | enforce: "post", 45 | name: "capri-restore-assets", 46 | generateBundle(options, bundle) { 47 | if (ssrBuild) { 48 | // Add the assets back to the bundle so that Vite will emit the files 49 | Object.assign(bundle, serverAssets); 50 | 51 | const css = getCssUrls(serverAssets, base); 52 | const ssrChunk = getSSRChunk(bundle); 53 | 54 | // We have to rewrite the code here, after the bundling has finished. 55 | // If the chunk had a source map, this would mess it up and we would 56 | // need to use a library like magic-string. 57 | // For now this naive approach seems fine. 58 | ssrChunk.code = ssrChunk.code.replace( 59 | "__CSS_ASSETS__", 60 | JSON.stringify(css), 61 | ); 62 | } 63 | }, 64 | }, 65 | ]; 66 | } 67 | 68 | function getSSRChunk(bundle: OutputBundle) { 69 | const chunk = Object.values(bundle) 70 | .filter(isOutputChunk) 71 | .find((c) => c.name === "ssr"); 72 | 73 | if (!chunk) throw new Error("Can't find SSR chunk."); 74 | return chunk; 75 | } 76 | 77 | function isOutputChunk(chunk: OutputAsset | OutputChunk): chunk is OutputChunk { 78 | return chunk.type === "chunk"; 79 | } 80 | 81 | function getCssUrls(serverAssets: OutputBundle, base = "/") { 82 | return Object.keys(serverAssets) 83 | .filter((f) => f.endsWith(".css")) 84 | .map((f) => `${base}${f}`); 85 | } 86 | -------------------------------------------------------------------------------- /packages/capri/src/bundle.ts: -------------------------------------------------------------------------------- 1 | import esbuild, { Plugin } from "esbuild"; 2 | 3 | export interface BundleOptions { 4 | target?: string; 5 | platform?: "browser" | "node" | "neutral"; 6 | format?: "iife" | "cjs" | "esm"; 7 | minify?: boolean; 8 | inject?: string[]; 9 | } 10 | 11 | /** 12 | * Creates a bundle() function that can be used by build targets 13 | * to package the SSR code together with some platform specific 14 | * adapter logic. 15 | */ 16 | export function createBundler(ssrBundle: string) { 17 | const resolve: Plugin = { 18 | name: "capri-resolve", 19 | setup(build) { 20 | build.onResolve({ filter: /^virtual:capri-ssr$/ }, () => { 21 | return { path: ssrBundle }; 22 | }); 23 | }, 24 | }; 25 | 26 | return async function bundle( 27 | input: string, 28 | output: string, 29 | options: BundleOptions = {}, 30 | ) { 31 | const { 32 | target = "es2020", 33 | format = "esm", 34 | platform = "neutral", 35 | minify = true, 36 | inject, 37 | } = options; 38 | await esbuild.build({ 39 | target, 40 | format, 41 | platform, 42 | plugins: [resolve], 43 | entryPoints: [input], 44 | inject, 45 | outfile: output, 46 | allowOverwrite: true, 47 | legalComments: "none", 48 | bundle: true, 49 | minify, 50 | }); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/capri/src/context.ts: -------------------------------------------------------------------------------- 1 | export type RenderContext = { 2 | status(code: number): void; 3 | getHeader(name: string): string | null | undefined; 4 | setHeader(name: string, value: string): void; 5 | }; 6 | 7 | export class StaticRenderContext implements RenderContext { 8 | statusCode = 200; 9 | headers: Record = {}; 10 | status(code: number) { 11 | this.statusCode = code; 12 | } 13 | getHeader(name: string) { 14 | return this.headers[name]; 15 | } 16 | setHeader(name: string, value: string) { 17 | this.headers[name] = value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/capri/src/dev.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { ModuleGraph, ModuleNode, ViteDevServer } from "vite"; 4 | 5 | import { RenderContext } from "./context.js"; 6 | import { getEntryScripts } from "./entry.js"; 7 | import { renderHtml } from "./render.js"; 8 | import { direct } from "./utils.js"; 9 | 10 | export async function renderPreview( 11 | server: ViteDevServer, 12 | url: string, 13 | context?: RenderContext, 14 | ) { 15 | // always read fresh template in dev 16 | const indexHtml = fs.readFileSync( 17 | path.resolve(server.config.root, "index.html"), 18 | "utf-8", 19 | ); 20 | 21 | const entry = getEntryScripts(server.config.root); 22 | 23 | // Load the server entry. vite.ssrLoadModule automatically transforms 24 | // your ESM source code to be usable in Node. 25 | const renderFn = (await server.ssrLoadModule(entry.server)).render; 26 | const css = collectCss(server.moduleGraph); 27 | try { 28 | const html = await renderHtml(renderFn, url, indexHtml, css, context); 29 | if (html) { 30 | // Apply Vite HTML transforms. This injects the Vite HMR client, and 31 | // also applies HTML transforms from Vite plugins, e.g. global preambles 32 | // from @vitejs/plugin-react 33 | return await server.transformIndexHtml(url, html); 34 | } 35 | } catch (e: any) { 36 | server.ssrFixStacktrace(e); 37 | throw e; 38 | } 39 | } 40 | 41 | function collectCss(moduleGraph: ModuleGraph) { 42 | const css: string[] = []; 43 | moduleGraph.idToModuleMap.forEach((m) => { 44 | if (isStyleModule(m)) { 45 | const href = direct(m.url); 46 | if (!css.includes(href)) { 47 | css.push(href); 48 | } 49 | } 50 | }); 51 | return css; 52 | } 53 | 54 | // Taken from https://github.com/vitejs/vite/blob/13ac37d/packages/vite/src/node/constants.ts#L49 55 | export const CSS_LANGS = 56 | /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; 57 | 58 | function isStyleModule(mod: ModuleNode) { 59 | return !!(CSS_LANGS.test(mod.url) || mod.id?.match(/\?vue&type=style/)); 60 | } 61 | -------------------------------------------------------------------------------- /packages/capri/src/entry.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import * as fsutils from "./fsutils.js"; 4 | import { getEntrySrc } from "./html.js"; 5 | 6 | /** 7 | * Get the absolute path to index.html. 8 | * @param root the project root 9 | * @returns The absolute path 10 | * @throws Error, if file not found 11 | */ 12 | export function getIndexHtml(root = "") { 13 | const indexHtml = path.resolve(root, "index.html"); 14 | if (!fsutils.exists(indexHtml)) { 15 | throw new Error(`Can't find index.html in ${root}`); 16 | } 17 | return indexHtml; 18 | } 19 | 20 | /** 21 | * Get the absolute path to the entry script. 22 | * @param root the project root 23 | * @returns The absolute path 24 | * @throws Error, if file not found 25 | */ 26 | export function getEntryScript(root = "") { 27 | const indexHtml = getIndexHtml(root); 28 | const src = getEntrySrc(fsutils.read(indexHtml)); 29 | if (!src) throw new Error(`Can't find entry script in ${indexHtml}`); 30 | return src; 31 | } 32 | 33 | /** 34 | * Check if the given script is a .server script. 35 | */ 36 | function isServerEntry(file: string) { 37 | return /\.server\.[^.]+$/.test(file); 38 | } 39 | 40 | export type EntryScripts = { 41 | raw: string; 42 | server: string; 43 | client?: string; 44 | }; 45 | 46 | /** 47 | * Get the absolute paths to the entry scripts. 48 | * @param root the project root 49 | */ 50 | export function getEntryScripts(root = ""): EntryScripts { 51 | const raw = getEntryScript(root); 52 | const resolved = path.join(path.resolve(root), raw); 53 | if (isServerEntry(raw)) { 54 | return { raw, server: resolved }; 55 | } 56 | const server = resolved.replace(/(\.client)?(\.[^.]+)$/, ".server$2"); 57 | if (!fsutils.exists(server)) { 58 | throw new Error( 59 | `File not found: ${server}. Make sure to name your server entry file accordingly.`, 60 | ); 61 | } 62 | return { 63 | raw, 64 | client: resolved, 65 | server, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /packages/capri/src/fsutils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import { escapeRegex, posixify } from "./utils.js"; 5 | 6 | /** 7 | * Check if a file or directory exists. 8 | * @returns true if the path exists, false otherwise. 9 | */ 10 | export function exists(file: string) { 11 | return fs.existsSync(file); 12 | } 13 | 14 | /** 15 | * Creates a directory and all possibly missing ancestors. 16 | */ 17 | export function mkdir(dir: string) { 18 | try { 19 | fs.mkdirSync(dir, { recursive: true }); 20 | } catch (err: any) { 21 | if (err.code === "EEXIST") return; 22 | throw err; 23 | } 24 | } 25 | 26 | /** 27 | * Recursively deletes the given path. 28 | */ 29 | export function rm(path: string) { 30 | fs.rmSync(path, { force: true, recursive: true }); 31 | } 32 | /** 33 | * Copy files. 34 | * @param source file or directory to copy 35 | * @param target destination file name or directory 36 | * @param opts optional filter and replacement functions 37 | * @returns the files that were written 38 | */ 39 | export function copy( 40 | source: string, 41 | target: string, 42 | opts: { 43 | filter?: (basename: string) => boolean; 44 | replace?: Record; 45 | } = {}, 46 | ) { 47 | if (!exists(source)) return []; 48 | 49 | const files: string[] = []; 50 | 51 | const prefix = posixify(target) + "/"; 52 | 53 | const regex = opts.replace 54 | ? new RegExp( 55 | `(${Object.keys(opts.replace).map(escapeRegex).join("|")})`, 56 | "g", 57 | ) 58 | : null; 59 | 60 | function go(from: string, to: string) { 61 | if (opts.filter && !opts.filter(path.basename(from))) return; 62 | 63 | const stats = fs.statSync(from); 64 | 65 | if (stats.isDirectory()) { 66 | fs.readdirSync(from).forEach((file) => { 67 | go(path.join(from, file), path.join(to, file)); 68 | }); 69 | } else { 70 | mkdir(path.dirname(to)); 71 | 72 | if (regex && opts.replace) { 73 | const data = read(from); 74 | fs.writeFileSync( 75 | to, 76 | data.replace(regex, (match, key) => opts.replace?.[key] ?? ""), 77 | ); 78 | } else { 79 | fs.copyFileSync(from, to); 80 | } 81 | 82 | files.push( 83 | to === target 84 | ? posixify(path.basename(to)) 85 | : posixify(to).replace(prefix, ""), 86 | ); 87 | } 88 | } 89 | 90 | go(source, target); 91 | 92 | return files; 93 | } 94 | 95 | /** 96 | * Read the contents of a UTF-8 encoded file. 97 | */ 98 | export function read(file: string) { 99 | return fs.readFileSync(file, "utf8"); 100 | } 101 | 102 | /** 103 | * Write a string to a file. Missing directories are created. 104 | */ 105 | export function write(file: string, data: string) { 106 | try { 107 | fs.mkdirSync(path.dirname(file), { recursive: true }); 108 | } catch { 109 | // ignore 110 | } 111 | fs.writeFileSync(file, data); 112 | } 113 | -------------------------------------------------------------------------------- /packages/capri/src/html.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | 3 | import { isLocalUrl, resolveUrl } from "./utils.js"; 4 | 5 | export function getLinks(html: string) { 6 | const $ = cheerio.load(html); 7 | return $('a[href]:not([target]),a[href][target="self"]') 8 | .map((i, el) => $(el).attr("href")) 9 | .toArray() 10 | .filter(isLocalUrl) 11 | .map(resolveUrl); 12 | } 13 | 14 | export function getEntrySrc(html: string) { 15 | const $ = cheerio.load(html); 16 | const src = $('script[type="module"][src]') 17 | .map((i, el) => $(el).attr("src")) 18 | .toArray() 19 | .filter(isLocalUrl); 20 | return src[0]; 21 | } 22 | -------------------------------------------------------------------------------- /packages/capri/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./context.js"; 2 | export * from "./dev.js"; 3 | export * from "./entry.js"; 4 | export * from "./prerender.js"; 5 | export * from "./render.js"; 6 | export * from "./types.js"; 7 | -------------------------------------------------------------------------------- /packages/capri/src/options.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigEnv, SSROptions, UserConfig } from "vite"; 2 | 3 | import type { BundleOptions } from "./bundle.js"; 4 | import type * as fsutils from "./fsutils.js"; 5 | import type { FollowLinksConfig, PrerenderConfig } from "./prerender.js"; 6 | import type { Wrapper, WrapperInjectionHook } from "./wrapper.js"; 7 | 8 | export interface Adapter { 9 | hydrate: string; 10 | island: Wrapper; 11 | lagoon: Wrapper; 12 | injectWrapper?: WrapperInjectionHook; 13 | } 14 | 15 | export interface BuildArgs { 16 | rootDir: string; 17 | outDir: string; 18 | ssrBundle: string; 19 | prerendered: string[]; 20 | fsutils: typeof fsutils; 21 | bundle: ( 22 | input: string, 23 | output: string, 24 | options?: BundleOptions, 25 | ) => Promise; 26 | } 27 | 28 | interface ViteConfig extends UserConfig { 29 | ssr?: SSROptions; 30 | } 31 | 32 | export interface BuildTarget { 33 | config?: ( 34 | config: ViteConfig, 35 | env: ConfigEnv, 36 | ) => ViteConfig | null | void | Promise; 37 | build: (args: BuildArgs) => Promise; 38 | } 39 | export interface CapriPluginOptions { 40 | createIndexFiles?: boolean; 41 | prerender?: PrerenderConfig; 42 | followLinks?: FollowLinksConfig; 43 | islandGlobPattern?: string; 44 | lagoonGlobPattern?: string; 45 | adapter: Adapter; 46 | target?: BuildTarget | string; 47 | spa?: string | false; 48 | } 49 | 50 | export type CapriAdapterPluginOptions = Omit; 51 | -------------------------------------------------------------------------------- /packages/capri/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes sure that the global fetch and ReadableStream APIs are available 3 | * and loads the polyfills if necessary. 4 | */ 5 | export async function polyfillWebAPIs() { 6 | if (!globalThis.ReadableStream) { 7 | await import("web-streams-polyfill"); 8 | } 9 | 10 | if (!globalThis.fetch) { 11 | const fetch: any = await import("node-fetch"); 12 | globalThis.fetch = fetch.default; 13 | globalThis.Request = fetch.Request; 14 | globalThis.Response = fetch.Response; 15 | globalThis.Headers = fetch.Headers; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/capri/src/prerender.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import urlJoin from "url-join"; 4 | 5 | import { StaticRenderContext } from "./context.js"; 6 | import { getLinks } from "./html.js"; 7 | import { polyfillWebAPIs } from "./polyfills.js"; 8 | import { loadSSRModule } from "./render.js"; 9 | import { stripLeadingSlash, stripTrailingSlash } from "./utils.js"; 10 | 11 | export type PrerenderConfig = 12 | | false 13 | | string 14 | | string[] 15 | | (() => string[] | Promise); 16 | 17 | export type FollowLinksConfig = boolean | ((pathname: string) => boolean); 18 | 19 | type StaticRenderConfig = { 20 | ssrBundle: string; 21 | createIndexFiles: boolean; 22 | outDir: string; 23 | base: string; 24 | prerender: PrerenderConfig; 25 | followLinks: FollowLinksConfig; 26 | }; 27 | 28 | async function getStaticPaths(prerender: PrerenderConfig): Promise { 29 | if (prerender === false) return []; 30 | if (typeof prerender === "string") return [prerender]; 31 | if (Array.isArray(prerender)) return prerender; 32 | return getStaticPaths(await prerender()); 33 | } 34 | 35 | export async function renderStaticPages({ 36 | ssrBundle, 37 | createIndexFiles, 38 | outDir, 39 | base, 40 | prerender, 41 | followLinks, 42 | }: StaticRenderConfig) { 43 | await polyfillWebAPIs(); 44 | const ssr = await loadSSRModule(ssrBundle); 45 | const seen = new Set( 46 | (await getStaticPaths(prerender)).map((s) => urlJoin(base, s)), 47 | ); 48 | const urls = [...seen]; 49 | for (const url of urls) { 50 | const context = new StaticRenderContext(); 51 | const html = await ssr(url, context); 52 | if (html && context.statusCode === 200) { 53 | const fileName = urlToFileName(url, createIndexFiles, base); 54 | const dest = path.join(outDir, fileName); 55 | fs.mkdirSync(path.dirname(dest), { recursive: true }); 56 | fs.writeFileSync(dest, html); 57 | 58 | if (followLinks) { 59 | const follow = 60 | typeof followLinks === "function" ? followLinks : Boolean; 61 | const links = getLinks(html).filter(follow); 62 | for (const link of links) { 63 | if (!seen.has(link)) { 64 | seen.add(link); 65 | urls.push(link); 66 | } 67 | } 68 | } 69 | } else { 70 | console.warn("Skipping", url, "- status", context.statusCode); 71 | } 72 | } 73 | return urls; 74 | } 75 | 76 | export function urlToFileName(url: string, extraDir: boolean, base: string) { 77 | let file = stripTrailingSlash(url); 78 | base = stripTrailingSlash(base); 79 | if (base && file.startsWith(base)) file = file.slice(base.length); 80 | file = stripLeadingSlash(file); 81 | if (!file) return "index.html"; 82 | if (file.includes(".html")) return file; 83 | return `${file}${extraDir ? "/index.html" : ".html"}`; 84 | } 85 | -------------------------------------------------------------------------------- /packages/capri/src/render.ts: -------------------------------------------------------------------------------- 1 | import { RenderContext, StaticRenderContext } from "./context.js"; 2 | import { Template } from "./Template.js"; 3 | import { Markup, RenderFunction } from "./types.js"; 4 | import { SSRFunction } from "./virtual/ssr.js"; 5 | 6 | export async function loadSSRModule(path: string) { 7 | if (path.startsWith(".")) { 8 | throw new Error("Path must be absolute"); 9 | } 10 | 11 | const mod = await import(/* @vite-ignore */ path); 12 | 13 | if (mod && typeof mod === "object" && "default" in mod) { 14 | const ssr = mod.default; 15 | // When ssr.format is set to "cjs" we end up with default.default: 16 | const fn = ssr.default ?? ssr; 17 | if (typeof fn === "function") return fn as SSRFunction; 18 | } 19 | throw new Error(`${path} is not a SSR module`); 20 | } 21 | 22 | /** 23 | * Renders a page and returns a template which can be used to insert additional 24 | * markup. 25 | * 26 | * @param renderFn The render function 27 | * @param url The URL of the page to render 28 | * @param indexHtml The index.html where to insert the markup 29 | * @param css List of stylesheets to include 30 | * @param context The context passed to the render function 31 | * @returns A HTML string or undefined, if nothing was rendered. 32 | */ 33 | export async function renderHtml( 34 | renderFn: RenderFunction, 35 | url: string, 36 | indexHtml: string, 37 | css: string[], 38 | context: RenderContext = new StaticRenderContext(), 39 | ) { 40 | const result = await renderFn(url, context); 41 | if (!result) return; 42 | 43 | const template = new Template(indexHtml); 44 | 45 | // Insert the rendered markup into the index.html template: 46 | template.insertMarkup(await resolveMarkup(result)); 47 | 48 | const head = css 49 | .map((href) => ``) 50 | .join(""); 51 | template.insertMarkup({ head }); 52 | 53 | const islands = template.getIslands(); 54 | if (!islands.length) { 55 | // No islands present, remove the hydration script. 56 | console.log("No islands found, removing hydration code"); 57 | template.removeScripts({ 58 | src: /index-|-legacy|modulepreload-polyfill/, 59 | text: /__vite_is_modern_browser|"noModule"|_\$HY/, 60 | }); 61 | } 62 | return template.toString(); 63 | } 64 | 65 | /** 66 | * The Markup object returned by a RenderFunction may have Promises as values. 67 | * This utility function awaits them and returns an object with the resolved 68 | * values. 69 | */ 70 | async function resolveMarkup(markup: Markup) { 71 | const resolved: Record = {}; 72 | for (const [key, value] of Object.entries(markup)) { 73 | resolved[key] = await value; 74 | } 75 | return resolved; 76 | } 77 | -------------------------------------------------------------------------------- /packages/capri/src/types.ts: -------------------------------------------------------------------------------- 1 | import { RenderContext } from "./context.js"; 2 | 3 | export type Markup = Record>; 4 | export type RenderResult = Markup | null | undefined; 5 | 6 | export type RenderFunction = ( 7 | url: string, 8 | context: RenderContext, 9 | ) => RenderResult | Promise; 10 | 11 | export interface IslandOptions { 12 | media?: string; 13 | } 14 | -------------------------------------------------------------------------------- /packages/capri/src/utils.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export function escapeRegex(str: string) { 4 | return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); 5 | } 6 | 7 | export function posixify(str: string) { 8 | return str.split(path.sep).join(path.posix.sep); 9 | } 10 | 11 | export function isLocalUrl(href: string) { 12 | const url = new URL(href, "file:///"); 13 | return url.protocol === "file:" && !url.host; 14 | } 15 | 16 | export function resolveUrl(href: string) { 17 | const url = new URL(href, "file:///"); 18 | return url.pathname; 19 | } 20 | 21 | export function stripLeadingSlash(s: string) { 22 | return s.replace(/^\//, ""); 23 | } 24 | 25 | export function stripTrailingSlash(s: string) { 26 | return s.replace(/\/$/, ""); 27 | } 28 | 29 | export function direct(s: string) { 30 | const i = s.indexOf("?"); 31 | if (i === -1) return s + "?direct"; 32 | if (s.slice(i).match(/[?&]direct\b/)) return s; 33 | return s.replace("?", "?direct&"); 34 | } 35 | 36 | export function addUnwrapped(s: string) { 37 | // Note: we add the basename so that the extension stays the same... 38 | return `${s}?unwrapped=${path.basename(s)}`; 39 | } 40 | -------------------------------------------------------------------------------- /packages/capri/src/virtual/client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When running inside a Vite dev server and the project has a server-only 3 | * entry file, this module will be used as client-entry. 4 | */ 5 | import "virtual:capri-hydration"; 6 | -------------------------------------------------------------------------------- /packages/capri/src/virtual/hydration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entry script to hydrate all the islands. 3 | */ 4 | 5 | // Find all modules that match the island glob pattern. 6 | // NOTE: The ISLAND_GLOB_PATTERN token below is replaced by the 7 | // actual pattern during the build. 8 | const modules = import.meta.glob("%ISLAND_GLOB_PATTERN%"); 9 | 10 | // Find all island marker scripts 11 | const islands = document.querySelectorAll("script[data-island]"); 12 | 13 | islands.forEach((node) => { 14 | // The element to be hydrated 15 | const element = node.previousElementSibling; 16 | if (!element) throw new Error("Missing previousElementSibling"); 17 | 18 | // The island source code to load 19 | const island = node.getAttribute("data-island"); 20 | if (!island) throw new Error("Missing attribute: data-island"); 21 | 22 | const load = modules[island]; 23 | if (!load) throw new Error(`Island module not found: ${island}`); 24 | 25 | // Island props and options read from the marker script 26 | const { props = {}, options = {} } = node.textContent 27 | ? JSON.parse(node.textContent) 28 | : {}; 29 | 30 | const hydrateComponent = async () => { 31 | const hydrate = (await import("virtual:capri-hydration-adapter")).default; 32 | const m: any = await load(); 33 | hydrate(m.default, props, element); 34 | }; 35 | 36 | const { media } = options; 37 | if (media && "matchMedia" in window) { 38 | const mql = matchMedia(media); 39 | if (mql.matches) { 40 | hydrateComponent(); 41 | } else { 42 | mql.addEventListener("change", hydrateComponent, { once: true }); 43 | } 44 | } else { 45 | hydrateComponent(); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /packages/capri/src/virtual/ssr.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Virtual module that inlines the index.html upon build. 3 | */ 4 | import * as renderModule from "virtual:capri-server-entry"; 5 | 6 | import { RenderContext } from "../context.js"; 7 | import { renderHtml } from "../render.js"; 8 | 9 | const render: any = renderModule; 10 | const renderFn = render.render ?? render.default; 11 | 12 | const template = "%TEMPLATE%"; 13 | 14 | const css: string[] = __CSS_ASSETS__; 15 | 16 | export default async function ssr(url: string, context: RenderContext) { 17 | return renderHtml(renderFn, url, template, css, context); 18 | } 19 | 20 | export type SSRFunction = typeof ssr; 21 | -------------------------------------------------------------------------------- /packages/capri/src/virtual/virtual.d.ts: -------------------------------------------------------------------------------- 1 | declare module "virtual:capri-hydration-adapter" { 2 | export default ( 3 | component: any, 4 | props: Record, 5 | element: Element, 6 | ) => any; 7 | } 8 | 9 | declare module "virtual:capri-server-entry" {} 10 | -------------------------------------------------------------------------------- /packages/capri/src/vite.d.ts: -------------------------------------------------------------------------------- 1 | import { ChunkMetadata } from "vite"; 2 | 3 | // Vite extends rollup's RenderedChunk interface but does not export its 4 | // declaration so we have to repeat it here: 5 | 6 | declare module "rollup" { 7 | export interface RenderedChunk { 8 | viteMetadata: ChunkMetadata; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/capri/ssr.d.ts: -------------------------------------------------------------------------------- 1 | declare module "virtual:capri-ssr" { 2 | const { default: ssr } = await import("./src/virtual/ssr.js"); 3 | export default ssr; 4 | } 5 | 6 | declare const __CSS_ASSETS__: string[]; 7 | -------------------------------------------------------------------------------- /packages/capri/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "types": ["vite/client"], 7 | "isolatedModules": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/cloudflare/README.md: -------------------------------------------------------------------------------- 1 | # Capri 🍋 SSR on Cloudflare Pages 2 | 3 | ## Usage 4 | 5 | ```ts 6 | // vite.config.ts 7 | import { defineConfig } from "vite"; 8 | import react from "@vitejs/plugin-react"; 9 | import capri from "@capri-js/react"; 10 | import cloudflare from "@capri-js/cloudflare"; 11 | 12 | export default defineConfig({ 13 | plugins: [ 14 | react(), 15 | capri({ 16 | target: cloudflare({ 17 | // options (see below) 18 | }), 19 | }), 20 | ], 21 | }); 22 | ``` 23 | 24 | ## Options 25 | 26 | The output can be configured using the following options: 27 | 28 | ### `webStreamsPolyfill` 29 | 30 | Some libraries like `react-dom/server` require 31 | the `streams_enable_constructors` feature flag to be enabled. As a workaround, you can set this option to `true`. 32 | 33 | ### `type` - What kind of function to create 34 | 35 | - When set to `"worker"`, Capri will generate a 36 | `_worker.js` file at the root of your output directory. In this case, Cloudflare will ignore any custom functions present in `/functions`. 37 | 38 | - When set to `"middleware`, Capri will generate a 39 | `/functions/_middleware.js` file instead. This allows 40 | you to use your own custom functions in addition to Capri. 41 | 42 | - When set to `"auto"` (default), Capri will generate a worker unless a `/functions` directory is present, in which 43 | case a middleware will be generated instead. 44 | -------------------------------------------------------------------------------- /packages/cloudflare/files/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 6 | 7 | 38 | 39 | 40 |
41 |

Error 404

42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /packages/cloudflare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@capri-js/cloudflare", 3 | "version": "1.2.3", 4 | "description": "", 5 | "author": "Felix Gnass ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/capri-js/capri", 9 | "directory": "packages/cloudflare" 10 | }, 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "./lib/index.js", 14 | "files": [ 15 | "lib", 16 | "files" 17 | ], 18 | "dependencies": { 19 | "capri": "^5.2.3", 20 | "web-streams-polyfill": "^3.2.1" 21 | }, 22 | "devDependencies": { 23 | "@cloudflare/workers-types": "^4.20231218.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/cloudflare/src/index.ts: -------------------------------------------------------------------------------- 1 | import { BuildTarget } from "capri/vite-plugin"; 2 | import * as path from "path"; 3 | 4 | type CloudflareOptions = { 5 | type?: "middleware" | "worker" | "auto"; 6 | webStreamsPolyfill?: boolean; 7 | }; 8 | 9 | export default function cloudflare({ 10 | type = "auto", 11 | webStreamsPolyfill = false, 12 | }: CloudflareOptions = {}): BuildTarget { 13 | return { 14 | config() { 15 | return { 16 | ssr: { 17 | target: "webworker", 18 | noExternal: true, 19 | }, 20 | }; 21 | }, 22 | async build({ rootDir, outDir, bundle, fsutils }) { 23 | const dirName = path.dirname(new URL(import.meta.url).pathname); 24 | const funcDir = path.resolve(rootDir, "functions"); 25 | const worker = path.resolve(outDir, "_worker.js"); 26 | const hasFunctions = fsutils.exists(funcDir); 27 | 28 | // Cloudflare ignores all functions if a _worker.js file is present 29 | if (hasFunctions && type === "worker") { 30 | console.warn( 31 | "Warning: The project contains a functions directory but type is set to 'worker'.", 32 | ); 33 | } 34 | 35 | const useWorker = type === "worker" || (type === "auto" && !hasFunctions); 36 | 37 | // Make sure no old _worker.js file is in the way 38 | if (!useWorker && fsutils.exists(worker)) { 39 | console.info("Removing old _worker.js file"); 40 | fsutils.rm(worker); 41 | } 42 | 43 | // Cloudflare treats projects without a 404 page as SPA. 44 | // To enable MPA mode we add a basic error page if no custom one exists. 45 | const notFound = path.resolve(outDir, "404.html"); 46 | if (!fsutils.exists(notFound)) { 47 | fsutils.copy( 48 | path.resolve(dirName, "..", "files", "404.html"), 49 | notFound, 50 | ); 51 | } 52 | 53 | let inject: string[] | undefined; 54 | if (webStreamsPolyfill) { 55 | inject = [path.resolve(dirName, "polyfill.js")]; 56 | } 57 | 58 | if (useWorker) { 59 | // Create the worker 60 | await bundle( 61 | path.resolve(dirName, "worker.js"), 62 | path.resolve(outDir, "_worker.js"), 63 | { 64 | inject, 65 | platform: "browser", 66 | }, 67 | ); 68 | } else { 69 | // Create the middleware 70 | await bundle( 71 | path.resolve(dirName, "middleware.js"), 72 | path.resolve(funcDir, "_middleware.js"), 73 | { 74 | inject, 75 | target: "es2017", 76 | platform: "browser", 77 | }, 78 | ); 79 | } 80 | }, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /packages/cloudflare/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import ssr from "virtual:capri-ssr"; 2 | 3 | const handler: PagesFunction = async ({ request, next }) => { 4 | // Handle the request as usual... 5 | const response = await next(); 6 | if ( 7 | response.status === 404 && 8 | request.headers.get("accept")?.includes("text/html") 9 | ) { 10 | // No static asset was found and the browser accepts html. 11 | // Try to render the requested page... 12 | const path = new URL(request.url).pathname; 13 | 14 | let status = 200; 15 | const headers = new Headers({ 16 | "Content-Type": "text/html; charset=utf-8", 17 | }); 18 | 19 | const html = await ssr(path, { 20 | status: (code) => { 21 | status = code; 22 | }, 23 | getHeader: request.headers.get.bind(request.headers), 24 | setHeader: headers.set.bind(headers), 25 | }); 26 | if (html) { 27 | return new Response(html, { status, headers }); 28 | } 29 | } 30 | return response; 31 | }; 32 | 33 | export const onRequest = [handler]; 34 | -------------------------------------------------------------------------------- /packages/cloudflare/src/polyfill.ts: -------------------------------------------------------------------------------- 1 | import "web-streams-polyfill/es6"; 2 | -------------------------------------------------------------------------------- /packages/cloudflare/src/worker.ts: -------------------------------------------------------------------------------- 1 | import ssr from "virtual:capri-ssr"; 2 | 3 | type Env = EventContext["env"]; 4 | const handler: ExportedHandler = { 5 | async fetch(request, env) { 6 | const response = await env.ASSETS.fetch(request); 7 | if ( 8 | response.status === 404 && 9 | request.headers.get("accept")?.includes("text/html") 10 | ) { 11 | const path = new URL(request.url).pathname; 12 | 13 | let status = 200; 14 | const headers = new Headers({ 15 | "Content-Type": "text/html; charset=utf-8", 16 | }); 17 | 18 | const html = await ssr(path, { 19 | status: (code) => { 20 | status = code; 21 | }, 22 | getHeader: request.headers.get.bind(request.headers), 23 | setHeader: headers.set.bind(headers), 24 | }); 25 | if (html) { 26 | return new Response(html, { status, headers }); 27 | } 28 | } 29 | return response; 30 | }, 31 | }; 32 | 33 | export default handler; 34 | -------------------------------------------------------------------------------- /packages/cloudflare/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "types": ["capri/ssr", "@cloudflare/workers-types"] 7 | }, 8 | "references": [{ "path": "../capri" }] 9 | } 10 | -------------------------------------------------------------------------------- /packages/create/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /packages/create/README.md: -------------------------------------------------------------------------------- 1 | # Create Capri 🌱 2 | 3 | The easiest way to get started with Capri is by using `@capri-js/create`. This CLI tool enables you to quickly start building a new Capri site, with everything set up for you. You can create a new site using the default template, or by using one of the [official examples](https://github.com/capri-js/capri/tree/main/examples). To get started, use the following command: 4 | 5 | ```bash 6 | npm init capri 7 | # or 8 | yarn create capri 9 | ``` 10 | 11 | To create a new project in a specific folder, you can pass a name as argument 12 | 13 | ```bash 14 | npm init capri -- my-capri-site 15 | # or 16 | yarn create capri my-capri-site 17 | ``` 18 | 19 | ## Options 20 | 21 | `create-capri` comes with the following options: 22 | 23 | - **-e, --example [name]|[github-url]** - An example to bootstrap the project with. You can use an example name from the [Capri repo](https://github.com/capri-js/capri/tree/main/examples) or a GitHub URL. The URL can use any branch and/or subdirectory. 24 | - **--example-path <path-to-example>** - In a rare case, your GitHub URL might contain a branch name with a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar). In this case, you must specify the path to the example separately: `--example-path foo/bar` 25 | 26 | ## Credits 27 | 28 | Thanks to [Vercel](https://vercel.com/) for [create-next-app](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) which was used as basis for `create-capri`. 29 | -------------------------------------------------------------------------------- /packages/create/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-capri", 3 | "version": "2.2.0", 4 | "description": "Create Capri projects with a single command", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/capri-js/capri", 8 | "directory": "packages/create" 9 | }, 10 | "bin": { 11 | "create-capri": "./lib/index.js" 12 | }, 13 | "type": "module", 14 | "dependencies": { 15 | "async-retry": "1.3.3", 16 | "chalk": "^5.3.0", 17 | "commander": "^11.1.0", 18 | "cpy": "^11.0.0", 19 | "cross-spawn": "7.0.3", 20 | "got": "^12.1.0", 21 | "prompts": "2.4.2", 22 | "rimraf": "^5.0.5", 23 | "tar": "^6.2.0", 24 | "validate-npm-package-name": "^5.0.0" 25 | }, 26 | "devDependencies": { 27 | "@types/async-retry": "^1.4.8", 28 | "@types/cross-spawn": "^6.0.6", 29 | "@types/node": "^20.10.6", 30 | "@types/prompts": "^2.4.9", 31 | "@types/tar": "^6.1.10", 32 | "@types/validate-npm-package-name": "^4.0.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/create/src/helpers/examples.ts: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | import { Stream } from "stream"; 3 | import tar from "tar"; 4 | import { promisify } from "util"; 5 | 6 | const pipeline = promisify(Stream.pipeline); 7 | 8 | export type RepoInfo = { 9 | username: string; 10 | name: string; 11 | branch: string; 12 | filePath: string; 13 | }; 14 | 15 | export async function isUrlOk(url: string): Promise { 16 | const res = await got.head(url).catch((e) => e); 17 | return res.statusCode === 200; 18 | } 19 | 20 | export async function getRepoInfo( 21 | url: URL, 22 | examplePath?: string, 23 | ): Promise { 24 | const [, username, name, t, _branch, ...file] = url.pathname.split("/"); 25 | const filePath = examplePath 26 | ? examplePath.replace(/^\//, "") 27 | : file.join("/"); 28 | 29 | // Support repos whose entire purpose is to be a Capri example, e.g. 30 | // https://github.com/:username/:my-example. 31 | if (t === undefined) { 32 | const infoResponse = await got( 33 | `https://api.github.com/repos/${username}/${name}`, 34 | ).catch((e) => e); 35 | if (infoResponse.statusCode !== 200) { 36 | return; 37 | } 38 | const info = JSON.parse(infoResponse.body); 39 | return { username, name, branch: info["default_branch"], filePath }; 40 | } 41 | 42 | // If examplePath is available, the branch name takes the entire path 43 | const branch = examplePath 44 | ? `${_branch}/${file.join("/")}`.replace(new RegExp(`/${filePath}|/$`), "") 45 | : _branch; 46 | 47 | if (username && name && branch && t === "tree") { 48 | return { username, name, branch, filePath }; 49 | } 50 | } 51 | 52 | export function hasRepo({ 53 | username, 54 | name, 55 | branch, 56 | filePath, 57 | }: RepoInfo): Promise { 58 | const contentsUrl = `https://api.github.com/repos/${username}/${name}/contents`; 59 | const packagePath = `${filePath ? `/${filePath}` : ""}/package.json`; 60 | 61 | return isUrlOk(contentsUrl + packagePath + `?ref=${branch}`); 62 | } 63 | 64 | export function hasExample(name: string): Promise { 65 | return isUrlOk( 66 | `https://api.github.com/repos/capri-js/capri/contents/examples/${encodeURIComponent( 67 | name, 68 | )}/package.json`, 69 | ); 70 | } 71 | 72 | export function downloadAndExtractRepo( 73 | root: string, 74 | { username, name, branch, filePath }: RepoInfo, 75 | ): Promise { 76 | return pipeline( 77 | got.stream( 78 | `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`, 79 | ), 80 | tar.extract( 81 | { cwd: root, strip: filePath ? filePath.split("/").length + 1 : 1 }, 82 | [`${name}-${branch}${filePath ? `/${filePath}` : ""}`], 83 | ), 84 | ); 85 | } 86 | 87 | export function downloadAndExtractExample( 88 | root: string, 89 | name: string, 90 | ): Promise { 91 | return pipeline( 92 | got.stream("https://codeload.github.com/capri-js/capri/tar.gz/main"), 93 | tar.extract({ cwd: root, strip: 3 }, [`capri-main/examples/${name}`]), 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /packages/create/src/helpers/get-pkg-manager.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | export type PackageManager = "npm" | "pnpm" | "yarn"; 4 | 5 | export function getPkgManager(): PackageManager { 6 | try { 7 | const userAgent = process.env.npm_config_user_agent; 8 | if (userAgent) { 9 | if (userAgent.startsWith("yarn")) { 10 | return "yarn"; 11 | } else if (userAgent.startsWith("pnpm")) { 12 | return "pnpm"; 13 | } 14 | } 15 | try { 16 | execSync("yarn --version", { stdio: "ignore" }); 17 | return "yarn"; 18 | } catch { 19 | execSync("pnpm --version", { stdio: "ignore" }); 20 | return "pnpm"; 21 | } 22 | } catch { 23 | return "npm"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/create/src/helpers/git.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import path from "path"; 3 | import { rimraf } from "rimraf"; 4 | 5 | function isInGitRepository() { 6 | try { 7 | execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); 8 | return true; 9 | } catch (_) { 10 | // ignore 11 | } 12 | return false; 13 | } 14 | 15 | function isInMercurialRepository() { 16 | try { 17 | execSync("hg --cwd . root", { stdio: "ignore" }); 18 | return true; 19 | } catch (_) { 20 | // ignore 21 | } 22 | return false; 23 | } 24 | 25 | export function tryGitInit(root: string) { 26 | let didInit = false; 27 | try { 28 | execSync("git --version", { stdio: "ignore" }); 29 | if (isInGitRepository() || isInMercurialRepository()) { 30 | return false; 31 | } 32 | 33 | execSync("git init", { stdio: "ignore" }); 34 | didInit = true; 35 | 36 | execSync("git checkout -b main", { stdio: "ignore" }); 37 | 38 | execSync("git add -A", { stdio: "ignore" }); 39 | execSync('git commit -m "initial commit"', { 40 | stdio: "ignore", 41 | }); 42 | return true; 43 | } catch (e) { 44 | if (didInit) { 45 | try { 46 | rimraf.sync(path.join(root, ".git")); 47 | } catch (_) { 48 | // ignore 49 | } 50 | } 51 | return false; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/create/src/helpers/is-folder-empty.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | export function isFolderEmpty(root: string, name: string) { 6 | const validFiles = [ 7 | ".DS_Store", 8 | ".git", 9 | ".gitattributes", 10 | ".gitignore", 11 | ".gitlab-ci.yml", 12 | ".hg", 13 | ".hgcheck", 14 | ".hgignore", 15 | ".idea", 16 | ".npmignore", 17 | ".travis.yml", 18 | "LICENSE", 19 | "Thumbs.db", 20 | "docs", 21 | "mkdocs.yml", 22 | "npm-debug.log", 23 | "yarn-debug.log", 24 | "yarn-error.log", 25 | ]; 26 | 27 | const conflicts = fs 28 | .readdirSync(root) 29 | .filter((file) => !validFiles.includes(file)) 30 | // Support IntelliJ IDEA-based editors 31 | .filter((file) => !/\.iml$/.test(file)); 32 | 33 | if (conflicts.length > 0) { 34 | console.log( 35 | `The directory ${chalk.green(name)} contains files that could conflict:`, 36 | ); 37 | console.log(); 38 | for (const file of conflicts) { 39 | try { 40 | const stats = fs.lstatSync(path.join(root, file)); 41 | if (stats.isDirectory()) { 42 | console.log(` ${chalk.blue(file)}/`); 43 | } else { 44 | console.log(` ${file}`); 45 | } 46 | } catch { 47 | console.log(` ${file}`); 48 | } 49 | } 50 | console.log(); 51 | console.log( 52 | "Either try using a new directory name, or remove the files listed above.", 53 | ); 54 | console.log(); 55 | return false; 56 | } 57 | 58 | return true; 59 | } 60 | -------------------------------------------------------------------------------- /packages/create/src/helpers/is-online.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import dns from "dns"; 3 | import url from "url"; 4 | 5 | function getProxy() { 6 | if (process.env.https_proxy) { 7 | return process.env.https_proxy; 8 | } 9 | 10 | try { 11 | const httpsProxy = execSync("npm config get https-proxy").toString().trim(); 12 | return httpsProxy !== "null" ? httpsProxy : undefined; 13 | } catch (e) { 14 | return; 15 | } 16 | } 17 | 18 | export function getOnline() { 19 | return new Promise((resolve) => { 20 | dns.lookup("registry.yarnpkg.com", (registryErr) => { 21 | if (!registryErr) { 22 | return resolve(true); 23 | } 24 | 25 | const proxy = getProxy(); 26 | if (!proxy) { 27 | return resolve(false); 28 | } 29 | 30 | const { hostname } = url.parse(proxy); 31 | if (!hostname) { 32 | return resolve(false); 33 | } 34 | 35 | dns.lookup(hostname, (proxyErr) => { 36 | resolve(proxyErr == null); 37 | }); 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /packages/create/src/helpers/is-writeable.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export async function isWriteable(directory: string) { 4 | try { 5 | await fs.promises.access(directory, (fs.constants ?? (fs as any)).W_OK); 6 | return true; 7 | } catch (err) { 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/create/src/helpers/make-dir.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export async function makeDir(root: string, options = { recursive: true }) { 4 | await fs.promises.mkdir(root, options); 5 | } 6 | -------------------------------------------------------------------------------- /packages/create/src/helpers/validate-pkg.ts: -------------------------------------------------------------------------------- 1 | import validateProjectName from "validate-npm-package-name"; 2 | 3 | export function validateNpmName(name: string): { 4 | valid: boolean; 5 | problems?: string[]; 6 | } { 7 | const nameValidation = validateProjectName(name); 8 | if (nameValidation.validForNewPackages) { 9 | return { valid: true }; 10 | } 11 | 12 | return { 13 | valid: false, 14 | problems: [ 15 | ...(nameValidation.errors || []), 16 | ...(nameValidation.warnings || []), 17 | ], 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /packages/create/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/preact/README.md: -------------------------------------------------------------------------------- 1 | # Preact bindings for Capri 🍋 2 | 3 | ## Usage 4 | 5 | ```ts 6 | // vite.config.ts 7 | import { defineConfig } from "vite"; 8 | import preact from "@preact/preset-vite"; 9 | import capri from "@capri-js/preact"; 10 | 11 | export default defineConfig({ 12 | plugins: [preact(), capri()], 13 | }); 14 | ``` 15 | 16 | Visit [capri.build](https://capri.build) for docs and more information. 17 | -------------------------------------------------------------------------------- /packages/preact/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@capri-js/preact", 3 | "version": "5.1.5", 4 | "description": "", 5 | "author": "Felix Gnass ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/capri-js/capri", 9 | "directory": "packages/preact" 10 | }, 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "./lib/index.js", 14 | "files": [ 15 | "lib" 16 | ], 17 | "exports": { 18 | ".": { 19 | "default": "./lib/index.js" 20 | }, 21 | "./server": { 22 | "default": "./lib/server.js" 23 | } 24 | }, 25 | "typesVersions": { 26 | "*": { 27 | "index.d.ts": [ 28 | "lib/index.d.ts" 29 | ], 30 | "server": [ 31 | "lib/server.d.ts" 32 | ] 33 | } 34 | }, 35 | "dependencies": { 36 | "capri": "^5.2.3", 37 | "preact-iso": "^2.3.2", 38 | "preact-render-to-string": "^6.3.1" 39 | }, 40 | "peerDependencies": { 41 | "preact": "^10.19.3", 42 | "vite": "3.x || 4.x" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/preact/src/capri.d.ts: -------------------------------------------------------------------------------- 1 | declare module "virtual:capri-component" { 2 | import { IslandOptions } from "capri"; 3 | import { ComponentType } from "preact"; 4 | 5 | const value: ComponentType; 6 | export default value; 7 | 8 | export const options: IslandOptions; 9 | } 10 | -------------------------------------------------------------------------------- /packages/preact/src/hydrate.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType, h, hydrate as hydrateComponent } from "preact"; 2 | 3 | export default function hydrate( 4 | component: ComponentType, 5 | props: Record, 6 | element: Element, 7 | ) { 8 | const children = element.querySelector("capri-children"); 9 | if (children) { 10 | props.children = h("capri-children", { 11 | style: { display: "contents" }, 12 | dangerouslySetInnerHTML: { __html: "" }, 13 | }); 14 | } 15 | return hydrateComponent(h(component, props), element.parentElement!); 16 | } 17 | -------------------------------------------------------------------------------- /packages/preact/src/index.ts: -------------------------------------------------------------------------------- 1 | import { capri, CapriAdapterPluginOptions } from "capri/vite-plugin"; 2 | 3 | export default function (opts: CapriAdapterPluginOptions = {}) { 4 | const resolve = (f: string) => new URL(f, import.meta.url).pathname; 5 | return capri({ 6 | ...opts, 7 | adapter: { 8 | hydrate: resolve("./hydrate.js"), 9 | island: { 10 | server: resolve("./island.server.jsx"), 11 | }, 12 | lagoon: { 13 | server: resolve("./lagoon.server.jsx"), 14 | client: resolve("./lagoon.client.jsx"), 15 | }, 16 | }, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /packages/preact/src/island.server.jsx: -------------------------------------------------------------------------------- 1 | import * as componentModule from "virtual:capri-component"; 2 | 3 | const { default: Component, options } = componentModule; 4 | 5 | function Island({ children, ...props }) { 6 | const wrappedChildren = children && ( 7 | {children} 8 | ); 9 | 10 | const scriptContent = JSON.stringify({ props, options }); 11 | return ( 12 | 13 | {wrappedChildren} 14 | 22 | `; 23 | }); 24 | -------------------------------------------------------------------------------- /packages/svelte/src/lagoon.client.js: -------------------------------------------------------------------------------- 1 | import { 2 | claim_element, 3 | init, 4 | insert_hydration, 5 | noop, 6 | safe_not_equal, 7 | SvelteComponent, 8 | } from "svelte/internal"; 9 | 10 | function create_fragment(ctx) { 11 | let el; 12 | return { 13 | c: noop, 14 | m(target, anchor) { 15 | insert_hydration(target, el, anchor); 16 | }, 17 | l(nodes) { 18 | el = claim_element(nodes, "CAPRI-LAGOON", {}); 19 | }, 20 | p: noop, 21 | d: noop, 22 | }; 23 | } 24 | 25 | export default class Lagoon extends SvelteComponent { 26 | constructor(options) { 27 | super(); 28 | init(this, options, null, create_fragment, safe_not_equal, {}); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/svelte/src/lagoon.server.js: -------------------------------------------------------------------------------- 1 | import { create_ssr_component } from "svelte/internal"; 2 | import component from "virtual:capri-component"; 3 | 4 | export default create_ssr_component((result, props, bindings, slots) => { 5 | const html = component.$$render(result, props, bindings, slots); 6 | return `${html}`; 7 | }); 8 | -------------------------------------------------------------------------------- /packages/svelte/src/server.ts: -------------------------------------------------------------------------------- 1 | export type { RenderFunction } from "capri"; 2 | -------------------------------------------------------------------------------- /packages/svelte/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src" 6 | }, 7 | "references": [{ "path": "../capri" }] 8 | } 9 | -------------------------------------------------------------------------------- /packages/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "strict": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowJs": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2020", 10 | "lib": ["ES2020", "DOM"], 11 | "declaration": true, 12 | "isolatedModules": true, 13 | "skipLibCheck": true, 14 | "jsx": "preserve" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "create" }, 4 | { "path": "preact" }, 5 | { "path": "react" }, 6 | { "path": "solid" }, 7 | { "path": "svelte" }, 8 | { "path": "vue" }, 9 | { "path": "vercel" }, 10 | { "path": "cloudflare" }, 11 | { "path": "react-render-to-string" } 12 | ], 13 | "exclude": ["*"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/vercel/README.md: -------------------------------------------------------------------------------- 1 | # Capri 🍋 SSR on Vercel 2 | 3 | Use `@capri-js/vercel` to render your static pages using either 4 | 5 | - a [serverless function](https://vercel.com/docs/concepts/functions/serverless-functions) 6 | - a [prerender function](https://vercel.com/docs/concepts/next.js/incremental-static-regeneration) 7 | - or an [edge function](https://vercel.com/docs/build-output-api/v3#vercel-primitives/edge-functions) 8 | 9 | _NOTE:_ This is only needed for SSR. For static sites that are prerendered upon build, 10 | leave the target set to `undefined`. 11 | 12 | https://vercel.com/docs/build-output-api/v3 13 | 14 | ## Usage 15 | 16 | ```ts 17 | // vite.config.ts 18 | import { defineConfig } from "vite"; 19 | import react from "@vitejs/plugin-react"; 20 | import capri from "@capri-js/react"; 21 | import vercel from "@capri-js/vercel"; 22 | 23 | export default defineConfig({ 24 | plugins: [ 25 | react(), 26 | capri({ 27 | target: vercel({ 28 | // options (see below) 29 | }), 30 | }), 31 | ], 32 | }); 33 | ``` 34 | 35 | ## Options 36 | 37 | ### `edge` 38 | 39 | Whether to create an edge function (default `false`). 40 | 41 | ### `isg` 42 | 43 | Settings for [Incremental Static Rendering](https://vercel.com/docs/build-output-api/v3#vercel-primitives/prerender-functions/configuration). 44 | 45 | ```ts 46 | { 47 | expiration: number | false; 48 | bypassToken?: string; 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /packages/vercel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@capri-js/vercel", 3 | "version": "1.1.3", 4 | "description": "", 5 | "author": "Felix Gnass ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/capri-js/capri", 9 | "directory": "packages/vercel" 10 | }, 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "./lib/index.js", 14 | "files": [ 15 | "lib" 16 | ], 17 | "dependencies": { 18 | "capri": "^5.2.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/vercel/src/edge.ts: -------------------------------------------------------------------------------- 1 | import ssr from "virtual:capri-ssr"; 2 | 3 | export default async (request: Request) => { 4 | const url = new URL(request.url).pathname; 5 | 6 | let status = 200; 7 | const headers = new Headers({ 8 | "Content-Type": "text/html; charset=utf-8", 9 | }); 10 | 11 | const html = await ssr(url, { 12 | status(code) { 13 | status = code; 14 | }, 15 | getHeader: request.headers.get.bind(request.headers), 16 | setHeader: headers.set.bind(headers), 17 | }); 18 | return new Response(html, { 19 | status, 20 | headers, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/vercel/src/index.ts: -------------------------------------------------------------------------------- 1 | import { BuildTarget } from "capri/vite-plugin"; 2 | import { builtinModules } from "module"; 3 | import * as path from "path"; 4 | 5 | interface VercelConfig { 6 | edge?: boolean; 7 | isg?: { 8 | expiration: number | false; 9 | bypassToken?: string; 10 | }; 11 | } 12 | export default function vercel({ 13 | edge = false, 14 | isg, 15 | }: VercelConfig = {}): BuildTarget { 16 | return { 17 | config() { 18 | return { 19 | build: { 20 | outDir: path.join(".vercel", "output", "static"), 21 | }, 22 | ssr: { 23 | target: edge ? "webworker" : "node", 24 | // Inline everything except for node builtins... 25 | noExternal: /.*/, 26 | external: builtinModules, 27 | }, 28 | }; 29 | }, 30 | async build({ outDir, bundle, fsutils }) { 31 | const dirName = path.dirname(new URL(import.meta.url).pathname); 32 | const rootDir = path.resolve(outDir, ".."); 33 | const funcDir = path.resolve(rootDir, "functions", "render.func"); 34 | 35 | if (!edge) { 36 | // Create package.json to enable ESM 37 | fsutils.write( 38 | path.resolve(funcDir, "package.json"), 39 | JSON.stringify({ type: "module" }), 40 | ); 41 | } 42 | 43 | // Create the handler 44 | await bundle( 45 | path.resolve( 46 | dirName, 47 | edge ? "edge.js" : isg ? "isg.js" : "serverless.js", 48 | ), 49 | path.resolve(funcDir, "index.js"), 50 | { 51 | platform: edge ? "browser" : "node", 52 | }, 53 | ); 54 | 55 | // Create .vc-config.json 56 | const runtime = edge 57 | ? { runtime: "edge" } 58 | : { runtime: "nodejs16.x", launcherType: "Nodejs" }; 59 | 60 | fsutils.write( 61 | path.resolve(funcDir, ".vc-config.json"), 62 | JSON.stringify({ 63 | ...runtime, 64 | [edge ? "entrypoint" : "handler"]: "index.js", 65 | }), 66 | ); 67 | 68 | if (isg) { 69 | if (edge) { 70 | throw new Error( 71 | "Incremental Static Generation is not supported on the edge.", 72 | ); 73 | } 74 | fsutils.write( 75 | path.resolve(funcDir, "..", "render.prerender-config.json"), 76 | JSON.stringify({ allowQuery: [], ...isg }), 77 | ); 78 | } 79 | 80 | // Create config.json 81 | fsutils.write( 82 | path.resolve(rootDir, "config.json"), 83 | JSON.stringify({ 84 | version: 3, 85 | routes: [ 86 | { 87 | handle: "filesystem", 88 | }, 89 | { 90 | src: isg ? "(?.*)" : "/.*", 91 | [edge ? "middlewarePath" : "dest"]: "render", 92 | }, 93 | ], 94 | }), 95 | ); 96 | }, 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /packages/vercel/src/isg.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "node:http"; 2 | 3 | import ssr from "virtual:capri-ssr"; 4 | 5 | export default async (req: IncomingMessage, res: ServerResponse) => { 6 | if (!req.headers.accept?.includes("text/html")) { 7 | return res.writeHead(404).end(); 8 | } 9 | const route = req.headers["x-now-route-matches"]; 10 | if (typeof route !== "string") { 11 | return res.writeHead(400, "Header x-now-route-matches expected").end(); 12 | } 13 | const match = decodeURIComponent(new URLSearchParams(route).get("1")!); 14 | res.setHeader("Content-Type", "text/html; charset=utf-8"); 15 | const html = await ssr(match, { 16 | status(code) { 17 | res.statusCode = code; 18 | }, 19 | getHeader(name: string) { 20 | const header = req.headers[name] ?? null; 21 | return Array.isArray(header) ? header[0] : header; 22 | }, 23 | setHeader: res.setHeader.bind(res), 24 | }); 25 | res.end(html); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/vercel/src/serverless.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "node:http"; 2 | 3 | import ssr from "virtual:capri-ssr"; 4 | 5 | export default async (req: IncomingMessage, res: ServerResponse) => { 6 | if (!req.headers.accept?.includes("text/html")) { 7 | res.writeHead(404).end(); 8 | } else { 9 | res.setHeader("Content-Type", "text/html; charset=utf-8"); 10 | const html = await ssr(req.url!, { 11 | status(code) { 12 | res.statusCode = code; 13 | }, 14 | getHeader(name: string) { 15 | const header = req.headers[name] ?? null; 16 | return Array.isArray(header) ? header[0] : header; 17 | }, 18 | setHeader: res.setHeader.bind(res), 19 | }); 20 | res.end(html); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /packages/vercel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "types": ["capri/ssr"] 7 | }, 8 | "references": [{ "path": "../capri" }] 9 | } 10 | -------------------------------------------------------------------------------- /packages/vue/README.md: -------------------------------------------------------------------------------- 1 | # Vue bindings for Capri 🍋 2 | 3 | ## Usage 4 | 5 | ```ts 6 | // vite.config.ts 7 | import { defineConfig } from "vite"; 8 | import react from "@vitejs/plugin-vue"; 9 | import capri from "@capri-js/vue"; 10 | 11 | export default defineConfig({ 12 | plugins: [react(), capri()], 13 | }); 14 | ``` 15 | 16 | Visit [capri.build](https://capri.build) for docs and more information. 17 | -------------------------------------------------------------------------------- /packages/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@capri-js/vue", 3 | "version": "2.1.4", 4 | "description": "", 5 | "author": "Felix Gnass ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/capri-js/capri", 9 | "directory": "packages/vue" 10 | }, 11 | "license": "MIT", 12 | "type": "module", 13 | "main": "./lib/index.js", 14 | "files": [ 15 | "lib" 16 | ], 17 | "exports": { 18 | ".": { 19 | "default": "./lib/index.js" 20 | }, 21 | "./server": { 22 | "default": "./lib/server.js" 23 | } 24 | }, 25 | "typesVersions": { 26 | "*": { 27 | "index.d.ts": [ 28 | "lib/index.d.ts" 29 | ], 30 | "server": [ 31 | "lib/server.d.ts" 32 | ] 33 | } 34 | }, 35 | "dependencies": { 36 | "capri": "^5.2.3" 37 | }, 38 | "devDependencies": { 39 | "vue": "^3.2.37" 40 | }, 41 | "peerDependencies": { 42 | "vite": "3.x || 4.x", 43 | "vue": "^3.2.37" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/vue/src/capri.d.ts: -------------------------------------------------------------------------------- 1 | declare module "virtual:capri-component" { 2 | import { IslandOptions } from "capri"; 3 | import { ComponentType } from "react"; 4 | 5 | const value: ComponentType; 6 | export default value; 7 | 8 | export const options: IslandOptions; 9 | } 10 | -------------------------------------------------------------------------------- /packages/vue/src/hydrate.ts: -------------------------------------------------------------------------------- 1 | import { createSSRApp, h } from "vue"; 2 | 3 | export default function hydrate( 4 | component: any, 5 | props: Record, 6 | element: Element, 7 | ) { 8 | const slots: any = {}; 9 | element.querySelectorAll("capri-slot").forEach((el) => { 10 | const name = el.getAttribute("name")!; 11 | slots[name] = h("capri-slot", { 12 | name, 13 | style: "display:contents", 14 | innerHTML: el.innerHTML, 15 | }); 16 | }); 17 | const app = createSSRApp({ 18 | /* name, */ render: () => h(component, props, slots), 19 | }); 20 | app.mount(element.parentElement!); 21 | } 22 | -------------------------------------------------------------------------------- /packages/vue/src/index.ts: -------------------------------------------------------------------------------- 1 | import { capri, CapriAdapterPluginOptions } from "capri/vite-plugin"; 2 | 3 | export default function (opts: CapriAdapterPluginOptions = {}) { 4 | const resolve = (f: string) => new URL(f, import.meta.url).pathname; 5 | return capri({ 6 | ...opts, 7 | adapter: { 8 | injectWrapper: "onTransform", 9 | hydrate: resolve("./hydrate.js"), 10 | island: { 11 | server: resolve("./island.server.js"), 12 | }, 13 | lagoon: { 14 | server: resolve("./lagoon.server.jsx"), 15 | client: resolve("./lagoon.client.jsx"), 16 | }, 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /packages/vue/src/island.server.js: -------------------------------------------------------------------------------- 1 | import * as componentModule from "virtual:capri-component"; 2 | import { withCtx } from "vue"; 3 | import { ssrRenderComponent, ssrRenderSlot } from "vue/server-renderer"; 4 | 5 | const { default: component, options } = componentModule; 6 | 7 | const __sfc__ = { 8 | __name: "Island", 9 | __ssrInlineRender: true, 10 | setup(__props) { 11 | return (_ctx, _push, _parent, _attrs) => { 12 | const slots = Object.fromEntries( 13 | Object.entries(_ctx.$slots).map(([name, fn]) => [ 14 | name, 15 | withCtx((_, _push, _parent, _scopeId) => { 16 | _push(``); 17 | ssrRenderSlot( 18 | _ctx.$slots, 19 | name, 20 | {}, 21 | null, 22 | _push, 23 | _parent, 24 | _scopeId, 25 | ); 26 | _push(``); 27 | }), 28 | ]), 29 | ); 30 | 31 | _push(``); 32 | _push(ssrRenderComponent(component, _attrs, slots, _parent)); 33 | _push( 34 | ``, 37 | ); 38 | _push(``); 39 | }; 40 | }, 41 | }; 42 | __sfc__.__file = "%COMPONENT_ID%"; 43 | export default __sfc__; 44 | -------------------------------------------------------------------------------- /packages/vue/src/lagoon.client.jsx: -------------------------------------------------------------------------------- 1 | import { createElementVNode, createStaticVNode } from "vue"; 2 | 3 | export default { 4 | render() { 5 | return createElementVNode("capri-lagoon", {}, [createStaticVNode("", 1)]); 6 | }, 7 | __file: "%COMPONENT_ID%", 8 | }; 9 | -------------------------------------------------------------------------------- /packages/vue/src/lagoon.server.jsx: -------------------------------------------------------------------------------- 1 | import component from "virtual:capri-component"; 2 | import { ssrRenderComponent } from "vue/server-renderer"; 3 | 4 | const __sfc__ = { 5 | __name: "Lagoon", 6 | __ssrInlineRender: true, 7 | setup(__props) { 8 | return (_ctx, _push, _parent, _attrs) => { 9 | _push(``); 10 | _push(ssrRenderComponent(component, _attrs, _ctx.$slots, _parent)); 11 | _push(``); 12 | }; 13 | }, 14 | }; 15 | __sfc__.__file = "%COMPONENT_ID%"; 16 | export default __sfc__; 17 | -------------------------------------------------------------------------------- /packages/vue/src/server.ts: -------------------------------------------------------------------------------- 1 | export type { RenderFunction } from "capri"; 2 | -------------------------------------------------------------------------------- /packages/vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src" 6 | }, 7 | "references": [ 8 | { "path": "../capri" }, 9 | { "path": "../react-render-to-string" } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/dom.ts: -------------------------------------------------------------------------------- 1 | import legacy from "@vitejs/plugin-legacy"; 2 | import { JSDOM } from "jsdom"; 3 | import * as path from "path"; 4 | import { build } from "vite"; 5 | 6 | export async function getExampleDOM(root: string) { 7 | const base = path.join(root, "dist") + "/"; 8 | await build({ root, base, plugins: [legacy()] }); 9 | await build({ root, base, build: { ssr: true } }); 10 | const dom = await JSDOM.fromFile(path.join(root, "dist", "index.html"), { 11 | url: `file://${root}/dist/index.html`, 12 | runScripts: "dangerously", 13 | resources: "usable", 14 | }); 15 | return dom; 16 | } 17 | -------------------------------------------------------------------------------- /test/e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { waitFor, within } from "@testing-library/dom"; 2 | import * as path from "path"; 3 | import { describe, expect, test } from "vitest"; 4 | 5 | import { getExampleDOM } from "./dom.js"; 6 | 7 | describe("examples", () => { 8 | test.each([["vue"], ["react"], ["preact"], ["solid"], ["svelte"]])( 9 | "%s", 10 | async (example) => { 11 | const root = path.resolve(__dirname, "../examples", example); 12 | const dom = await getExampleDOM(root); 13 | 14 | global.window = dom.window as any; 15 | global.document = dom.window.document; 16 | 17 | const { default: userEvent } = await import( 18 | "@testing-library/user-event" 19 | ); 20 | const user = userEvent.setup({ document }); 21 | 22 | const screen = within(dom.window.document.body); 23 | const styles = document.querySelector('link[rel="stylesheet"]'); 24 | expect(styles).toBeTruthy(); 25 | 26 | const [counter1, counter2] = await screen.findAllByTestId("counter"); 27 | const value1 = counter1.querySelector("span"); 28 | const value2 = counter2.querySelector("span"); 29 | const inc1 = await within(counter1).findByText("+"); 30 | const inc2 = await within(counter2).findByText("+"); 31 | 32 | expect(value1).toHaveTextContent("0"); 33 | expect(value2).toHaveTextContent("100"); 34 | 35 | await waitFor(async () => { 36 | await user.click(inc1); 37 | expect(value1?.textContent).not.toBe("0"); 38 | }); 39 | 40 | await user.click(inc2); 41 | await waitFor(async () => { 42 | expect(value2?.textContent).toBe("101"); 43 | }); 44 | }, 45 | { 46 | timeout: 60000, 47 | }, 48 | ); 49 | }); 50 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | // Extend Vitest's matchers with the ones provided by Testing Library: 2 | import "@testing-library/jest-dom"; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowJs": true, 7 | "allowSyntheticDefaultImports": true, 8 | "target": "ES2020", 9 | "lib": ["ES2020", "DOM"], 10 | "skipLibCheck": true, 11 | "noEmit": true 12 | }, 13 | "include": ["./*.ts", "./.eslintrc.cjs", "./test"] 14 | } 15 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | setupFiles: "test/setup.ts", 7 | testTimeout: 15000, 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------