├── .github └── assets │ └── gh-banner.jpg ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── packages ├── create-sapling │ ├── .gitignore │ ├── .vscode │ │ └── settings.json │ ├── README.md │ ├── create-sapling.mjs │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── name-generator.ts │ │ └── templates.ts │ ├── tsconfig.json │ └── vite.config.ts ├── create │ ├── .gitignore │ ├── README.md │ ├── deno.json │ └── src │ │ ├── index.ts │ │ ├── name-generator.ts │ │ └── templates.ts ├── image │ ├── README.md │ ├── deno.json │ ├── deno.lock │ ├── example │ │ └── image.ts │ └── src │ │ ├── index.ts │ │ ├── optimizeImages │ │ ├── deno.ts │ │ ├── index.ts │ │ ├── node.ts │ │ └── types.ts │ │ └── picture │ │ └── picture.ts ├── markdown │ ├── .gitignore │ ├── README.md │ ├── deno.json │ └── src │ │ └── index.ts ├── sapling-island │ ├── README.md │ ├── index.js │ └── package.json └── sapling │ ├── .gitignore │ ├── README.md │ ├── deno.json │ ├── example │ └── main.ts │ └── src │ ├── constants.ts │ ├── html-stream-layout.ts │ ├── index.ts │ ├── prerender │ ├── deno.ts │ ├── index.ts │ └── node.ts │ ├── sapling-layout.ts │ ├── sapling.ts │ └── types │ └── index.ts └── scripts └── saplingVersion.ts /.github/assets/gh-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/withsapling/sapling/e671a4c7ead0463f3a29453e21477ee3d1427bc5/.github/assets/gh-banner.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 - Present, Treefarm Studio LLC 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![A Simpler Web Framework](.github/assets/gh-banner.jpg 'A Simpler Web Framework') 2 | 3 | 4 | # Sapling 5 | 6 | A Modern SSR framework for generating content-first websites. 7 | 8 | - Multi-runtime, meaning you can use it with [Deno](https://deno.com/), [Node](https://nodejs.org/), [Bun](https://bun.sh/), and [Cloudflare Workers](https://developers.cloudflare.com/workers/). 9 | - Support for Tailwind CSS via [UnoCSS](https://github.com/unocss/unocss). 10 | - Completely buildless meaning it has no build step and does not rely on a bundler. 11 | 12 | 13 | ## Getting Started 14 | 15 | Deno 16 | 17 | ```bash 18 | deno -A jsr:@sapling/create 19 | ``` 20 | 21 | Node 22 | 23 | ```bash 24 | npm create sapling@latest 25 | ``` 26 | 27 | Bun 28 | 29 | ```bash 30 | bunx create-sapling@latest 31 | ``` 32 | 33 | ## Packages 34 | 35 | | Package | Description | Version | 36 | |---------|-------------|-----| 37 | | [sapling](./packages/sapling/) | A micro SSR framework | [![JSR](https://jsr.io/badges/@sapling/sapling)](https://jsr.io/@sapling/sapling) | 38 | | [create](./packages/create/) | A CLI for creating Sapling projects | [![JSR](https://jsr.io/badges/@sapling/create)](https://jsr.io/@sapling/create) | 39 | | [markdown](./packages/markdown/) | A markdown parser for Sapling sites or Deno projects | [![JSR](https://jsr.io/badges/@sapling/markdown)](https://jsr.io/@sapling/markdown) | 40 | | [image](./packages/image/) | A powerful image optimization library | [![JSR](https://jsr.io/badges/@sapling/image)](https://jsr.io/@sapling/image) | 41 | | [create-sapling](./packages/create-sapling/) | A CLI for creating Sapling projects with npm | [![npm](https://img.shields.io/npm/v/create-sapling.svg)](https://www.npmjs.com/package/create-sapling) | 42 | | [sapling-island](./packages/sapling-island/) | A web component for partial hydration | [CDN](https://sapling-is.land) | 43 | 44 | ## Examples 45 | 46 | We would recommend checking out the [Sapling Examples Repository](https://github.com/withsapling/examples) for more examples of how to use Sapling. 47 | 48 | ## Attributions 49 | 50 | - [Hono](https://github.com/honojs/hono) - Our html and raw HTML helpers are based on Hono's to allow for easy migration between Sapling and Hono. We are also huge fans of their routing approach which is why we've structured Sapling's API in a similar way. 51 | - [UnoCSS](https://github.com/unocss/unocss) - We use UnoCSS for atomic CSS. 52 | - [marked](https://github.com/markedjs/marked) - Our markdown parser. 53 | - [shiki](https://github.com/shikijs/shiki) - The code highlighter for syntax highlighting. 54 | - [wasm-image-optimization](https://github.com/node-libraries/wasm-image-optimization) - The WASM-based image optimization library we use for image processing. 55 | -------------------------------------------------------------------------------- /packages/create-sapling/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | dist/ 4 | -------------------------------------------------------------------------------- /packages/create-sapling/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": false 3 | } 4 | -------------------------------------------------------------------------------- /packages/create-sapling/README.md: -------------------------------------------------------------------------------- 1 | # Create Sapling 2 | 3 | Create Sapling is a CLI tool that helps you create new Sapling projects. 4 | 5 | 6 | ## Usage 7 | 8 | ```bash 9 | npm create sapling@latest 10 | ``` 11 | -------------------------------------------------------------------------------- /packages/create-sapling/create-sapling.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | const currentVersion = process.versions.node; 6 | const requiredMajorVersion = parseInt(currentVersion.split(".")[0], 10); 7 | const minimumMajorVersion = 18; 8 | 9 | if (requiredMajorVersion < minimumMajorVersion) { 10 | console.error(`Node.js v${currentVersion} is out of date and unsupported!`); 11 | console.error(`Please use Node.js v${minimumMajorVersion} or higher.`); 12 | process.exit(1); 13 | } 14 | 15 | import("./dist/index.js").then((module) => module.default()); 16 | -------------------------------------------------------------------------------- /packages/create-sapling/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-sapling", 3 | "version": "0.3.2", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "create-sapling", 9 | "version": "0.3.2", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@clack/prompts": "^0.8.2", 13 | "degit": "^2.8.4", 14 | "vite": "^6.0.1" 15 | }, 16 | "bin": { 17 | "create-sapling": "create-sapling.mjs" 18 | }, 19 | "engines": { 20 | "node": "^18.17.1 || ^20.3.0 || >=21.0.0" 21 | } 22 | }, 23 | "node_modules/@clack/core": { 24 | "version": "0.3.5", 25 | "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", 26 | "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", 27 | "license": "MIT", 28 | "dependencies": { 29 | "picocolors": "^1.0.0", 30 | "sisteransi": "^1.0.5" 31 | } 32 | }, 33 | "node_modules/@clack/prompts": { 34 | "version": "0.8.2", 35 | "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.8.2.tgz", 36 | "integrity": "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==", 37 | "license": "MIT", 38 | "dependencies": { 39 | "@clack/core": "0.3.5", 40 | "picocolors": "^1.0.0", 41 | "sisteransi": "^1.0.5" 42 | } 43 | }, 44 | "node_modules/@esbuild/aix-ppc64": { 45 | "version": "0.24.0", 46 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", 47 | "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", 48 | "cpu": [ 49 | "ppc64" 50 | ], 51 | "license": "MIT", 52 | "optional": true, 53 | "os": [ 54 | "aix" 55 | ], 56 | "engines": { 57 | "node": ">=18" 58 | } 59 | }, 60 | "node_modules/@esbuild/android-arm": { 61 | "version": "0.24.0", 62 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", 63 | "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", 64 | "cpu": [ 65 | "arm" 66 | ], 67 | "license": "MIT", 68 | "optional": true, 69 | "os": [ 70 | "android" 71 | ], 72 | "engines": { 73 | "node": ">=18" 74 | } 75 | }, 76 | "node_modules/@esbuild/android-arm64": { 77 | "version": "0.24.0", 78 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", 79 | "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", 80 | "cpu": [ 81 | "arm64" 82 | ], 83 | "license": "MIT", 84 | "optional": true, 85 | "os": [ 86 | "android" 87 | ], 88 | "engines": { 89 | "node": ">=18" 90 | } 91 | }, 92 | "node_modules/@esbuild/android-x64": { 93 | "version": "0.24.0", 94 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", 95 | "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", 96 | "cpu": [ 97 | "x64" 98 | ], 99 | "license": "MIT", 100 | "optional": true, 101 | "os": [ 102 | "android" 103 | ], 104 | "engines": { 105 | "node": ">=18" 106 | } 107 | }, 108 | "node_modules/@esbuild/darwin-arm64": { 109 | "version": "0.24.0", 110 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", 111 | "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", 112 | "cpu": [ 113 | "arm64" 114 | ], 115 | "license": "MIT", 116 | "optional": true, 117 | "os": [ 118 | "darwin" 119 | ], 120 | "engines": { 121 | "node": ">=18" 122 | } 123 | }, 124 | "node_modules/@esbuild/darwin-x64": { 125 | "version": "0.24.0", 126 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", 127 | "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", 128 | "cpu": [ 129 | "x64" 130 | ], 131 | "license": "MIT", 132 | "optional": true, 133 | "os": [ 134 | "darwin" 135 | ], 136 | "engines": { 137 | "node": ">=18" 138 | } 139 | }, 140 | "node_modules/@esbuild/freebsd-arm64": { 141 | "version": "0.24.0", 142 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", 143 | "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", 144 | "cpu": [ 145 | "arm64" 146 | ], 147 | "license": "MIT", 148 | "optional": true, 149 | "os": [ 150 | "freebsd" 151 | ], 152 | "engines": { 153 | "node": ">=18" 154 | } 155 | }, 156 | "node_modules/@esbuild/freebsd-x64": { 157 | "version": "0.24.0", 158 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", 159 | "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", 160 | "cpu": [ 161 | "x64" 162 | ], 163 | "license": "MIT", 164 | "optional": true, 165 | "os": [ 166 | "freebsd" 167 | ], 168 | "engines": { 169 | "node": ">=18" 170 | } 171 | }, 172 | "node_modules/@esbuild/linux-arm": { 173 | "version": "0.24.0", 174 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", 175 | "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", 176 | "cpu": [ 177 | "arm" 178 | ], 179 | "license": "MIT", 180 | "optional": true, 181 | "os": [ 182 | "linux" 183 | ], 184 | "engines": { 185 | "node": ">=18" 186 | } 187 | }, 188 | "node_modules/@esbuild/linux-arm64": { 189 | "version": "0.24.0", 190 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", 191 | "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", 192 | "cpu": [ 193 | "arm64" 194 | ], 195 | "license": "MIT", 196 | "optional": true, 197 | "os": [ 198 | "linux" 199 | ], 200 | "engines": { 201 | "node": ">=18" 202 | } 203 | }, 204 | "node_modules/@esbuild/linux-ia32": { 205 | "version": "0.24.0", 206 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", 207 | "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", 208 | "cpu": [ 209 | "ia32" 210 | ], 211 | "license": "MIT", 212 | "optional": true, 213 | "os": [ 214 | "linux" 215 | ], 216 | "engines": { 217 | "node": ">=18" 218 | } 219 | }, 220 | "node_modules/@esbuild/linux-loong64": { 221 | "version": "0.24.0", 222 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", 223 | "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", 224 | "cpu": [ 225 | "loong64" 226 | ], 227 | "license": "MIT", 228 | "optional": true, 229 | "os": [ 230 | "linux" 231 | ], 232 | "engines": { 233 | "node": ">=18" 234 | } 235 | }, 236 | "node_modules/@esbuild/linux-mips64el": { 237 | "version": "0.24.0", 238 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", 239 | "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", 240 | "cpu": [ 241 | "mips64el" 242 | ], 243 | "license": "MIT", 244 | "optional": true, 245 | "os": [ 246 | "linux" 247 | ], 248 | "engines": { 249 | "node": ">=18" 250 | } 251 | }, 252 | "node_modules/@esbuild/linux-ppc64": { 253 | "version": "0.24.0", 254 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", 255 | "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", 256 | "cpu": [ 257 | "ppc64" 258 | ], 259 | "license": "MIT", 260 | "optional": true, 261 | "os": [ 262 | "linux" 263 | ], 264 | "engines": { 265 | "node": ">=18" 266 | } 267 | }, 268 | "node_modules/@esbuild/linux-riscv64": { 269 | "version": "0.24.0", 270 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", 271 | "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", 272 | "cpu": [ 273 | "riscv64" 274 | ], 275 | "license": "MIT", 276 | "optional": true, 277 | "os": [ 278 | "linux" 279 | ], 280 | "engines": { 281 | "node": ">=18" 282 | } 283 | }, 284 | "node_modules/@esbuild/linux-s390x": { 285 | "version": "0.24.0", 286 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", 287 | "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", 288 | "cpu": [ 289 | "s390x" 290 | ], 291 | "license": "MIT", 292 | "optional": true, 293 | "os": [ 294 | "linux" 295 | ], 296 | "engines": { 297 | "node": ">=18" 298 | } 299 | }, 300 | "node_modules/@esbuild/linux-x64": { 301 | "version": "0.24.0", 302 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", 303 | "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", 304 | "cpu": [ 305 | "x64" 306 | ], 307 | "license": "MIT", 308 | "optional": true, 309 | "os": [ 310 | "linux" 311 | ], 312 | "engines": { 313 | "node": ">=18" 314 | } 315 | }, 316 | "node_modules/@esbuild/netbsd-x64": { 317 | "version": "0.24.0", 318 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", 319 | "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", 320 | "cpu": [ 321 | "x64" 322 | ], 323 | "license": "MIT", 324 | "optional": true, 325 | "os": [ 326 | "netbsd" 327 | ], 328 | "engines": { 329 | "node": ">=18" 330 | } 331 | }, 332 | "node_modules/@esbuild/openbsd-arm64": { 333 | "version": "0.24.0", 334 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", 335 | "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", 336 | "cpu": [ 337 | "arm64" 338 | ], 339 | "license": "MIT", 340 | "optional": true, 341 | "os": [ 342 | "openbsd" 343 | ], 344 | "engines": { 345 | "node": ">=18" 346 | } 347 | }, 348 | "node_modules/@esbuild/openbsd-x64": { 349 | "version": "0.24.0", 350 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", 351 | "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", 352 | "cpu": [ 353 | "x64" 354 | ], 355 | "license": "MIT", 356 | "optional": true, 357 | "os": [ 358 | "openbsd" 359 | ], 360 | "engines": { 361 | "node": ">=18" 362 | } 363 | }, 364 | "node_modules/@esbuild/sunos-x64": { 365 | "version": "0.24.0", 366 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", 367 | "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", 368 | "cpu": [ 369 | "x64" 370 | ], 371 | "license": "MIT", 372 | "optional": true, 373 | "os": [ 374 | "sunos" 375 | ], 376 | "engines": { 377 | "node": ">=18" 378 | } 379 | }, 380 | "node_modules/@esbuild/win32-arm64": { 381 | "version": "0.24.0", 382 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", 383 | "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", 384 | "cpu": [ 385 | "arm64" 386 | ], 387 | "license": "MIT", 388 | "optional": true, 389 | "os": [ 390 | "win32" 391 | ], 392 | "engines": { 393 | "node": ">=18" 394 | } 395 | }, 396 | "node_modules/@esbuild/win32-ia32": { 397 | "version": "0.24.0", 398 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", 399 | "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", 400 | "cpu": [ 401 | "ia32" 402 | ], 403 | "license": "MIT", 404 | "optional": true, 405 | "os": [ 406 | "win32" 407 | ], 408 | "engines": { 409 | "node": ">=18" 410 | } 411 | }, 412 | "node_modules/@esbuild/win32-x64": { 413 | "version": "0.24.0", 414 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", 415 | "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", 416 | "cpu": [ 417 | "x64" 418 | ], 419 | "license": "MIT", 420 | "optional": true, 421 | "os": [ 422 | "win32" 423 | ], 424 | "engines": { 425 | "node": ">=18" 426 | } 427 | }, 428 | "node_modules/@rollup/rollup-android-arm-eabi": { 429 | "version": "4.27.4", 430 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.4.tgz", 431 | "integrity": "sha512-2Y3JT6f5MrQkICUyRVCw4oa0sutfAsgaSsb0Lmmy1Wi2y7X5vT9Euqw4gOsCyy0YfKURBg35nhUKZS4mDcfULw==", 432 | "cpu": [ 433 | "arm" 434 | ], 435 | "license": "MIT", 436 | "optional": true, 437 | "os": [ 438 | "android" 439 | ] 440 | }, 441 | "node_modules/@rollup/rollup-android-arm64": { 442 | "version": "4.27.4", 443 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.4.tgz", 444 | "integrity": "sha512-wzKRQXISyi9UdCVRqEd0H4cMpzvHYt1f/C3CoIjES6cG++RHKhrBj2+29nPF0IB5kpy9MS71vs07fvrNGAl/iA==", 445 | "cpu": [ 446 | "arm64" 447 | ], 448 | "license": "MIT", 449 | "optional": true, 450 | "os": [ 451 | "android" 452 | ] 453 | }, 454 | "node_modules/@rollup/rollup-darwin-arm64": { 455 | "version": "4.27.4", 456 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.4.tgz", 457 | "integrity": "sha512-PlNiRQapift4LNS8DPUHuDX/IdXiLjf8mc5vdEmUR0fF/pyy2qWwzdLjB+iZquGr8LuN4LnUoSEvKRwjSVYz3Q==", 458 | "cpu": [ 459 | "arm64" 460 | ], 461 | "license": "MIT", 462 | "optional": true, 463 | "os": [ 464 | "darwin" 465 | ] 466 | }, 467 | "node_modules/@rollup/rollup-darwin-x64": { 468 | "version": "4.27.4", 469 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.4.tgz", 470 | "integrity": "sha512-o9bH2dbdgBDJaXWJCDTNDYa171ACUdzpxSZt+u/AAeQ20Nk5x+IhA+zsGmrQtpkLiumRJEYef68gcpn2ooXhSQ==", 471 | "cpu": [ 472 | "x64" 473 | ], 474 | "license": "MIT", 475 | "optional": true, 476 | "os": [ 477 | "darwin" 478 | ] 479 | }, 480 | "node_modules/@rollup/rollup-freebsd-arm64": { 481 | "version": "4.27.4", 482 | "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.4.tgz", 483 | "integrity": "sha512-NBI2/i2hT9Q+HySSHTBh52da7isru4aAAo6qC3I7QFVsuhxi2gM8t/EI9EVcILiHLj1vfi+VGGPaLOUENn7pmw==", 484 | "cpu": [ 485 | "arm64" 486 | ], 487 | "license": "MIT", 488 | "optional": true, 489 | "os": [ 490 | "freebsd" 491 | ] 492 | }, 493 | "node_modules/@rollup/rollup-freebsd-x64": { 494 | "version": "4.27.4", 495 | "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.4.tgz", 496 | "integrity": "sha512-wYcC5ycW2zvqtDYrE7deary2P2UFmSh85PUpAx+dwTCO9uw3sgzD6Gv9n5X4vLaQKsrfTSZZ7Z7uynQozPVvWA==", 497 | "cpu": [ 498 | "x64" 499 | ], 500 | "license": "MIT", 501 | "optional": true, 502 | "os": [ 503 | "freebsd" 504 | ] 505 | }, 506 | "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 507 | "version": "4.27.4", 508 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.4.tgz", 509 | "integrity": "sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==", 510 | "cpu": [ 511 | "arm" 512 | ], 513 | "license": "MIT", 514 | "optional": true, 515 | "os": [ 516 | "linux" 517 | ] 518 | }, 519 | "node_modules/@rollup/rollup-linux-arm-musleabihf": { 520 | "version": "4.27.4", 521 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.4.tgz", 522 | "integrity": "sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==", 523 | "cpu": [ 524 | "arm" 525 | ], 526 | "license": "MIT", 527 | "optional": true, 528 | "os": [ 529 | "linux" 530 | ] 531 | }, 532 | "node_modules/@rollup/rollup-linux-arm64-gnu": { 533 | "version": "4.27.4", 534 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.4.tgz", 535 | "integrity": "sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==", 536 | "cpu": [ 537 | "arm64" 538 | ], 539 | "license": "MIT", 540 | "optional": true, 541 | "os": [ 542 | "linux" 543 | ] 544 | }, 545 | "node_modules/@rollup/rollup-linux-arm64-musl": { 546 | "version": "4.27.4", 547 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.4.tgz", 548 | "integrity": "sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==", 549 | "cpu": [ 550 | "arm64" 551 | ], 552 | "license": "MIT", 553 | "optional": true, 554 | "os": [ 555 | "linux" 556 | ] 557 | }, 558 | "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { 559 | "version": "4.27.4", 560 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.4.tgz", 561 | "integrity": "sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==", 562 | "cpu": [ 563 | "ppc64" 564 | ], 565 | "license": "MIT", 566 | "optional": true, 567 | "os": [ 568 | "linux" 569 | ] 570 | }, 571 | "node_modules/@rollup/rollup-linux-riscv64-gnu": { 572 | "version": "4.27.4", 573 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.4.tgz", 574 | "integrity": "sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==", 575 | "cpu": [ 576 | "riscv64" 577 | ], 578 | "license": "MIT", 579 | "optional": true, 580 | "os": [ 581 | "linux" 582 | ] 583 | }, 584 | "node_modules/@rollup/rollup-linux-s390x-gnu": { 585 | "version": "4.27.4", 586 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.4.tgz", 587 | "integrity": "sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==", 588 | "cpu": [ 589 | "s390x" 590 | ], 591 | "license": "MIT", 592 | "optional": true, 593 | "os": [ 594 | "linux" 595 | ] 596 | }, 597 | "node_modules/@rollup/rollup-linux-x64-gnu": { 598 | "version": "4.27.4", 599 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.4.tgz", 600 | "integrity": "sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==", 601 | "cpu": [ 602 | "x64" 603 | ], 604 | "license": "MIT", 605 | "optional": true, 606 | "os": [ 607 | "linux" 608 | ] 609 | }, 610 | "node_modules/@rollup/rollup-linux-x64-musl": { 611 | "version": "4.27.4", 612 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.4.tgz", 613 | "integrity": "sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==", 614 | "cpu": [ 615 | "x64" 616 | ], 617 | "license": "MIT", 618 | "optional": true, 619 | "os": [ 620 | "linux" 621 | ] 622 | }, 623 | "node_modules/@rollup/rollup-win32-arm64-msvc": { 624 | "version": "4.27.4", 625 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.4.tgz", 626 | "integrity": "sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==", 627 | "cpu": [ 628 | "arm64" 629 | ], 630 | "license": "MIT", 631 | "optional": true, 632 | "os": [ 633 | "win32" 634 | ] 635 | }, 636 | "node_modules/@rollup/rollup-win32-ia32-msvc": { 637 | "version": "4.27.4", 638 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.4.tgz", 639 | "integrity": "sha512-KtwEJOaHAVJlxV92rNYiG9JQwQAdhBlrjNRp7P9L8Cb4Rer3in+0A+IPhJC9y68WAi9H0sX4AiG2NTsVlmqJeQ==", 640 | "cpu": [ 641 | "ia32" 642 | ], 643 | "license": "MIT", 644 | "optional": true, 645 | "os": [ 646 | "win32" 647 | ] 648 | }, 649 | "node_modules/@rollup/rollup-win32-x64-msvc": { 650 | "version": "4.27.4", 651 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.4.tgz", 652 | "integrity": "sha512-3j4jx1TppORdTAoBJRd+/wJRGCPC0ETWkXOecJ6PPZLj6SptXkrXcNqdj0oclbKML6FkQltdz7bBA3rUSirZug==", 653 | "cpu": [ 654 | "x64" 655 | ], 656 | "license": "MIT", 657 | "optional": true, 658 | "os": [ 659 | "win32" 660 | ] 661 | }, 662 | "node_modules/@types/estree": { 663 | "version": "1.0.6", 664 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", 665 | "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", 666 | "license": "MIT" 667 | }, 668 | "node_modules/degit": { 669 | "version": "2.8.4", 670 | "resolved": "https://registry.npmjs.org/degit/-/degit-2.8.4.tgz", 671 | "integrity": "sha512-vqYuzmSA5I50J882jd+AbAhQtgK6bdKUJIex1JNfEUPENCgYsxugzKVZlFyMwV4i06MmnV47/Iqi5Io86zf3Ng==", 672 | "license": "MIT", 673 | "bin": { 674 | "degit": "degit" 675 | }, 676 | "engines": { 677 | "node": ">=8.0.0" 678 | } 679 | }, 680 | "node_modules/esbuild": { 681 | "version": "0.24.0", 682 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", 683 | "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", 684 | "hasInstallScript": true, 685 | "license": "MIT", 686 | "bin": { 687 | "esbuild": "bin/esbuild" 688 | }, 689 | "engines": { 690 | "node": ">=18" 691 | }, 692 | "optionalDependencies": { 693 | "@esbuild/aix-ppc64": "0.24.0", 694 | "@esbuild/android-arm": "0.24.0", 695 | "@esbuild/android-arm64": "0.24.0", 696 | "@esbuild/android-x64": "0.24.0", 697 | "@esbuild/darwin-arm64": "0.24.0", 698 | "@esbuild/darwin-x64": "0.24.0", 699 | "@esbuild/freebsd-arm64": "0.24.0", 700 | "@esbuild/freebsd-x64": "0.24.0", 701 | "@esbuild/linux-arm": "0.24.0", 702 | "@esbuild/linux-arm64": "0.24.0", 703 | "@esbuild/linux-ia32": "0.24.0", 704 | "@esbuild/linux-loong64": "0.24.0", 705 | "@esbuild/linux-mips64el": "0.24.0", 706 | "@esbuild/linux-ppc64": "0.24.0", 707 | "@esbuild/linux-riscv64": "0.24.0", 708 | "@esbuild/linux-s390x": "0.24.0", 709 | "@esbuild/linux-x64": "0.24.0", 710 | "@esbuild/netbsd-x64": "0.24.0", 711 | "@esbuild/openbsd-arm64": "0.24.0", 712 | "@esbuild/openbsd-x64": "0.24.0", 713 | "@esbuild/sunos-x64": "0.24.0", 714 | "@esbuild/win32-arm64": "0.24.0", 715 | "@esbuild/win32-ia32": "0.24.0", 716 | "@esbuild/win32-x64": "0.24.0" 717 | } 718 | }, 719 | "node_modules/fsevents": { 720 | "version": "2.3.3", 721 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 722 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 723 | "hasInstallScript": true, 724 | "license": "MIT", 725 | "optional": true, 726 | "os": [ 727 | "darwin" 728 | ], 729 | "engines": { 730 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 731 | } 732 | }, 733 | "node_modules/nanoid": { 734 | "version": "3.3.8", 735 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", 736 | "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", 737 | "funding": [ 738 | { 739 | "type": "github", 740 | "url": "https://github.com/sponsors/ai" 741 | } 742 | ], 743 | "license": "MIT", 744 | "bin": { 745 | "nanoid": "bin/nanoid.cjs" 746 | }, 747 | "engines": { 748 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 749 | } 750 | }, 751 | "node_modules/picocolors": { 752 | "version": "1.1.1", 753 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 754 | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 755 | "license": "ISC" 756 | }, 757 | "node_modules/postcss": { 758 | "version": "8.4.49", 759 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", 760 | "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", 761 | "funding": [ 762 | { 763 | "type": "opencollective", 764 | "url": "https://opencollective.com/postcss/" 765 | }, 766 | { 767 | "type": "tidelift", 768 | "url": "https://tidelift.com/funding/github/npm/postcss" 769 | }, 770 | { 771 | "type": "github", 772 | "url": "https://github.com/sponsors/ai" 773 | } 774 | ], 775 | "license": "MIT", 776 | "dependencies": { 777 | "nanoid": "^3.3.7", 778 | "picocolors": "^1.1.1", 779 | "source-map-js": "^1.2.1" 780 | }, 781 | "engines": { 782 | "node": "^10 || ^12 || >=14" 783 | } 784 | }, 785 | "node_modules/rollup": { 786 | "version": "4.27.4", 787 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.4.tgz", 788 | "integrity": "sha512-RLKxqHEMjh/RGLsDxAEsaLO3mWgyoU6x9w6n1ikAzet4B3gI2/3yP6PWY2p9QzRTh6MfEIXB3MwsOY0Iv3vNrw==", 789 | "license": "MIT", 790 | "dependencies": { 791 | "@types/estree": "1.0.6" 792 | }, 793 | "bin": { 794 | "rollup": "dist/bin/rollup" 795 | }, 796 | "engines": { 797 | "node": ">=18.0.0", 798 | "npm": ">=8.0.0" 799 | }, 800 | "optionalDependencies": { 801 | "@rollup/rollup-android-arm-eabi": "4.27.4", 802 | "@rollup/rollup-android-arm64": "4.27.4", 803 | "@rollup/rollup-darwin-arm64": "4.27.4", 804 | "@rollup/rollup-darwin-x64": "4.27.4", 805 | "@rollup/rollup-freebsd-arm64": "4.27.4", 806 | "@rollup/rollup-freebsd-x64": "4.27.4", 807 | "@rollup/rollup-linux-arm-gnueabihf": "4.27.4", 808 | "@rollup/rollup-linux-arm-musleabihf": "4.27.4", 809 | "@rollup/rollup-linux-arm64-gnu": "4.27.4", 810 | "@rollup/rollup-linux-arm64-musl": "4.27.4", 811 | "@rollup/rollup-linux-powerpc64le-gnu": "4.27.4", 812 | "@rollup/rollup-linux-riscv64-gnu": "4.27.4", 813 | "@rollup/rollup-linux-s390x-gnu": "4.27.4", 814 | "@rollup/rollup-linux-x64-gnu": "4.27.4", 815 | "@rollup/rollup-linux-x64-musl": "4.27.4", 816 | "@rollup/rollup-win32-arm64-msvc": "4.27.4", 817 | "@rollup/rollup-win32-ia32-msvc": "4.27.4", 818 | "@rollup/rollup-win32-x64-msvc": "4.27.4", 819 | "fsevents": "~2.3.2" 820 | } 821 | }, 822 | "node_modules/sisteransi": { 823 | "version": "1.0.5", 824 | "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", 825 | "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", 826 | "license": "MIT" 827 | }, 828 | "node_modules/source-map-js": { 829 | "version": "1.2.1", 830 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 831 | "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 832 | "license": "BSD-3-Clause", 833 | "engines": { 834 | "node": ">=0.10.0" 835 | } 836 | }, 837 | "node_modules/vite": { 838 | "version": "6.0.1", 839 | "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.1.tgz", 840 | "integrity": "sha512-Ldn6gorLGr4mCdFnmeAOLweJxZ34HjKnDm4HGo6P66IEqTxQb36VEdFJQENKxWjupNfoIjvRUnswjn1hpYEpjQ==", 841 | "license": "MIT", 842 | "dependencies": { 843 | "esbuild": "^0.24.0", 844 | "postcss": "^8.4.49", 845 | "rollup": "^4.23.0" 846 | }, 847 | "bin": { 848 | "vite": "bin/vite.js" 849 | }, 850 | "engines": { 851 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 852 | }, 853 | "funding": { 854 | "url": "https://github.com/vitejs/vite?sponsor=1" 855 | }, 856 | "optionalDependencies": { 857 | "fsevents": "~2.3.3" 858 | }, 859 | "peerDependencies": { 860 | "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 861 | "jiti": ">=1.21.0", 862 | "less": "*", 863 | "lightningcss": "^1.21.0", 864 | "sass": "*", 865 | "sass-embedded": "*", 866 | "stylus": "*", 867 | "sugarss": "*", 868 | "terser": "^5.16.0", 869 | "tsx": "^4.8.1", 870 | "yaml": "^2.4.2" 871 | }, 872 | "peerDependenciesMeta": { 873 | "@types/node": { 874 | "optional": true 875 | }, 876 | "jiti": { 877 | "optional": true 878 | }, 879 | "less": { 880 | "optional": true 881 | }, 882 | "lightningcss": { 883 | "optional": true 884 | }, 885 | "sass": { 886 | "optional": true 887 | }, 888 | "sass-embedded": { 889 | "optional": true 890 | }, 891 | "stylus": { 892 | "optional": true 893 | }, 894 | "sugarss": { 895 | "optional": true 896 | }, 897 | "terser": { 898 | "optional": true 899 | }, 900 | "tsx": { 901 | "optional": true 902 | }, 903 | "yaml": { 904 | "optional": true 905 | } 906 | } 907 | } 908 | } 909 | } 910 | -------------------------------------------------------------------------------- /packages/create-sapling/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-sapling", 3 | "author": "withsapling", 4 | "readme": "README.md", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/withsapling/sapling/tree/main/packages/create-sapling" 8 | }, 9 | "files": [ 10 | "dist/", 11 | "create-sapling.mjs" 12 | ], 13 | "version": "0.3.2", 14 | "type": "module", 15 | "main": "./create-sapling.mjs", 16 | "exports": { 17 | ".": "./create-sapling.mjs" 18 | }, 19 | "bin": { 20 | "create-sapling": "./create-sapling.mjs" 21 | }, 22 | "scripts": { 23 | "build": "vite build", 24 | "test": "echo \"Error: no test specified\" && exit 1" 25 | }, 26 | "keywords": [], 27 | "license": "MIT", 28 | "description": "", 29 | "dependencies": { 30 | "@clack/prompts": "^0.8.2", 31 | "degit": "^2.8.4", 32 | "vite": "^6.0.1" 33 | }, 34 | "engines": { 35 | "node": "^18.17.1 || ^20.3.0 || >=21.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/create-sapling/src/index.ts: -------------------------------------------------------------------------------- 1 | import { intro, outro, text, select, isCancel, spinner } from "@clack/prompts"; 2 | import degit from "degit"; 3 | import { generateName } from "./name-generator.ts"; 4 | import { templates } from "./templates.ts"; 5 | import { execSync } from "child_process"; 6 | 7 | // Helper function to execute a command with a timeout 8 | const executeWithTimeout = (command, options, timeout = 120000) => { 9 | return new Promise((resolve, reject) => { 10 | const timer = setTimeout(() => { 11 | reject(new Error(`Operation timed out after ${timeout / 1000} seconds`)); 12 | }, timeout); 13 | 14 | try { 15 | const result = execSync(command, options); 16 | clearTimeout(timer); 17 | resolve(result); 18 | } catch (error) { 19 | clearTimeout(timer); 20 | reject(error); 21 | } 22 | }); 23 | }; 24 | 25 | // Export the init function so it can be called from other files 26 | export default async function init() { 27 | intro(`Welcome to Sapling 🌲`); 28 | 29 | const repo = await select({ 30 | message: "Select a project to clone:", 31 | options: templates.map((template) => ({ 32 | label: template.name, 33 | value: template.repo, 34 | })), 35 | }); 36 | 37 | const template = templates.find((template) => template.repo === repo); 38 | 39 | if (isCancel(repo)) { 40 | outro("Operation cancelled"); 41 | Deno.exit(0); 42 | } 43 | 44 | const suggestedName = generateName(); 45 | 46 | const targetDir = await text({ 47 | message: "Enter the project directory:", 48 | placeholder: `./${suggestedName}`, 49 | initialValue: `./${suggestedName}`, 50 | }); 51 | 52 | if (isCancel(targetDir)) { 53 | outro("Operation cancelled"); 54 | Deno.exit(0); 55 | } 56 | 57 | const emitter = await degit(repo, { 58 | force: true, 59 | }); 60 | 61 | await emitter.clone(targetDir); 62 | 63 | const installDeps = await select({ 64 | message: "Would you like to install dependencies?", 65 | options: [ 66 | { label: "Yes", value: true }, 67 | { label: "No", value: false }, 68 | ], 69 | }); 70 | 71 | if (isCancel(installDeps)) { 72 | outro("Operation cancelled"); 73 | Deno.exit(0); 74 | } 75 | 76 | if (installDeps) { 77 | const s = spinner(); 78 | s.start("Installing dependencies..."); 79 | 80 | try { 81 | await executeWithTimeout( 82 | "npm install --no-audit", 83 | { cwd: targetDir }, 84 | 120000 85 | ); 86 | s.stop("Dependencies installed successfully"); 87 | } catch (error) { 88 | s.stop("Failed to install dependencies"); 89 | if (error.message.includes("timed out")) { 90 | console.error( 91 | "Installation timed out after 120 seconds. Please try running 'npm install' manually." 92 | ); 93 | } else { 94 | console.error("Error details:", error); 95 | } 96 | } 97 | } 98 | 99 | const nextSteps = `Next steps:\n\n 1. cd ${targetDir}\n\n 2. ${!installDeps ? "npm install\n\n 3. " : "" 100 | }${template?.outro}`; 101 | 102 | outro(nextSteps); 103 | } 104 | -------------------------------------------------------------------------------- /packages/create-sapling/src/name-generator.ts: -------------------------------------------------------------------------------- 1 | const colors = [ 2 | "white", 3 | "black", 4 | "amber", 5 | "azure", 6 | "crimson", 7 | "emerald", 8 | "indigo", 9 | "jade", 10 | "maple", 11 | "ruby", 12 | "sapphire", 13 | "teal", 14 | "violet", 15 | "red", 16 | "sapphire", 17 | "hazel", 18 | ]; 19 | 20 | const trees = [ 21 | "aspen", 22 | "birch", 23 | "cedar", 24 | "elm", 25 | "maple", 26 | "oak", 27 | "pine", 28 | "redwood", 29 | "sequoia", 30 | "spruce", 31 | "willow", 32 | "cypress", 33 | "magnolia", 34 | "juniper", 35 | "sycamore", 36 | "beech", 37 | "hemlock", 38 | "poplar", 39 | "chestnut", 40 | "larch", 41 | "acacia", 42 | "alder", 43 | "ash", 44 | "eucalyptus", 45 | "fir", 46 | "hickory", 47 | "mahogany", 48 | "palm", 49 | "teak", 50 | "walnut", 51 | ]; 52 | 53 | export function generateName() { 54 | const randomColor = colors[Math.floor(Math.random() * colors.length)]; 55 | const randomTree = trees[Math.floor(Math.random() * trees.length)]; 56 | 57 | return `${randomColor}-${randomTree}`; 58 | } 59 | -------------------------------------------------------------------------------- /packages/create-sapling/src/templates.ts: -------------------------------------------------------------------------------- 1 | export const templates = [ 2 | { 3 | name: "Basics (recommended)", 4 | repo: "https://github.com/withsapling/examples/node/basics", 5 | outro: "npm run dev", 6 | }, 7 | { 8 | name: "Hello World", 9 | repo: "https://github.com/withsapling/examples/node/hello-sapling", 10 | outro: "npm run dev", 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /packages/create-sapling/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Node", 5 | "target": "ESNext" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/create-sapling/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { resolve } from "path"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | target: "node18", 7 | lib: { 8 | entry: resolve(__dirname, "src/index.ts"), 9 | formats: ["es"], 10 | fileName: () => "index.js", 11 | }, 12 | rollupOptions: { 13 | external: ["@clack/prompts", "degit", "child_process"], 14 | }, 15 | outDir: "dist", 16 | emptyOutDir: true, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/create/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .deno 3 | .vscode 4 | 5 | .DS_Store -------------------------------------------------------------------------------- /packages/create/README.md: -------------------------------------------------------------------------------- 1 | [![JSR](https://jsr.io/badges/@sapling/create)](https://jsr.io/@sapling/create) 2 | 3 | # Sapling 4 | 5 | A CLI for creating Sapling projects. 6 | 7 | ## Usage 8 | 9 | ```bash 10 | deno -A jsr:@sapling/create 11 | ``` 12 | 13 | You're here really early. More to come soon. 14 | 15 | -------------------------------------------------------------------------------- /packages/create/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sapling/create", 3 | "description": "A CLI for creating Sapling projects", 4 | "license": "MIT", 5 | "version": "0.1.0", 6 | "imports": { 7 | "@clack/prompts": "npm:@clack/prompts@^0.7.0", 8 | "degit": "npm:degit@^2.8.4" 9 | }, 10 | "exports": { 11 | ".": "./src/index.ts" 12 | }, 13 | "exclude": ["node_modules"], 14 | "lock": false, 15 | "nodeModulesDir": "auto" 16 | } 17 | -------------------------------------------------------------------------------- /packages/create/src/index.ts: -------------------------------------------------------------------------------- 1 | import { intro, outro, text, select, isCancel } from '@clack/prompts'; 2 | import degit from "degit"; 3 | import { generateName } from './name-generator.ts'; 4 | import { templates } from './templates.ts'; 5 | 6 | 7 | // Export the init function so it can be called from other files 8 | export async function init() { 9 | intro(`Welcome to Sapling 🌲`); 10 | 11 | const repo = await select({ 12 | message: "Select a project to clone:", 13 | options: templates.map((template) => ({ 14 | label: template.name, 15 | value: template.repo, 16 | })), 17 | }); 18 | 19 | const template = templates.find((template) => template.repo === repo); 20 | 21 | if (isCancel(repo)) { 22 | outro('Operation cancelled'); 23 | Deno.exit(0); 24 | } 25 | 26 | const suggestedName = generateName(); 27 | 28 | const targetDir = await text({ 29 | message: "Enter the project directory:", 30 | placeholder: `./${suggestedName}`, 31 | initialValue: `./${suggestedName}`, 32 | }); 33 | 34 | if (isCancel(targetDir)) { 35 | outro('Operation cancelled'); 36 | Deno.exit(0); 37 | } 38 | 39 | const emitter = await degit(repo, { 40 | force: true, 41 | }); 42 | 43 | await emitter.clone(targetDir); 44 | 45 | const nextSteps = `Next steps:\n\n 1. cd ${targetDir}\n\n 2. ${template?.outro}`; 46 | 47 | outro(nextSteps); 48 | } 49 | 50 | // Add this to allow running directly from command line 51 | if (import.meta.main) { 52 | await init(); 53 | } 54 | -------------------------------------------------------------------------------- /packages/create/src/name-generator.ts: -------------------------------------------------------------------------------- 1 | const colors = [ 2 | 'white', 3 | 'black', 4 | 'amber', 5 | 'azure', 6 | 'crimson', 7 | 'emerald', 8 | 'indigo', 9 | 'jade', 10 | 'maple', 11 | 'ruby', 12 | 'sapphire', 13 | 'teal', 14 | 'violet', 15 | 'red', 16 | 'sapphire', 17 | 'hazel', 18 | ]; 19 | 20 | const trees = [ 21 | 'aspen', 22 | 'birch', 23 | 'cedar', 24 | 'elm', 25 | 'maple', 26 | 'oak', 27 | 'pine', 28 | 'redwood', 29 | 'sequoia', 30 | 'spruce', 31 | 'willow', 32 | 'cypress', 33 | 'magnolia', 34 | 'juniper', 35 | 'sycamore', 36 | 'beech', 37 | 'hemlock', 38 | 'poplar', 39 | 'chestnut', 40 | 'larch', 41 | 'acacia', 42 | 'alder', 43 | 'ash', 44 | 'eucalyptus', 45 | 'fir', 46 | 'hickory', 47 | 'mahogany', 48 | 'palm', 49 | 'teak', 50 | 'walnut', 51 | ]; 52 | 53 | export function generateName(): string { 54 | const randomColor = colors[Math.floor(Math.random() * colors.length)]; 55 | const randomTree = trees[Math.floor(Math.random() * trees.length)]; 56 | 57 | return `${randomColor}-${randomTree}`; 58 | } 59 | -------------------------------------------------------------------------------- /packages/create/src/templates.ts: -------------------------------------------------------------------------------- 1 | export const templates = [ 2 | { 3 | name: "Basics (recommended)", 4 | repo: "https://github.com/withsapling/examples/deno/basics", 5 | outro: "deno task dev", 6 | }, 7 | { 8 | name: "Hello World", 9 | repo: "https://github.com/withsapling/examples/deno/hello-sapling", 10 | outro: "deno task dev", 11 | }, 12 | ]; 13 | 14 | -------------------------------------------------------------------------------- /packages/image/README.md: -------------------------------------------------------------------------------- 1 | # @sapling/image 2 | 3 | A powerful image optimization library that makes it easy to process and optimize images in various formats and sizes. 4 | 5 | ## Features 6 | 7 | - 🖼️ Process both individual images and entire directories 8 | - 📏 Generate multiple sizes of each image (small, medium, large) 9 | - 🎨 Support for multiple output formats (AVIF, WebP, JPEG, PNG) 10 | - ⚡ Efficient processing with WASM-based optimization 11 | - 🔄 Skip existing files to avoid redundant processing 12 | - ⚙️ Highly configurable with custom sizes and quality settings 13 | 14 | ## Installation 15 | 16 | ```ts 17 | import { optimizeImages } from "@sapling/image"; 18 | ``` 19 | 20 | ## Usage 21 | 22 | ### Basic Usage 23 | 24 | ```ts 25 | await optimizeImages({ 26 | entries: [ 27 | { 28 | input: "src/images/gallery", 29 | output: "static/images/gallery" 30 | }, 31 | { 32 | input: "src/images/hero.jpg", 33 | output: "static/images/home" 34 | } 35 | ] 36 | }); 37 | ``` 38 | 39 | ### Advanced Configuration 40 | 41 | ```ts 42 | await optimizeImages({ 43 | entries: [ 44 | { 45 | input: "src/images/gallery", 46 | output: "static/images/gallery" 47 | } 48 | ], 49 | // Custom sizes with different quality settings 50 | sizes: [ 51 | { suffix: "sm", width: 640, quality: 80 }, 52 | { suffix: "md", width: 1280, quality: 85 }, 53 | { suffix: "lg", width: 1920, quality: 90 } 54 | ], 55 | // Choose output format 56 | format: "webp", 57 | // Set default quality for sizes without specific quality 58 | defaultQuality: 85 59 | }); 60 | ``` 61 | 62 | ## Configuration Options 63 | 64 | ### OptimizeImagesConfig 65 | 66 | | Option | Type | Default | Description | 67 | |--------|------|---------|-------------| 68 | | `entries` | `Array<{input: string, output: string}>` | Required | Array of input/output path pairs | 69 | | `sizes` | `ImageSize[]` | See below | Array of size configurations | 70 | | `format` | `"avif" \| "webp" \| "jpeg" \| "png"` | `"avif"` | Output format for all images | 71 | | `defaultQuality` | `number` | `85` | Default quality setting (1-100) | 72 | 73 | ### Default Sizes 74 | 75 | ```ts 76 | const DEFAULT_SIZES = [ 77 | { suffix: "sm", width: 640 }, 78 | { suffix: "md", width: 1280 }, 79 | { suffix: "lg", width: 1920 } 80 | ]; 81 | ``` 82 | 83 | ### ImageSize Interface 84 | 85 | | Property | Type | Required | Description | 86 | |----------|------|----------|-------------| 87 | | `suffix` | `string` | Yes | Suffix added to the filename (e.g., "-sm") | 88 | | `width` | `number` | Yes | Target width in pixels | 89 | | `quality` | `number` | No | Optional quality override (1-100) | 90 | 91 | ## Supported Input Formats 92 | 93 | - JPEG (.jpg, .jpeg) 94 | - PNG (.png) 95 | - WebP (.webp) 96 | - AVIF (.avif) 97 | 98 | ## Output 99 | 100 | The library will create optimized versions of your images with the following naming convention: 101 | 102 | ``` 103 | {original-name}-{size-suffix}.{format} 104 | ``` 105 | 106 | Example: 107 | - Input: `hero.jpg` 108 | - Output: `hero-sm.webp`, `hero-md.webp`, `hero-lg.webp` 109 | 110 | ## Progress Tracking 111 | 112 | The library provides detailed console output during processing: 113 | 114 | - ✅ Successfully optimized images 115 | - ⏭️ Skipped existing files 116 | - ❌ Error messages for failed optimizations 117 | - ⏱️ Total processing time 118 | 119 | ## Error Handling 120 | 121 | The library includes comprehensive error handling: 122 | - Skips files that already exist 123 | - Reports detailed error messages for failed optimizations 124 | - Creates output directories automatically 125 | - Validates input paths and formats 126 | -------------------------------------------------------------------------------- /packages/image/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sapling/image", 3 | "version": "0.4.0", 4 | "imports": { 5 | "@hono/hono": "jsr:@hono/hono@^4.7.7", 6 | "@std/fs": "jsr:@std/fs@^1.0.14", 7 | "@std/path": "jsr:@std/path@^1.0.8", 8 | "wasm-image-optimization": "npm:wasm-image-optimization@^1.2.28" 9 | }, 10 | "exports": { 11 | ".": "./src/index.ts" 12 | }, 13 | "license": "MIT", 14 | "exclude": ["example", "node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/image/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@hono/hono@^4.7.7": "4.7.7", 5 | "jsr:@std/fs@^1.0.14": "1.0.14", 6 | "jsr:@std/path@^1.0.8": "1.0.8", 7 | "npm:@types/node@*": "22.12.0", 8 | "npm:glob@*": "11.0.1", 9 | "npm:wasm-image-optimization@*": "1.2.28", 10 | "npm:wasm-image-optimization@^1.2.28": "1.2.28" 11 | }, 12 | "jsr": { 13 | "@hono/hono@4.7.7": { 14 | "integrity": "74ea9985cb405fada079a922538f6baa06fa1ee150ce409c5bdae2c89ac05cba" 15 | }, 16 | "@std/fs@1.0.14": { 17 | "integrity": "1e84bf0b95fe08f41f1f4aea9717bbf29f45408a56ce073b0114474ce1c9fccf", 18 | "dependencies": [ 19 | "jsr:@std/path" 20 | ] 21 | }, 22 | "@std/path@1.0.8": { 23 | "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" 24 | } 25 | }, 26 | "npm": { 27 | "@isaacs/cliui@8.0.2": { 28 | "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", 29 | "dependencies": [ 30 | "string-width-cjs@npm:string-width@4.2.3", 31 | "string-width@5.1.2", 32 | "strip-ansi-cjs@npm:strip-ansi@6.0.1", 33 | "strip-ansi@7.1.0", 34 | "wrap-ansi-cjs@npm:wrap-ansi@7.0.0", 35 | "wrap-ansi@8.1.0" 36 | ] 37 | }, 38 | "@types/node@22.12.0": { 39 | "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", 40 | "dependencies": [ 41 | "undici-types" 42 | ] 43 | }, 44 | "ansi-regex@5.0.1": { 45 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" 46 | }, 47 | "ansi-regex@6.1.0": { 48 | "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==" 49 | }, 50 | "ansi-styles@4.3.0": { 51 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 52 | "dependencies": [ 53 | "color-convert" 54 | ] 55 | }, 56 | "ansi-styles@6.2.1": { 57 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==" 58 | }, 59 | "balanced-match@1.0.2": { 60 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 61 | }, 62 | "brace-expansion@2.0.1": { 63 | "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 64 | "dependencies": [ 65 | "balanced-match" 66 | ] 67 | }, 68 | "color-convert@2.0.1": { 69 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 70 | "dependencies": [ 71 | "color-name" 72 | ] 73 | }, 74 | "color-name@1.1.4": { 75 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 76 | }, 77 | "cross-spawn@7.0.6": { 78 | "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 79 | "dependencies": [ 80 | "path-key", 81 | "shebang-command", 82 | "which" 83 | ] 84 | }, 85 | "eastasianwidth@0.2.0": { 86 | "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 87 | }, 88 | "emoji-regex@8.0.0": { 89 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" 90 | }, 91 | "emoji-regex@9.2.2": { 92 | "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" 93 | }, 94 | "foreground-child@3.3.0": { 95 | "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", 96 | "dependencies": [ 97 | "cross-spawn", 98 | "signal-exit" 99 | ] 100 | }, 101 | "glob@11.0.1": { 102 | "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", 103 | "dependencies": [ 104 | "foreground-child", 105 | "jackspeak", 106 | "minimatch", 107 | "minipass", 108 | "package-json-from-dist", 109 | "path-scurry" 110 | ] 111 | }, 112 | "is-fullwidth-code-point@3.0.0": { 113 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" 114 | }, 115 | "isexe@2.0.0": { 116 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" 117 | }, 118 | "jackspeak@4.0.2": { 119 | "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", 120 | "dependencies": [ 121 | "@isaacs/cliui" 122 | ] 123 | }, 124 | "lru-cache@11.0.2": { 125 | "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==" 126 | }, 127 | "minimatch@10.0.1": { 128 | "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", 129 | "dependencies": [ 130 | "brace-expansion" 131 | ] 132 | }, 133 | "minipass@7.1.2": { 134 | "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" 135 | }, 136 | "package-json-from-dist@1.0.1": { 137 | "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" 138 | }, 139 | "path-key@3.1.1": { 140 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 141 | }, 142 | "path-scurry@2.0.0": { 143 | "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", 144 | "dependencies": [ 145 | "lru-cache", 146 | "minipass" 147 | ] 148 | }, 149 | "shebang-command@2.0.0": { 150 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 151 | "dependencies": [ 152 | "shebang-regex" 153 | ] 154 | }, 155 | "shebang-regex@3.0.0": { 156 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" 157 | }, 158 | "signal-exit@4.1.0": { 159 | "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" 160 | }, 161 | "string-width@4.2.3": { 162 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 163 | "dependencies": [ 164 | "emoji-regex@8.0.0", 165 | "is-fullwidth-code-point", 166 | "strip-ansi@6.0.1" 167 | ] 168 | }, 169 | "string-width@5.1.2": { 170 | "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", 171 | "dependencies": [ 172 | "eastasianwidth", 173 | "emoji-regex@9.2.2", 174 | "strip-ansi@7.1.0" 175 | ] 176 | }, 177 | "strip-ansi@6.0.1": { 178 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 179 | "dependencies": [ 180 | "ansi-regex@5.0.1" 181 | ] 182 | }, 183 | "strip-ansi@7.1.0": { 184 | "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", 185 | "dependencies": [ 186 | "ansi-regex@6.1.0" 187 | ] 188 | }, 189 | "undici-types@6.20.0": { 190 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" 191 | }, 192 | "wasm-image-optimization@1.2.28": { 193 | "integrity": "sha512-mjhJXHSd/5kqASdRL/2skvVMNSv23NwQY3w/yGqQM0dbJjwSAY8BJtAaD9qrt69TZjvNmJl09EwOHRRi3ah/Ug==", 194 | "dependencies": [ 195 | "worker-lib" 196 | ] 197 | }, 198 | "which@2.0.2": { 199 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 200 | "dependencies": [ 201 | "isexe" 202 | ] 203 | }, 204 | "worker-lib@2.0.7": { 205 | "integrity": "sha512-LkT+hMUakAAakGPtWVY3ZSJ91BgcXpPYzwcpJbkC2LXzwYiIiSgKxWxt1EM8s9c/z6Va6xZ49lELDvMSjwgbpA==" 206 | }, 207 | "wrap-ansi@7.0.0": { 208 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 209 | "dependencies": [ 210 | "ansi-styles@4.3.0", 211 | "string-width@4.2.3", 212 | "strip-ansi@6.0.1" 213 | ] 214 | }, 215 | "wrap-ansi@8.1.0": { 216 | "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", 217 | "dependencies": [ 218 | "ansi-styles@6.2.1", 219 | "string-width@5.1.2", 220 | "strip-ansi@7.1.0" 221 | ] 222 | } 223 | }, 224 | "workspace": { 225 | "dependencies": [ 226 | "jsr:@hono/hono@^4.7.7", 227 | "jsr:@std/fs@^1.0.14", 228 | "jsr:@std/path@^1.0.8", 229 | "npm:wasm-image-optimization@^1.2.28" 230 | ] 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /packages/image/example/image.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/withsapling/sapling/e671a4c7ead0463f3a29453e21477ee3d1427bc5/packages/image/example/image.ts -------------------------------------------------------------------------------- /packages/image/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./optimizeImages/index.ts"; 2 | export * from "./picture/picture.ts"; 3 | -------------------------------------------------------------------------------- /packages/image/src/optimizeImages/deno.ts: -------------------------------------------------------------------------------- 1 | import { ensureDir, walk, exists } from "@std/fs"; 2 | import { join, relative, dirname } from "@std/path"; 3 | import { optimizeImage } from "wasm-image-optimization"; 4 | import type { ImageSize, OptimizeImagesConfig } from "./types.ts"; 5 | 6 | // Default configurations 7 | const DEFAULT_SIZES: ImageSize[] = [ 8 | { suffix: "sm", width: 640 }, 9 | { suffix: "md", width: 1280 }, 10 | { suffix: "lg", width: 1920 }, 11 | ]; 12 | 13 | const SUPPORTED_FORMATS = [".jpg", ".jpeg", ".png", ".webp", ".avif"]; 14 | 15 | /** 16 | * Optimizes images according to the provided configuration. 17 | * This function will process either single images or entire directories, 18 | * creating multiple sizes of each image in the specified format. 19 | * 20 | * @param {OptimizeImagesConfig} config - Configuration object for image optimization 21 | * @returns {Promise} A promise that resolves when all images have been processed 22 | * 23 | * @example 24 | * ```ts 25 | * await optimizeImages({ 26 | * entries: [{ input: "./images", output: "./optimized" }], 27 | * format: "avif", 28 | * sizes: [{ suffix: "sm", width: 640 }] 29 | * }); 30 | * ``` 31 | */ 32 | export async function optimizeImages(config: OptimizeImagesConfig) { 33 | const startTime = performance.now(); 34 | const sizes = config.sizes || DEFAULT_SIZES; 35 | const format = config.format || "avif"; 36 | const defaultQuality = config.defaultQuality || 85; 37 | 38 | for (const entry of config.entries) { 39 | const inputStats = await Deno.stat(entry.input); 40 | await ensureDir(entry.output); 41 | 42 | if (inputStats.isDirectory) { 43 | // Process directory 44 | for await (const file of walk(entry.input, { 45 | exts: SUPPORTED_FORMATS.map((ext) => ext.slice(1)), 46 | })) { 47 | if (file.isFile) { 48 | await processImage( 49 | file.path, 50 | entry.input, 51 | entry.output, 52 | sizes, 53 | format, 54 | defaultQuality 55 | ); 56 | } 57 | } 58 | } else if (inputStats.isFile) { 59 | // Process single file 60 | await processImage( 61 | entry.input, 62 | dirname(entry.input), 63 | entry.output, 64 | sizes, 65 | format, 66 | defaultQuality 67 | ); 68 | } 69 | } 70 | 71 | const endTime = performance.now(); 72 | const elapsedTime = (endTime - startTime) / 1000; 73 | console.log( 74 | `⏱️ Image optimization completed in ${elapsedTime.toFixed(2)} seconds` 75 | ); 76 | } 77 | 78 | /** 79 | * Processes a single image file, creating multiple sized versions according to the configuration. 80 | * 81 | * @param {string} filePath - Path to the source image file 82 | * @param {string} basePath - Base directory path for calculating relative paths 83 | * @param {string} outputPath - Directory where optimized images will be saved 84 | * @param {ImageSize[]} sizes - Array of size configurations to generate 85 | * @param {"avif" | "webp" | "jpeg" | "png"} format - Output format for the optimized images 86 | * @param {number} defaultQuality - Default quality setting for optimization (1-100) 87 | * @returns {Promise} A promise that resolves when the image has been processed 88 | * @private 89 | */ 90 | async function processImage( 91 | filePath: string, 92 | basePath: string, 93 | outputPath: string, 94 | sizes: ImageSize[], 95 | format: "avif" | "webp" | "jpeg" | "png", 96 | defaultQuality: number 97 | ) { 98 | const relativePath = relative(basePath, filePath); 99 | const baseName = relativePath.replace(/\.[^/.]+$/, ""); 100 | 101 | for (const size of sizes) { 102 | const outputFilePath = join( 103 | outputPath, 104 | `${baseName}-${size.suffix}.${format}` 105 | ); 106 | 107 | // Skip if this size already exists 108 | if (await exists(outputFilePath)) { 109 | console.log( 110 | `⏭️ Skipping: ${relativePath} -> ${format} (${size.suffix}) - exists` 111 | ); 112 | continue; 113 | } 114 | 115 | try { 116 | const imageData = await Deno.readFile(filePath); 117 | const optimizedImage = await optimizeImage({ 118 | image: imageData, 119 | width: size.width, 120 | quality: size.quality || defaultQuality, 121 | format, 122 | }); 123 | 124 | if (optimizedImage) { 125 | await ensureDir(dirname(outputFilePath)); 126 | await Deno.writeFile(outputFilePath, optimizedImage); 127 | console.log( 128 | `✅ Optimized: ${relativePath} -> ${format} (${size.suffix})` 129 | ); 130 | } else { 131 | console.error( 132 | `❌ Failed to optimize ${relativePath} (${size.suffix}): No output generated` 133 | ); 134 | } 135 | } catch (error) { 136 | console.error( 137 | `❌ Error processing ${relativePath} (${size.suffix}):`, 138 | error 139 | ); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /packages/image/src/optimizeImages/index.ts: -------------------------------------------------------------------------------- 1 | import type { OptimizeImagesConfig } from "./types.ts"; 2 | 3 | // Re-export types 4 | export * from "./types.ts"; 5 | 6 | let optimizeImages: (config: OptimizeImagesConfig) => Promise; 7 | 8 | // Check if we're running in Deno 9 | if (typeof Deno !== "undefined") { 10 | optimizeImages = (await import("./deno.ts")).optimizeImages; 11 | } else { 12 | optimizeImages = (await import("./node.ts")).optimizeImages; 13 | } 14 | 15 | export { optimizeImages }; 16 | -------------------------------------------------------------------------------- /packages/image/src/optimizeImages/node.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readFile, writeFile, stat } from "node:fs/promises"; 2 | import { join, relative, dirname } from "node:path"; 3 | import { optimizeImage } from "wasm-image-optimization"; 4 | import type { ImageSize, OptimizeImagesConfig } from "./types.ts"; 5 | import { walk } from "@std/fs"; 6 | 7 | // Default configurations 8 | const DEFAULT_SIZES: ImageSize[] = [ 9 | { suffix: "sm", width: 640 }, 10 | { suffix: "md", width: 1280 }, 11 | { suffix: "lg", width: 1920 }, 12 | ]; 13 | 14 | const SUPPORTED_FORMATS = [".jpg", ".jpeg", ".png", ".webp", ".avif"]; 15 | 16 | /** 17 | * Optimizes images according to the provided configuration. 18 | * This function will process either single images or entire directories, 19 | * creating multiple sizes of each image in the specified format. 20 | * 21 | * @param {OptimizeImagesConfig} config - Configuration object for image optimization 22 | * @returns {Promise} A promise that resolves when all images have been processed 23 | * 24 | * @example 25 | * ```ts 26 | * await optimizeImages({ 27 | * entries: [{ input: "./images", output: "./optimized" }], 28 | * format: "avif", 29 | * sizes: [{ suffix: "sm", width: 640 }] 30 | * }); 31 | * ``` 32 | */ 33 | export async function optimizeImages(config: OptimizeImagesConfig) { 34 | const startTime = performance.now(); 35 | const sizes = config.sizes || DEFAULT_SIZES; 36 | const format = config.format || "avif"; 37 | const defaultQuality = config.defaultQuality || 85; 38 | 39 | for (const entry of config.entries) { 40 | const inputStats = await stat(entry.input); 41 | await mkdir(entry.output, { recursive: true }); 42 | 43 | if (inputStats.isDirectory()) { 44 | // Process directory 45 | for await (const file of walk(entry.input, { 46 | exts: SUPPORTED_FORMATS.map((ext) => ext.slice(1)), 47 | })) { 48 | if (file.isFile) { 49 | await processImage( 50 | file.path, 51 | entry.input, 52 | entry.output, 53 | sizes, 54 | format, 55 | defaultQuality 56 | ); 57 | } 58 | } 59 | } else if (inputStats.isFile()) { 60 | // Process single file 61 | await processImage( 62 | entry.input, 63 | dirname(entry.input), 64 | entry.output, 65 | sizes, 66 | format, 67 | defaultQuality 68 | ); 69 | } 70 | } 71 | 72 | const endTime = performance.now(); 73 | const elapsedTime = (endTime - startTime) / 1000; 74 | console.log( 75 | `⏱️ Image optimization completed in ${elapsedTime.toFixed(2)} seconds` 76 | ); 77 | } 78 | 79 | /** 80 | * Processes a single image file, creating multiple sized versions according to the configuration. 81 | * 82 | * @param {string} filePath - Path to the source image file 83 | * @param {string} basePath - Base directory path for calculating relative paths 84 | * @param {string} outputPath - Directory where optimized images will be saved 85 | * @param {ImageSize[]} sizes - Array of size configurations to generate 86 | * @param {"avif" | "webp" | "jpeg" | "png"} format - Output format for the optimized images 87 | * @param {number} defaultQuality - Default quality setting for optimization (1-100) 88 | * @returns {Promise} A promise that resolves when the image has been processed 89 | * @private 90 | */ 91 | async function processImage( 92 | filePath: string, 93 | basePath: string, 94 | outputPath: string, 95 | sizes: ImageSize[], 96 | format: "avif" | "webp" | "jpeg" | "png", 97 | defaultQuality: number 98 | ) { 99 | const relativePath = relative(basePath, filePath); 100 | const baseName = relativePath.replace(/\.[^/.]+$/, ""); 101 | 102 | for (const size of sizes) { 103 | const outputFilePath = join( 104 | outputPath, 105 | `${baseName}-${size.suffix}.${format}` 106 | ); 107 | 108 | // Skip if this size already exists 109 | try { 110 | await stat(outputFilePath); 111 | console.log( 112 | `⏭️ Skipping: ${relativePath} -> ${format} (${size.suffix}) - exists` 113 | ); 114 | continue; 115 | } catch { 116 | // File doesn't exist, proceed with optimization 117 | } 118 | 119 | try { 120 | const imageData = await readFile(filePath); 121 | const optimizedImage = await optimizeImage({ 122 | image: imageData, 123 | width: size.width, 124 | quality: size.quality || defaultQuality, 125 | format, 126 | }); 127 | 128 | if (optimizedImage) { 129 | await mkdir(dirname(outputFilePath), { recursive: true }); 130 | await writeFile(outputFilePath, optimizedImage); 131 | console.log( 132 | `✅ Optimized: ${relativePath} -> ${format} (${size.suffix})` 133 | ); 134 | } else { 135 | console.error( 136 | `❌ Failed to optimize ${relativePath} (${size.suffix}): No output generated` 137 | ); 138 | } 139 | } catch (error) { 140 | console.error( 141 | `❌ Error processing ${relativePath} (${size.suffix}):`, 142 | error 143 | ); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /packages/image/src/optimizeImages/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for a specific image size output. 3 | */ 4 | export interface ImageSize { 5 | /** Suffix to append to the filename (e.g., "sm" for small) */ 6 | suffix: string; 7 | /** Target width in pixels for this size variant */ 8 | width: number; 9 | /** Optional quality setting (1-100). Falls back to defaultQuality if not specified */ 10 | quality?: number; 11 | } 12 | 13 | /** 14 | * Configuration for the image optimization process. 15 | */ 16 | export interface OptimizeImagesConfig { 17 | /** Array of input/output directory pairs to process */ 18 | entries: Array<{ 19 | /** Source directory or file path */ 20 | input: string; 21 | /** Output directory path for optimized images */ 22 | output: string; 23 | }>; 24 | /** Optional array of size configurations. Falls back to default sizes if not specified */ 25 | sizes?: ImageSize[]; 26 | /** Optional output format. Defaults to "avif" */ 27 | format?: "avif" | "webp" | "jpeg" | "png"; 28 | /** Optional default quality setting (1-100). Defaults to 85 */ 29 | defaultQuality?: number; 30 | } 31 | -------------------------------------------------------------------------------- /packages/image/src/picture/picture.ts: -------------------------------------------------------------------------------- 1 | import { html } from "@hono/hono/html"; 2 | import type { HtmlEscapedString } from "@hono/hono/utils/html"; 3 | 4 | /** 5 | * Properties for the Picture component. 6 | */ 7 | interface PictureProps { 8 | src: string; 9 | format?: "avif" | "webp" | "jpeg" | "png"; 10 | alt: string; 11 | imgClass?: string; 12 | width: number; 13 | height: number; 14 | loading?: "lazy" | "eager"; 15 | decoding?: "async" | "sync" | "auto"; 16 | } 17 | 18 | /** 19 | * Creates a picture element with source and fallback image. 20 | * @param props - The properties for the picture element. 21 | * @returns The HTML content for the picture element. 22 | */ 23 | export function Picture(props: PictureProps): HtmlEscapedString | Promise { 24 | const { src, alt, imgClass, width, height, loading, decoding } = props; 25 | 26 | if (!src) { 27 | throw new Error("src is required"); 28 | } 29 | 30 | const format = props.format ?? "avif"; 31 | 32 | return html` 33 | 34 | 39 | 40 | 45 | 46 | 47 | 48 | ${alt} 57 | `; 58 | } 59 | -------------------------------------------------------------------------------- /packages/markdown/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .deno 3 | .vscode -------------------------------------------------------------------------------- /packages/markdown/README.md: -------------------------------------------------------------------------------- 1 | [![JSR](https://jsr.io/badges/@sapling/markdown)](https://jsr.io/@sapling/markdown) 2 | 3 | # Sapling Markdown Package 4 | 5 | This package contains the markdown parser and renderer that can be used in Sapling websites or other Deno projects. 6 | 7 | ## Usage 8 | 9 | ```ts 10 | import { renderMarkdown } from "@sapling/markdown"; 11 | 12 | // Render markdown to html 13 | const html = await renderMarkdown(markdown); 14 | ``` 15 | 16 | ## Attributions 17 | 18 | - [marked](https://github.com/markedjs/marked) - The markdown parser 19 | - [shiki](https://github.com/shikijs/shiki) - The code highlighter -------------------------------------------------------------------------------- /packages/markdown/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sapling/markdown", 3 | "description": "A markdown parser for Sapling sites or Deno projects", 4 | "license": "MIT", 5 | "version": "0.3.0", 6 | "imports": { 7 | "marked": "npm:marked@^15.0.6", 8 | "marked-gfm-heading-id": "npm:marked-gfm-heading-id@^4.1.1", 9 | "marked-shiki": "npm:marked-shiki@^1.1.1", 10 | "shiki": "npm:shiki@^1.22.2" 11 | }, 12 | "exports": { 13 | ".": "./src/index.ts" 14 | }, 15 | "exclude": ["node_modules"], 16 | "lock": false, 17 | "nodeModulesDir": "auto" 18 | } 19 | -------------------------------------------------------------------------------- /packages/markdown/src/index.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import markedShiki from "marked-shiki"; 3 | import { codeToHtml, type BundledLanguage, type BundledTheme, type CodeToHastOptions } from "shiki"; 4 | import { gfmHeadingId } from "marked-gfm-heading-id"; 5 | 6 | type ThemeOptions = 7 | | { theme: BundledTheme; themes?: never } 8 | | { theme?: never; themes: { light: BundledTheme; dark: BundledTheme } }; 9 | 10 | /** 11 | * Custom type for Shiki options where lang is optional since we handle it internally 12 | */ 13 | type CustomShikiOptions = Omit, 'lang' | 'theme' | 'themes'> & ThemeOptions; 14 | 15 | /** 16 | * Options for markdown rendering 17 | */ 18 | interface MarkdownOptions { 19 | /** Prefix for heading IDs */ 20 | idPrefix?: string; 21 | /** Enable GitHub Flavored Markdown. Defaults to true */ 22 | gfm?: boolean; 23 | /** Convert \n to
. Defaults to false */ 24 | breaks?: boolean; 25 | /** 26 | * Options passed directly to shiki's codeToHtml. 27 | * The language (lang) from code blocks will always take precedence over 28 | * any language specified in shikiOptions. 29 | * You must provide a theme or themes configuration. 30 | */ 31 | shikiOptions?: CustomShikiOptions; 32 | } 33 | 34 | /** 35 | * Renders markdown content to HTML with syntax highlighting and GitHub-style features 36 | * 37 | * @example 38 | * ```ts 39 | * // Basic usage with a single theme 40 | * const html = await renderMarkdown("# Hello World", { 41 | * shikiOptions: { 42 | * theme: "github-light" 43 | * } 44 | * }); 45 | * 46 | * // With dark/light theme support 47 | * const html = await renderMarkdown("# Hello World", { 48 | * gfm: true, 49 | * idPrefix: "content-", 50 | * shikiOptions: { 51 | * themes: { 52 | * light: "github-light", 53 | * dark: "github-dark" 54 | * } 55 | * } 56 | * }); 57 | * 58 | * // Language examples 59 | * const markdown = ` 60 | * \`\`\`typescript 61 | * // This will use TypeScript highlighting 62 | * const x: number = 42; 63 | * \`\`\` 64 | * 65 | * \`\`\` 66 | * // This will fallback to text highlighting 67 | * const x = 42; 68 | * \`\`\` 69 | * `; 70 | * const html = await renderMarkdown(markdown, { 71 | * shikiOptions: { theme: "github-light" } 72 | * }); 73 | * ``` 74 | * 75 | * @param markdown - The markdown string to render 76 | * @param options - Rendering options 77 | * @returns Promise resolving to the rendered HTML 78 | */ 79 | export async function renderMarkdown( 80 | markdown: string, 81 | options: MarkdownOptions = {}, 82 | ): Promise { 83 | // Configure marked options 84 | await marked.use({ 85 | gfm: options.gfm ?? true, 86 | breaks: options.breaks ?? false, 87 | }); 88 | 89 | await marked.use( 90 | markedShiki({ 91 | async highlight(code: string, lang: string) { 92 | if (!options.shikiOptions?.theme && !options.shikiOptions?.themes) { 93 | throw new Error('You must provide either a theme or themes in shikiOptions'); 94 | } 95 | return await codeToHtml(code, { 96 | ...options.shikiOptions, 97 | // Always ensure code block language takes precedence 98 | lang: lang || "text", 99 | }); 100 | }, 101 | }), 102 | ); 103 | 104 | await marked.use( 105 | gfmHeadingId({ 106 | prefix: options.idPrefix ?? undefined, 107 | }), 108 | ); 109 | 110 | return marked(markdown); 111 | } -------------------------------------------------------------------------------- /packages/sapling-island/README.md: -------------------------------------------------------------------------------- 1 | # Sapling Island 2 | 3 | This is the source code for the `` web component. Due to the simplicity of the component, the source code might not change often if ever; however, we want to keep the current source code here for reference. 4 | 5 | This package is not distributed via npm or JSR since it is intended to be used client side without a bundler or build step. We distribute it via our CDN url at https://sapling-is.land. 6 | 7 | You can read more about how to install and use it in the [sapling docs](https://sapling.land/docs/sapling-island). 8 | 9 | ## License 10 | 11 | MIT License 12 | -------------------------------------------------------------------------------- /packages/sapling-island/index.js: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Version: 0.2.0 3 | 4 | export default class SaplingIsland extends HTMLElement { 5 | constructor() { 6 | super(); 7 | this.loaded = false; 8 | this.observer = null; 9 | this.timeoutId = null; 10 | this.mediaQuery = null; 11 | } 12 | 13 | // Attributes that trigger attributeChangedCallback 14 | static get observedAttributes() { 15 | return ["loading", "timeout"]; 16 | } 17 | 18 | connectedCallback() { 19 | // Default to onload if no loading strategy specified 20 | const loadingStrategy = this.getAttribute("loading") || "onload"; 21 | 22 | // Check if loading attribute is a media query 23 | if (loadingStrategy.includes("(") && loadingStrategy.includes(")")) { 24 | this.setupMediaLoading(loadingStrategy); 25 | return; 26 | } 27 | 28 | switch (loadingStrategy) { 29 | case "visible": 30 | this.setupVisibilityLoading(); 31 | break; 32 | 33 | case "idle": 34 | this.loadWhenIdle(); 35 | break; 36 | 37 | default: // "load" 38 | this.handleLoad(); 39 | } 40 | } 41 | 42 | // Load when browser is idle or after window load 43 | loadWhenIdle() { 44 | const timeout = parseInt(this.getAttribute("timeout")); 45 | const options = !isNaN(timeout) && timeout > 0 ? { timeout } : undefined; 46 | 47 | if ("requestIdleCallback" in window) { 48 | window.requestIdleCallback(() => this.handleLoad(), options); 49 | } else { 50 | // Fallback for browsers that don't support requestIdleCallback 51 | window.addEventListener("load", () => { 52 | // Add a small delay after load to lower priority 53 | setTimeout(() => this.handleLoad(), 0); 54 | }); 55 | } 56 | } 57 | 58 | // Load when element becomes visible or timeout is reached 59 | setupVisibilityLoading() { 60 | const timeout = parseInt(this.getAttribute("timeout")); 61 | if (!isNaN(timeout) && timeout > 0) { 62 | this.timeoutId = setTimeout(() => { 63 | this.handleLoad(); 64 | if (this.observer) { 65 | this.observer.disconnect(); 66 | this.observer = null; 67 | } 68 | }, timeout); 69 | } 70 | 71 | // Create intersection observer to detect when element's children enter viewport 72 | this.observer = new IntersectionObserver( 73 | (entries) => { 74 | for (const entry of entries) { 75 | if (entry.isIntersecting && !this.loaded) { 76 | if (this.timeoutId) { 77 | clearTimeout(this.timeoutId); 78 | this.timeoutId = null; 79 | } 80 | this.handleLoad(); 81 | // Disconnect observer once loaded 82 | this.observer.disconnect(); 83 | break; 84 | } 85 | } 86 | }, 87 | { 88 | rootMargin: "50px", 89 | threshold: 0, 90 | } 91 | ); 92 | 93 | // Observe all children instead of the island element itself 94 | for (const child of this.children) { 95 | this.observer.observe(child); 96 | } 97 | } 98 | 99 | // Cleanup when element is removed from DOM 100 | disconnectedCallback() { 101 | if (this.observer) { 102 | this.observer.disconnect(); 103 | } 104 | if (this.timeoutId) { 105 | clearTimeout(this.timeoutId); 106 | this.timeoutId = null; 107 | } 108 | // Cleanup media query listener 109 | if (this.mediaQuery && !this.loaded) { 110 | this.mediaQuery.removeEventListener("change", handleMediaChange); 111 | } 112 | } 113 | 114 | // Handle the load event 115 | handleLoad() { 116 | if (this.loaded) { 117 | return; 118 | } 119 | 120 | this.loaded = true; 121 | 122 | // Handle template content if present 123 | const template = this.querySelector("template"); 124 | if (template) { 125 | const content = template.content.cloneNode(true); 126 | template.replaceWith(content); 127 | } 128 | 129 | this.setAttribute("hydrated", ""); 130 | this.dispatchEvent( 131 | new CustomEvent("island:hydrated", { 132 | bubbles: true, 133 | composed: true, 134 | }) 135 | ); 136 | } 137 | 138 | // Load when media query matches 139 | setupMediaLoading(query) { 140 | this.mediaQuery = window.matchMedia(query); 141 | 142 | const handleMediaChange = (e) => { 143 | if (e.matches && !this.loaded) { 144 | this.handleLoad(); 145 | // Cleanup listener after loading 146 | this.mediaQuery.removeEventListener("change", handleMediaChange); 147 | } 148 | }; 149 | 150 | // Check initial state 151 | if (this.mediaQuery.matches) { 152 | this.handleLoad(); 153 | } else { 154 | // Listen for changes if initial state doesn't match 155 | this.mediaQuery.addEventListener("change", handleMediaChange); 156 | } 157 | } 158 | } 159 | 160 | customElements.define("sapling-island", SaplingIsland); 161 | -------------------------------------------------------------------------------- /packages/sapling-island/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "name": "sapling-island", 4 | "description": "A collection of Sapling Island assets", 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /packages/sapling/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | node_modules/ 4 | .deno 5 | .vscode 6 | .DS_Store -------------------------------------------------------------------------------- /packages/sapling/README.md: -------------------------------------------------------------------------------- 1 | [![JSR](https://jsr.io/badges/@sapling/sapling)](https://jsr.io/@sapling/sapling) 2 | 3 | # Sapling 4 | 5 | A simpler SSR and API framework built on top of web standards. Sapling provides an intuitive way to build server-side rendered applications and APIs using modern JavaScript/TypeScript. 6 | 7 | ## Features 8 | 9 | - **Web Standards First**: Built on native Web APIs and standards like `URLPattern` 10 | - **Simple & Intuitive API**: Express-like routing with modern conveniences 11 | - **Zero Configuration**: Works out of the box with sensible defaults 12 | - **Built-in Layout System**: Includes UnoCSS support and optional Tailwind reset 13 | - **Full SSR Support**: Server-side rendering of HTML and atomic CSS 14 | - **Type-Safe**: Written in TypeScript with full type support 15 | - **Middleware Support**: Easy to extend and customize 16 | - **Modern Form Handling**: Built-in support for JSON and FormData 17 | - **Static Site Generation**: Built-in support for prerendering routes 18 | 19 | ## Documentation 20 | 21 | - [Website](https://sapling.land) 22 | - [Documentation](https://sapling.land/docs) 23 | 24 | ## Installation 25 | 26 | Deno 27 | 28 | ```bash 29 | # Install from JSR 30 | deno add jsr:@sapling/sapling 31 | ``` 32 | 33 | Node 34 | 35 | ```bash 36 | # Install from JSR 37 | npx jsr add @sapling/sapling 38 | ``` 39 | 40 | ## Quick Start 41 | 42 | ```typescript 43 | import { Sapling } from "@sapling/sapling"; 44 | 45 | const site = new Sapling(); 46 | 47 | // Basic route 48 | site.get("/", (c) => { 49 | return c.html("

Hello World!

"); 50 | }); 51 | 52 | // JSON API endpoint 53 | site.post("/api/users", async (c) => { 54 | const data = await c.req.json<{ name: string }>(); 55 | return c.json({ created: data.name }); 56 | }); 57 | 58 | // URL parameters 59 | site.get("/users/:id", (c) => { 60 | const userId = c.req.param("id"); 61 | return c.text(`User ID: ${userId}`); 62 | }); 63 | 64 | // Query parameters 65 | site.get("/search", (c) => { 66 | const query = c.req.query("q"); 67 | return c.text(`Search query: ${query}`); 68 | }); 69 | ``` 70 | 71 | ## Layout System 72 | 73 | Sapling includes a powerful layout system with built-in Tailwind CSS via [UnoCSS](https://unocss.dev/): 74 | 75 | ```typescript 76 | import { Layout } from "@sapling/sapling"; 77 | 78 | site.get("/", async (c) => { 79 | const content = await Layout({ 80 | head: "My App", 81 | bodyClass: "bg-gray-100", 82 | children: "

Welcome!

" 83 | }); 84 | 85 | return c.html(content); 86 | }); 87 | ``` 88 | 89 | ## API Reference 90 | 91 | ### Routing 92 | 93 | ```typescript 94 | site.get(path, handler) // Handle GET requests 95 | site.post(path, handler) // Handle POST requests 96 | site.put(path, handler) // Handle PUT requests 97 | site.delete(path, handler) // Handle DELETE requests 98 | site.patch(path, handler) // Handle PATCH requests 99 | ``` 100 | 101 | ### Context Methods 102 | 103 | Request Methods: 104 | - `c.req.raw` - The original Request object 105 | - `c.req.json()` - Parse JSON request body 106 | - `c.req.formData()` - Parse form data 107 | - `c.req.text()` - Get request body as text 108 | - `c.req.param(name?)` - Access URL parameters 109 | - `c.req.query(name?)` - Access query parameters 110 | - `c.req.header(name)` - Get request header 111 | 112 | Response Methods: 113 | - `c.json(data, status?)` - Send JSON response 114 | - `c.html(content)` - Send HTML response 115 | - `c.text(content, status?)` - Send text response 116 | - `c.redirect(url, status?)` - Redirect response 117 | 118 | State Management: 119 | - `c.set(key, value)` - Set state value 120 | - `c.get(key)` - Get state value 121 | 122 | ### Middleware Support 123 | 124 | ```typescript 125 | // Global middleware 126 | site.use(async (c, next) => { 127 | const start = Date.now(); 128 | const response = await next(); 129 | const duration = Date.now() - start; 130 | console.log(`Request took ${duration}ms`); 131 | return response; 132 | }); 133 | 134 | // Route-specific middleware 135 | site.get("/protected", 136 | async (c, next) => { 137 | if (!isAuthenticated(c)) { 138 | return c.redirect("/login"); 139 | } 140 | return next(); 141 | }, 142 | (c) => { 143 | return c.html("

Protected Content

"); 144 | } 145 | ); 146 | ``` 147 | 148 | ## Contributing 149 | 150 | Contributions are welcome! Please feel free to submit a Pull Request. 151 | 152 | ## License 153 | 154 | MIT License 155 | -------------------------------------------------------------------------------- /packages/sapling/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sapling/sapling", 3 | "description": "A micro SSR framework", 4 | "license": "MIT", 5 | "version": "0.7.0", 6 | "imports": { 7 | "@hono/hono": "jsr:@hono/hono@^4.7.5", 8 | "@std/media-types": "jsr:@std/media-types@^1.1.0", 9 | "@std/path": "jsr:@std/path@^1.0.8", 10 | "@unocss/core": "npm:@unocss/core@^66.0.0", 11 | "@unocss/preset-uno": "npm:@unocss/preset-uno@^66.0.0", 12 | "urlpattern-polyfill": "npm:urlpattern-polyfill@^10.0.0" 13 | }, 14 | "exports": { 15 | ".": "./src/index.ts" 16 | }, 17 | "compilerOptions": { 18 | "jsx": "precompile", 19 | "jsxImportSource": "@hono/hono/jsx", 20 | "lib": ["ESNext", "DOM", "DOM.Iterable", "deno.ns"] 21 | }, 22 | "exclude": ["node_modules", "example"], 23 | "lock": false, 24 | "nodeModulesDir": "auto" 25 | } 26 | -------------------------------------------------------------------------------- /packages/sapling/example/main.ts: -------------------------------------------------------------------------------- 1 | import { Sapling, Layout, html, type Context } from "../src/index.ts"; 2 | 3 | const site = new Sapling(); 4 | 5 | // Regular layout example 6 | site.get("/", async (c: Context) => { 7 | return c.html( 8 | await Layout({ 9 | head: html`Hello World`, 10 | bodyClass: "bg-gray-100", 11 | children: html`
12 |

Hello World

13 | View Streamed Version 14 |
`, 15 | }) 16 | ); 17 | }); 18 | 19 | async function fetchSlowData() { 20 | await new Promise(resolve => setTimeout(resolve, 2000)); 21 | return html`
This content was loaded after 2 seconds!
`; 22 | } 23 | 24 | // Streamed layout example 25 | site.get("/streamed", async (c: Context) => { 26 | return c.html( 27 | await Layout({ 28 | stream: true, 29 | head: html`Hello World (Streamed)`, 30 | bodyClass: "bg-gray-100", 31 | children: html`
32 |

Hello World (Streamed)

33 | View Regular Version 34 |
Hello
35 |
36 | 43 |
Loading...
44 |
45 |
`, 46 | }) 47 | ); 48 | }); 49 | 50 | // API endpoint for slow data 51 | site.get("/api/slow-data", async (c: Context) => { 52 | const slowData = await fetchSlowData(); 53 | return c.html(String(slowData)); 54 | }); 55 | 56 | Deno.serve({ 57 | port: 3000, 58 | onListen: () => { 59 | console.log("Server is running on http://localhost:3000"); 60 | }, 61 | handler: site.fetch, 62 | }); 63 | -------------------------------------------------------------------------------- /packages/sapling/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SAPLING_VERSION = "0.6.0"; 2 | -------------------------------------------------------------------------------- /packages/sapling/src/html-stream-layout.ts: -------------------------------------------------------------------------------- 1 | import { createGenerator } from "@unocss/core"; 2 | import presetUno from "@unocss/preset-uno"; 3 | import type { LayoutProps } from "./types/index.ts"; 4 | import type { UserConfig } from "@unocss/core"; 5 | import { SAPLING_VERSION } from "./constants.ts"; 6 | import type { HtmlEscapedString } from "@hono/hono/utils/html"; 7 | 8 | 9 | /** 10 | * The Layout function creates an HTML document with UnoCSS support and optional Tailwind reset styles. 11 | * 12 | * @returns A Promise that resolves to the complete HTML document as a string or a ReadableStream that streams the HTML document 13 | * 14 | * @param props - The properties for the layout 15 | * @param props.unoConfig - Optional custom UnoCSS configuration. If not provided, uses the default UnoCSS preset 16 | * @param props.disableTailwindReset - When true, removes the default Tailwind reset styles 17 | * @param props.head - Additional content to inject into the document's head section 18 | * @param props.bodyClass - Optional class string to add to the body element 19 | * @param props.children - The content to render in the body of the page 20 | * @param props.disableUnoCSS - When true, skips UnoCSS generation 21 | * @param props.enableIslands - When true, adds the islands script and CSS 22 | * @param props.lang - Optional language for the HTML document 23 | * @param props.disableGeneratorTag - When true, skips the generator meta tag 24 | * 25 | * @example 26 | * Example usage with Hono 27 | * ```ts 28 | * const site = new Hono(); 29 | * 30 | * // Home page 31 | * site.get("/", async (c: Context) => { 32 | * const stream = await Home(); 33 | * return c.body(stream, { 34 | * headers: { 35 | * "Content-Type": "text/html", 36 | * "Transfer-Encoding": "chunked", 37 | * }, 38 | * }); 39 | * }); 40 | * ``` 41 | */ 42 | export function HtmlStreamLayout(props: LayoutProps): Promise | ReadableStream { 43 | // UnoCSS config and generator setup 44 | let config: UserConfig = { 45 | presets: [presetUno()], 46 | }; 47 | let css = { css: "" }; // Default empty CSS if UnoCSS is disabled 48 | 49 | // Only setup UnoCSS if not disabled 50 | if (!props.disableUnoCSS) { 51 | // If no config is provided, use the default UnoCSS preset 52 | if (props.unoConfig) { 53 | config = props.unoConfig; 54 | } 55 | } 56 | 57 | // Tailwind Reset Minified 58 | let resetStyles = `*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:var(--un-default-border-color,#e5e7eb)}::after,::before{--un-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}`; 59 | 60 | // If the tailwind reset is disabled, remove the default tailwind reset 61 | if (props.disableTailwindReset) { 62 | resetStyles = ``; 63 | } 64 | 65 | // Streaming logic 66 | return new ReadableStream({ 67 | async start(controller) { 68 | // Only generate UnoCSS if not disabled 69 | if (!props.disableUnoCSS) { 70 | // Create the UnoCSS generator 71 | const generator = await createGenerator(config); 72 | // Generate the CSS from the provided children and body class 73 | css = await generator.generate( 74 | `${props.bodyClass ? `${props.bodyClass} ` : ``} ${props.children}` 75 | ); 76 | } 77 | 78 | // Enqueue the beginning of the HTML document 79 | controller.enqueue( 80 | new TextEncoder().encode( 81 | ` 82 | 83 | 84 | 85 | 86 | ${ 87 | props.disableGeneratorTag 88 | ? `` 89 | : `` 90 | } 91 | ${props.disableTailwindReset ? `` : ``} 92 | ${ 93 | !props.disableUnoCSS 94 | ? ` 95 | ` 96 | : `` 97 | } 98 | ${ 99 | props.enableIslands 100 | ? ` 101 | 102 | 103 | 104 | ` 105 | : `` 106 | } 107 | ${props.head}` 108 | ) 109 | ); 110 | 111 | // Enqueue the body and the rest of the HTML 112 | controller.enqueue( 113 | new TextEncoder().encode( 114 | `${props.bodyClass ? `` : ``} 115 | ${props.children} 116 | 117 | ` 118 | ) 119 | ); 120 | 121 | // Close the stream 122 | controller.close(); 123 | }, 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /packages/sapling/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Layout } from "./sapling-layout.ts"; 2 | import { Sapling } from "./sapling.ts"; 3 | import { HtmlStreamLayout } from "./html-stream-layout.ts"; 4 | 5 | // export Default Layout 6 | export { Layout }; 7 | 8 | // export Streaming Layout 9 | export { HtmlStreamLayout }; 10 | 11 | // export Sapling class 12 | export { Sapling }; 13 | 14 | // export prerender types 15 | // export type { PrerenderRoute, PrerenderOptions } from "./prerender/index.ts"; 16 | 17 | // export all types from types/index.ts 18 | export * from "./types/index.ts"; 19 | 20 | export type { LayoutProps } from "./types/index.ts"; 21 | -------------------------------------------------------------------------------- /packages/sapling/src/prerender/deno.ts: -------------------------------------------------------------------------------- 1 | // import * as path from "@std/path"; 2 | // import type { PrerenderRoute, PrerenderOptions } from "./index.ts"; 3 | 4 | // /** 5 | // * Build pre-rendered HTML files for registered routes using Deno's file system APIs 6 | // */ 7 | // export async function buildPrerenderRoutes( 8 | // routes: PrerenderRoute[], 9 | // options: PrerenderOptions 10 | // ): Promise { 11 | // const { outputDir, createContext } = options; 12 | 13 | // // Create output directory if it doesn't exist 14 | // try { 15 | // await Deno.mkdir(outputDir, { recursive: true }); 16 | // } catch (error) { 17 | // if (!(error instanceof Deno.errors.AlreadyExists)) { 18 | // throw error; 19 | // } 20 | // } 21 | 22 | // // Create a flat list of all pages to render 23 | // const pages = routes.flatMap((route) => { 24 | // const params = route.params || [{}]; 25 | // return params.map((param) => { 26 | // let requestPath = route.path; 27 | // // Replace dynamic segments with parameter values 28 | // for (const [key, value] of Object.entries(param)) { 29 | // requestPath = requestPath.replace(`:${key}`, value.toString()); 30 | // } 31 | // return { route, param, requestPath }; 32 | // }); 33 | // }); 34 | 35 | // // Smart concurrency based on page count 36 | // const concurrencyLimit = Math.min( 37 | // Math.max(2, Math.ceil(pages.length / 4)), 38 | // navigator.hardwareConcurrency || 4 39 | // ); 40 | 41 | // console.log( 42 | // `\nPrerendering ${pages.length} pages with ${concurrencyLimit} workers...` 43 | // ); 44 | // const startTime = Date.now(); 45 | // let completed = 0; 46 | 47 | // // Process pages in parallel with a concurrency limit 48 | // const chunks = []; 49 | // for (let i = 0; i < pages.length; i += concurrencyLimit) { 50 | // chunks.push(pages.slice(i, i + concurrencyLimit)); 51 | // } 52 | 53 | // for (const chunk of chunks) { 54 | // await Promise.all( 55 | // chunk.map(async ({ route, param, requestPath }) => { 56 | // // Create context with path and params 57 | // const context = createContext(requestPath, param); 58 | 59 | // try { 60 | // // Create middleware chain 61 | // let index = 0; 62 | // const allMiddleware = route.middleware; 63 | 64 | // const executeMiddleware = async (): Promise => { 65 | // if (index < allMiddleware.length) { 66 | // const middleware = allMiddleware[index++]; 67 | // return await middleware(context, executeMiddleware); 68 | // } else { 69 | // return await route.handler(context); 70 | // } 71 | // }; 72 | 73 | // const response = await executeMiddleware(); 74 | 75 | // if (response instanceof Response && response.ok) { 76 | // const html = await response.text(); 77 | 78 | // // Create nested directories if needed 79 | // const filePath = 80 | // requestPath === "/" 81 | // ? path.join(outputDir, "index.html") 82 | // : path.join( 83 | // outputDir, 84 | // `${ 85 | // requestPath.endsWith("/") 86 | // ? requestPath.slice(0, -1) 87 | // : requestPath 88 | // }.html` 89 | // ); 90 | 91 | // const fileDir = path.dirname(filePath); 92 | // await Deno.mkdir(fileDir, { recursive: true }); 93 | 94 | // // Write the HTML file 95 | // await Deno.writeTextFile(filePath, html); 96 | // completed++; 97 | // const percent = Math.round((completed / pages.length) * 100); 98 | // console.log(`[${percent}%] Pre-rendered: ${filePath}`); 99 | // } else { 100 | // console.error( 101 | // `Error pre-rendering page: ${requestPath} - Response not OK` 102 | // ); 103 | // } 104 | // } catch (error) { 105 | // console.error(`Error pre-rendering page: ${requestPath}`, error); 106 | // } 107 | // }) 108 | // ); 109 | // } 110 | 111 | // const duration = Date.now() - startTime; 112 | // console.log(`\nPrerendered ${completed} pages in ${duration}ms`); 113 | // } 114 | -------------------------------------------------------------------------------- /packages/sapling/src/prerender/index.ts: -------------------------------------------------------------------------------- 1 | // import type { Context } from "../types/index.ts"; 2 | // import type { ContextHandler, Middleware } from "../sapling.ts"; 3 | 4 | // type PrerenderRoute = { 5 | // path: string; 6 | // handler: ContextHandler; 7 | // middleware: Middleware[]; 8 | // params?: Record[]; 9 | // }; 10 | 11 | // type PrerenderOptions = { 12 | // /** Directory to output the pre-rendered files */ 13 | // outputDir: string; 14 | // /** Function to create a context object */ 15 | // createContext: (path: string, params: Record) => Context; 16 | // }; 17 | 18 | // let buildPrerenderRoutes: ( 19 | // routes: PrerenderRoute[], 20 | // options: PrerenderOptions 21 | // ) => Promise; 22 | 23 | // // Check if we're running in Deno 24 | // if (typeof Deno !== "undefined") { 25 | // buildPrerenderRoutes = (await import("./deno.ts")).buildPrerenderRoutes; 26 | // } else { 27 | // buildPrerenderRoutes = (await import("./node.ts")).buildPrerenderRoutes; 28 | // } 29 | 30 | // export { buildPrerenderRoutes }; 31 | // export type { PrerenderRoute, PrerenderOptions }; 32 | -------------------------------------------------------------------------------- /packages/sapling/src/prerender/node.ts: -------------------------------------------------------------------------------- 1 | // import * as path from "node:path"; 2 | // import * as fs from "node:fs/promises"; 3 | // import * as os from "node:os"; 4 | // import type { PrerenderRoute, PrerenderOptions } from "./index.ts"; 5 | 6 | // type NodeError = { 7 | // code?: string; 8 | // [key: string]: unknown; 9 | // }; 10 | 11 | // /** 12 | // * Build pre-rendered HTML files for registered routes using Node.js file system APIs 13 | // */ 14 | // export async function buildPrerenderRoutes( 15 | // routes: PrerenderRoute[], 16 | // options: PrerenderOptions 17 | // ): Promise { 18 | // const { outputDir, createContext } = options; 19 | 20 | // // Create output directory if it doesn't exist 21 | // try { 22 | // await fs.mkdir(outputDir, { recursive: true }); 23 | // } catch (error) { 24 | // const nodeError = error as NodeError; 25 | // if (nodeError?.code !== "EEXIST") { 26 | // throw error; 27 | // } 28 | // } 29 | 30 | // // Create a flat list of all pages to render 31 | // const pages = routes.flatMap((route) => { 32 | // const params = route.params || [{}]; 33 | // return params.map((param) => { 34 | // let requestPath = route.path; 35 | // // Replace dynamic segments with parameter values 36 | // for (const [key, value] of Object.entries(param)) { 37 | // requestPath = requestPath.replace(`:${key}`, value.toString()); 38 | // } 39 | // return { route, param, requestPath }; 40 | // }); 41 | // }); 42 | 43 | // // Smart concurrency based on page count 44 | // const concurrencyLimit = Math.min( 45 | // Math.max(2, Math.ceil(pages.length / 4)), 46 | // os.cpus().length || 4 47 | // ); 48 | 49 | // console.log( 50 | // `\nPrerendering ${pages.length} pages with ${concurrencyLimit} workers...` 51 | // ); 52 | // const startTime = Date.now(); 53 | // let completed = 0; 54 | 55 | // // Process pages in parallel with a concurrency limit 56 | // const chunks = []; 57 | // for (let i = 0; i < pages.length; i += concurrencyLimit) { 58 | // chunks.push(pages.slice(i, i + concurrencyLimit)); 59 | // } 60 | 61 | // for (const chunk of chunks) { 62 | // await Promise.all( 63 | // chunk.map(async ({ route, param, requestPath }) => { 64 | // // Create context with path and params 65 | // const context = createContext(requestPath, param); 66 | 67 | // try { 68 | // // Create middleware chain 69 | // let index = 0; 70 | // const allMiddleware = route.middleware; 71 | 72 | // const executeMiddleware = async (): Promise => { 73 | // if (index < allMiddleware.length) { 74 | // const middleware = allMiddleware[index++]; 75 | // return await middleware(context, executeMiddleware); 76 | // } else { 77 | // return await route.handler(context); 78 | // } 79 | // }; 80 | 81 | // const response = await executeMiddleware(); 82 | 83 | // if (response instanceof Response && response.ok) { 84 | // const html = await response.text(); 85 | 86 | // // Create nested directories if needed 87 | // const filePath = 88 | // requestPath === "/" 89 | // ? path.join(outputDir, "index.html") 90 | // : path.join( 91 | // outputDir, 92 | // `${ 93 | // requestPath.endsWith("/") 94 | // ? requestPath.slice(0, -1) 95 | // : requestPath 96 | // }.html` 97 | // ); 98 | 99 | // const fileDir = path.dirname(filePath); 100 | // await fs.mkdir(fileDir, { recursive: true }); 101 | 102 | // // Write the HTML file 103 | // await fs.writeFile(filePath, html); 104 | // completed++; 105 | // const percent = Math.round((completed / pages.length) * 100); 106 | // console.log(`[${percent}%] Pre-rendered: ${filePath}`); 107 | // } else { 108 | // console.error( 109 | // `Error pre-rendering page: ${requestPath} - Response not OK` 110 | // ); 111 | // } 112 | // } catch (error) { 113 | // console.error(`Error pre-rendering page: ${requestPath}`, error); 114 | // } 115 | // }) 116 | // ); 117 | // } 118 | 119 | // const duration = Date.now() - startTime; 120 | // console.log(`\nPrerendered ${completed} pages in ${duration}ms`); 121 | // } 122 | -------------------------------------------------------------------------------- /packages/sapling/src/sapling-layout.ts: -------------------------------------------------------------------------------- 1 | import { createGenerator } from "@unocss/core"; 2 | import presetUno from "@unocss/preset-uno"; 3 | import type { LayoutProps } from "./types/index.ts"; 4 | import type { UserConfig } from "@unocss/core"; 5 | import { SAPLING_VERSION } from "./constants.ts"; 6 | import { html, raw } from "@hono/hono/html"; 7 | import type { HtmlEscapedString } from "@hono/hono/utils/html"; 8 | 9 | 10 | /** 11 | * The Layout function creates an HTML document with UnoCSS support and optional Tailwind reset styles. 12 | * 13 | * @returns A Promise that resolves to the complete HTML document as a string or a ReadableStream that streams the HTML document 14 | * 15 | * @param props - The properties for the layout 16 | * @param props.unoConfig - Optional custom UnoCSS configuration. If not provided, uses the default UnoCSS preset 17 | * @param props.disableTailwindReset - When true, removes the default Tailwind reset styles 18 | * @param props.head - Additional content to inject into the document's head section 19 | * @param props.bodyClass - Optional class string to add to the body element 20 | * @param props.children - The content to render in the body of the page 21 | * @param props.disableUnoCSS - When true, skips UnoCSS generation 22 | * @param props.enableIslands - When true, adds the islands script and CSS 23 | * @param props.lang - Optional language for the HTML document 24 | * @param props.disableGeneratorTag - When true, skips the generator meta tag 25 | * 26 | * @example 27 | * ```ts 28 | * // Basic usage (non-streaming) 29 | * const html = await Layout({ children: html`

Hello World

` }); 30 | * ``` 31 | */ 32 | export function Layout(props: LayoutProps): HtmlEscapedString | Promise { 33 | // UnoCSS config and generator setup 34 | let config: UserConfig = { 35 | presets: [presetUno()], 36 | }; 37 | let css = { css: "" }; // Default empty CSS if UnoCSS is disabled 38 | 39 | // Only setup UnoCSS if not disabled 40 | if (!props.disableUnoCSS) { 41 | // If no config is provided, use the default UnoCSS preset 42 | if (props.unoConfig) { 43 | config = props.unoConfig; 44 | } 45 | } 46 | 47 | // Tailwind Reset Minified 48 | let resetStyles = `*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:var(--un-default-border-color,#e5e7eb)}::after,::before{--un-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}`; 49 | 50 | // If the tailwind reset is disabled, remove the default tailwind reset 51 | if (props.disableTailwindReset) { 52 | resetStyles = ``; 53 | } 54 | 55 | 56 | return (async () => { 57 | // Only generate UnoCSS if not disabled 58 | if (!props.disableUnoCSS) { 59 | // Create the UnoCSS generator 60 | const generator = await createGenerator(config); 61 | // Generate the CSS from the provided children and body class 62 | css = await generator.generate( 63 | `${props.bodyClass ? `${props.bodyClass} ` : ``} ${props.children}` 64 | ); 65 | } 66 | 67 | // Return the HTML as a string 68 | return html` 69 | 70 | 71 | 72 | 73 | 74 | ${ 75 | props.disableGeneratorTag 76 | ? "" 77 | : raw(``) 78 | } 79 | ${props.disableTailwindReset ? "" : raw(``)} 80 | ${ 81 | !props.disableUnoCSS 82 | ? raw(` 83 | `) 84 | : "" 85 | } 86 | ${ 87 | props.enableIslands 88 | ? raw(` 89 | 90 | 91 | 92 | `) 93 | : "" 94 | } 95 | ${props.head} 96 | 97 | ${props.bodyClass ? raw(``) : raw(``)} 98 | ${props.children} 99 | 100 | 101 | `; 102 | })(); 103 | } 104 | -------------------------------------------------------------------------------- /packages/sapling/src/sapling.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated Sapling is no longer a standalone router. 3 | * 4 | * We recommend using [Hono](https://hono.dev/) instead. 5 | * 6 | * @example 7 | * ```ts 8 | * import { Hono } from "@hono/hono"; 9 | * 10 | * const app = new Hono(); 11 | * 12 | * app.get("/", (c) => c.text("Hello World")); 13 | * 14 | * export default app; 15 | * ``` 16 | */ 17 | export class Sapling {} -------------------------------------------------------------------------------- /packages/sapling/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@unocss/core"; 2 | 3 | export interface LayoutProps { 4 | /** 5 | * Pass an optional custom UnoCSS config 6 | */ 7 | unoConfig?: UserConfig; 8 | /** 9 | * Whether to disable UnoCSS entirely 10 | */ 11 | disableUnoCSS?: boolean; 12 | /** 13 | * Whether to disable the tailwind reset 14 | */ 15 | disableTailwindReset?: boolean; 16 | /** 17 | * Whether to enable Sapling Islands functionality 18 | */ 19 | enableIslands?: boolean; 20 | /** 21 | * Whether to disable the generator meta tag 22 | */ 23 | disableGeneratorTag?: boolean; 24 | /** 25 | * The head content 26 | */ 27 | head?: string | Promise; 28 | /** 29 | * Provide a custom body class 30 | */ 31 | bodyClass?: string; 32 | /** 33 | * The language attribute for the HTML tag. Defaults to "en" 34 | */ 35 | lang?: string; 36 | /** 37 | * The children content to render in the body of the page 38 | */ 39 | children: string | Promise; 40 | /** 41 | * When true, returns a ReadableStream to stream the HTML output. Defaults to false 42 | */ 43 | stream?: boolean; 44 | } 45 | -------------------------------------------------------------------------------- /scripts/saplingVersion.ts: -------------------------------------------------------------------------------- 1 | import { parseArgs } from "jsr:@std/cli"; 2 | 3 | const constantsPath = "packages/sapling/src/constants.ts"; 4 | const denoJsonPath = "packages/sapling/deno.json"; 5 | 6 | /** 7 | * Bumps the version of the Sapling library. 8 | * 9 | * @param {object} options - The options for bumping the version. 10 | * @param {boolean} [options.major] - Whether to bump the major version. 11 | * @param {boolean} [options.minor] - Whether to bump the minor version. 12 | * @returns {void} 13 | * 14 | * @example Major version bump (0.1.0 -> 1.0.0): 15 | * ```bash 16 | * deno run --allow-read --allow-write scripts/saplingVersion.ts --major 17 | * # or 18 | * deno run --allow-read --allow-write scripts/saplingVersion.ts -M 19 | * ``` 20 | * 21 | * @example Minor version bump (0.1.0 -> 0.2.0): 22 | * ```bash 23 | * deno run --allow-read --allow-write scripts/saplingVersion.ts --minor 24 | * # or 25 | * deno run --allow-read --allow-write scripts/saplingVersion.ts -m 26 | * ``` 27 | * 28 | * @example Patch version bump (0.1.0 -> 0.1.1): 29 | * ```bash 30 | * deno run --allow-read --allow-write scripts/saplingVersion.ts 31 | * ``` 32 | */ 33 | function bumpVersion(options: { major?: boolean; minor?: boolean }): void { 34 | // Read the current version from constants.ts 35 | const constantsContent = Deno.readTextFileSync(constantsPath); 36 | const versionMatch = constantsContent.match( 37 | /export const SAPLING_VERSION = "(\d+)\.(\d+)\.(\d+)";/, 38 | ); 39 | 40 | if (!versionMatch) { 41 | console.error("Could not find SAPLING_VERSION in constants.ts"); 42 | Deno.exit(1); 43 | } 44 | 45 | let [major, minor, patch] = versionMatch.slice(1).map(Number); 46 | 47 | // Increment the appropriate version based on flags 48 | if (options.major) { 49 | major++; 50 | minor = 0; 51 | patch = 0; 52 | } else if (options.minor) { 53 | minor++; 54 | patch = 0; 55 | } else { 56 | patch++; 57 | } 58 | 59 | const newVersion = `${major}.${minor}.${patch}`; 60 | 61 | // Update constants.ts 62 | const updatedConstantsContent = constantsContent.replace( 63 | /export const SAPLING_VERSION = "(\d+)\.(\d+)\.(\d+)";/, 64 | `export const SAPLING_VERSION = "${newVersion}";`, 65 | ); 66 | Deno.writeTextFileSync(constantsPath, updatedConstantsContent); 67 | 68 | // Update deno.json 69 | const denoJsonContent = Deno.readTextFileSync(denoJsonPath); 70 | const updatedDenoJsonContent = denoJsonContent.replace( 71 | /"version": "\d+\.\d+\.\d+"/, 72 | `"version": "${newVersion}"`, 73 | ); 74 | Deno.writeTextFileSync(denoJsonPath, updatedDenoJsonContent); 75 | 76 | console.log(`Version bumped to ${newVersion}`); 77 | } 78 | 79 | const parsedArgs = parseArgs(Deno.args); 80 | 81 | bumpVersion({ 82 | major: parsedArgs['--major'] ?? false, 83 | minor: parsedArgs['--minor'] ?? false, 84 | }); 85 | --------------------------------------------------------------------------------