├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore ├── astrox.iml ├── modules.xml └── vcs.xml ├── .npmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── logo.jpeg └── logo.rounded.png ├── examples └── simple-form │ ├── .gitignore │ ├── .idea │ ├── .gitignore │ ├── modules.xml │ ├── simple-form.iml │ └── vcs.xml │ ├── .vscode │ ├── extensions.json │ └── launch.json │ ├── README.md │ ├── astro.config.mjs │ ├── package.json │ ├── public │ └── favicon.svg │ ├── src │ ├── components │ │ ├── Counter.astro │ │ └── ReactComponent.tsx │ ├── env.d.ts │ ├── layouts │ │ └── Layout.astro │ ├── middleware.ts │ └── pages │ │ ├── api.json.ts │ │ ├── button.astro │ │ ├── counter.astro │ │ ├── default-submit.astro │ │ ├── forms.astro │ │ ├── index.astro │ │ ├── multi-file-upload.astro │ │ ├── multi-forms.astro │ │ ├── multi-selects.astro │ │ ├── state-check.astro │ │ ├── state-json-upload.astro │ │ ├── state-lifecycle.astro │ │ ├── throw.astro │ │ ├── upload.astro │ │ └── uploadBigFileTest.astro │ └── tsconfig.json ├── package-lock.json ├── package.json └── packages ├── context ├── .npmignore ├── CHANGELOG.md ├── Context.astro ├── LICENSE ├── README.md ├── context.ts ├── package.json ├── src │ └── index.ts └── tsconfig.json ├── express-endpoints ├── CHANGELOG.md ├── README.md ├── package.json ├── src │ ├── express-route.ts │ ├── http │ │ ├── express-request.ts │ │ ├── express-response.ts │ │ └── http-errors │ │ │ ├── express-body-error.ts │ │ │ └── express-error.ts │ └── index.ts └── tsconfig.json ├── formidable ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src │ ├── ExtendedFormData.ts │ └── index.ts └── tsconfig.json └── forms ├── .idea ├── .gitignore ├── forms.iml ├── modules.xml └── vcs.xml ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── forms.ts ├── package.json ├── src ├── components-control │ ├── form-utils │ │ ├── about-form-name.ts │ │ ├── bind-form-plugins │ │ │ ├── iform-plugin.ts │ │ │ ├── input-radio.ts │ │ │ └── select.ts │ │ ├── bind-form.ts │ │ ├── parse-multi.ts │ │ ├── parse.ts │ │ ├── validate.ts │ │ └── view-state.ts │ ├── input-parse.ts │ ├── props-utils.ts │ └── select.ts ├── components │ ├── WebForms.astro │ └── form │ │ ├── BButton.astro │ │ ├── BInput.astro │ │ ├── BOption.astro │ │ ├── BSelect.astro │ │ ├── BTextarea.astro │ │ ├── BindForm.astro │ │ ├── FormErrors.astro │ │ └── UploadBigFile │ │ ├── BigFile.ts │ │ ├── UploadBigFile.astro │ │ ├── UploadBigFileProgress.astro │ │ ├── uploadBigFileClient.ts │ │ └── uploadBigFileServer.ts ├── errors │ ├── AstroFormsError.ts │ ├── MissingClickActionError.ts │ └── MissingNamePropError.ts ├── form-tools │ ├── connectId.ts │ ├── csrf.ts │ ├── events.ts │ ├── forms-react.ts │ └── post.ts ├── index.ts ├── integration.ts ├── integration │ └── codeTransform.ts ├── jwt-session.ts ├── middleware.ts ├── settings.ts ├── throw-action │ ├── throw-action.ts │ └── throwOverrideResponse.ts └── utils.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ido-pluto -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please refer to the [troubleshooting](https://github.com/@astro-utils/forms/blob/main/docs/troubleshooting.md) before 11 | opening an issue. You might find the solution there. 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **Screenshots** 17 | If applicable, add screenshots to help explain your problem. 18 | 19 | **Desktop (please complete the following information):** 20 | 21 | - OS: [e.g. windows, macOS, linux] 22 | - Browser [e.g. chrome, safari] 23 | - Package name and version [e.g. 0.3.10] 24 | - Node.js version [e.g 19] (`node --version`) 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description of change 2 | 3 | 16 | 17 | ### Pull-Request Checklist 18 | 19 | 24 | 25 | - [ ] Code is up-to-date with the `main` branch 26 | - [ ] This pull request links relevant issues as `Fixes #0000` 27 | - [ ] Documentation has been updated to reflect this change 28 | - [ ] The new commits follow conventions explained 29 | in [CONTRIBUTING.md](https://github.com/withastro-utils/utils/blob/master/CONTRIBUTING.md) 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | 12 | release: 13 | name: Build & Release 14 | runs-on: ubuntu-latest 15 | environment: npm 16 | concurrency: release-${{ github.ref }} 17 | permissions: 18 | id-token: write 19 | contents: write 20 | issues: write 21 | pull-requests: write 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: "20" 28 | - name: Install modules 29 | run: | 30 | npm ci 31 | 32 | - name: Release 33 | if: github.ref == 'refs/heads/main' 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | run: | 38 | npm run create-release 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | ### macOS ### 147 | # General 148 | .DS_Store 149 | .AppleDouble 150 | .LSOverride -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # GitHub Copilot persisted chat sessions 7 | /copilot/chatSessions 8 | -------------------------------------------------------------------------------- /.idea/astrox.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | link-workspace-packages = true 2 | prefer-workspace-packages = true 3 | strict-peer-dependencies = false 4 | auto-install-peers = true -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.excludeGitIgnore": false 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 Ido S. (ido.pluto) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Astro Forms Utils 4 | 5 | Astro Utils 6 | 7 | 8 | [![Build](https://github.com/withastro-utils/utils/actions/workflows/release.yml/badge.svg)](https://github.com/withastro-utils/utils/actions/workflows/build.yml) 9 | [![License](https://badgen.net/badge/color/MIT/green?label=license)](https://www.npmjs.com/package/@astro-utils/forms) 10 | [![License](https://badgen.net/badge/color/TypeScript/blue?label=types)](https://www.npmjs.com/package/@astro-utils/forms) 11 | [![Version](https://badgen.net/npm/v/@astro-utils/forms)](https://www.npmjs.com/package/@astro-utils/forms) 12 |
13 | 14 | > Server component for Astro (validation and state management) 15 | 16 | 17 | # Full feature server components for Astro.js 18 | 19 | This package is a framework for Astro.js that allows you to create forms and manage their state without any JavaScript. 20 | 21 | It also allows you to validate the form on the client side and server side, and protect against CSRF attacks. 22 | 23 | ### More features 24 | - JWT session management 25 | - Override response at runtime (useful for error handling) 26 | - Custom server validation with `zod` 27 | - Multiples app states at the same time 28 | 29 | # Show me the code 30 | ```astro 31 | --- 32 | import { Bind, BindForm, BButton, BInput } from "@astro-utils/forms/forms.js"; 33 | import Layout from "../layouts/Layout.astro"; 34 | 35 | const form = Bind(); 36 | let showSubmitText: string; 37 | 38 | function formSubmit(){ 39 | showSubmitText = `You name is ${form.name}, you are ${form.age} years old. `; 40 | } 41 | --- 42 | 43 | 44 | {showSubmitText} 45 | 46 |

What you name*

47 | 48 | 49 |

Enter age*

50 | 51 | 52 | Submit 53 |
54 |
55 | ``` 56 | 57 | ## Usage 58 | 59 | ### Add the middleware to your server 60 | 61 | ``` 62 | npm install @astro-utils/forms 63 | ``` 64 | 65 | Add the middleware to your server 66 | 67 | 68 | `src/middleware.ts` 69 | ```ts 70 | import astroForms from "@astro-utils/forms"; 71 | import {sequence} from "astro/middleware"; 72 | 73 | export const onRequest = sequence(astroForms()); 74 | ``` 75 | 76 | ### Add to Layout 77 | Add the `WebForms` component in the layout 78 | 79 | `layouts/Layout.astro` 80 | ```astro 81 | --- 82 | import {WebForms} from '@astro-utils/forms/forms.js'; 83 | --- 84 | 85 | 86 | 87 | ``` 88 | 89 | ### Code Integration 90 | This changes astro behavior to allow the form to work, it ensure the components render by the order they are in the file. 91 | 92 | `astro.config.mjs` 93 | ```js 94 | import { defineConfig } from 'astro/config'; 95 | import astroForms from "@astro-utils/forms/dist/integration.js"; 96 | 97 | export default defineConfig({ 98 | output: 'server', 99 | integrations: [astroForms] 100 | }); 101 | ``` -------------------------------------------------------------------------------- /assets/logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/withastro-utils/utils/6cbdfd482d79d48fe00fc152bd5dcd13cb5ea607/assets/logo.jpeg -------------------------------------------------------------------------------- /assets/logo.rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/withastro-utils/utils/6cbdfd482d79d48fe00fc152bd5dcd13cb5ea607/assets/logo.rounded.png -------------------------------------------------------------------------------- /examples/simple-form/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /examples/simple-form/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /examples/simple-form/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/simple-form/.idea/simple-form.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/simple-form/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/simple-form/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /examples/simple-form/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/simple-form/README.md: -------------------------------------------------------------------------------- 1 | # Astro Starter Kit: Basics 2 | 3 | ```sh 4 | npm create astro@latest -- --template basics 5 | ``` 6 | 7 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) 8 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics) 9 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json) 10 | 11 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 12 | 13 | ![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554) 14 | 15 | ## 🚀 Project Structure 16 | 17 | Inside of your Astro project, you'll see the following folders and files: 18 | 19 | ```text 20 | / 21 | ├── public/ 22 | │ └── favicon.svg 23 | ├── src/ 24 | │ ├── components/ 25 | │ │ └── Card.astro 26 | │ ├── layouts/ 27 | │ │ └── Layout.astro 28 | │ └── pages/ 29 | │ └── index.astro 30 | └── package.json 31 | ``` 32 | 33 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. 34 | 35 | There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. 36 | 37 | Any static assets, like images, can be placed in the `public/` directory. 38 | 39 | ## 🧞 Commands 40 | 41 | All commands are run from the root of the project, from a terminal: 42 | 43 | | Command | Action | 44 | | :------------------------ | :----------------------------------------------- | 45 | | `npm install` | Installs dependencies | 46 | | `npm run dev` | Starts local dev server at `localhost:4321` | 47 | | `npm run build` | Build your production site to `./dist/` | 48 | | `npm run preview` | Preview your build locally, before deploying | 49 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 50 | | `npm run astro -- --help` | Get help using the Astro CLI | 51 | 52 | ## 👀 Want to learn more? 53 | 54 | Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). 55 | -------------------------------------------------------------------------------- /examples/simple-form/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'astro/config'; 2 | import react from '@astrojs/react'; 3 | import astroFormsDebug from "@astro-utils/forms/dist/integration.js"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | output: "server", 8 | integrations: [react(), astroFormsDebug] 9 | }); 10 | -------------------------------------------------------------------------------- /examples/simple-form/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-form", 3 | "description": "A simple form with validation", 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro check && astro build", 10 | "preview": "astro preview", 11 | "astro": "astro" 12 | }, 13 | "dependencies": { 14 | "@astro-utils/express-endpoints": "^0.0.1", 15 | "@astro-utils/forms": "^0.0.1", 16 | "@astrojs/check": "^0.7.0", 17 | "@astrojs/react": "^3.6.0", 18 | "@types/react": "^18.3.3", 19 | "@types/react-dom": "^18.3.0", 20 | "astro": "^4.7.0", 21 | "bootstrap": "^5.3.2", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "reactstrap": "^9.2.1", 25 | "sleep-promise": "^9.1.0", 26 | "typescript": "^5.5.3", 27 | "zod": "^3.22.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/simple-form/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /examples/simple-form/src/components/Counter.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import {BButton} from '@astro-utils/forms/forms.js'; 3 | 4 | function inc() { 5 | Astro.locals.session.count ??= 0; 6 | this.innerText = Astro.locals.session.count++; 7 | } 8 | --- 9 | Counter 10 | -------------------------------------------------------------------------------- /examples/simple-form/src/components/ReactComponent.tsx: -------------------------------------------------------------------------------- 1 | export function ReactComponent(){ 2 | return
console.log('ReactComponent')}>ReactComponent
3 | } -------------------------------------------------------------------------------- /examples/simple-form/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /examples/simple-form/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import {WebForms} from '@astro-utils/forms/forms.js'; 3 | 4 | interface Props { 5 | title: string; 6 | } 7 | 8 | const { title } = Astro.props; 9 | 10 | export const astroFiles = import.meta.glob('../pages/*.astro', { eager: true }); 11 | --- 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {title} 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 45 | -------------------------------------------------------------------------------- /examples/simple-form/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { sequence } from 'astro:middleware'; 2 | import astroForms from '@astro-utils/forms'; 3 | 4 | export const onRequest = sequence(astroForms({ 5 | forms: { 6 | bigFilesUpload: { 7 | bigFileServerOptions: { 8 | maxUploadSize: 1024 * 1024 * 1024 * 2.5, // 2.5GB 9 | maxDirectorySize: 1024 * 1024 * 1024 * 10, // 10GB 10 | } 11 | } 12 | } 13 | })); 14 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/api.json.ts: -------------------------------------------------------------------------------- 1 | import {ExpressRoute} from '@astro-utils/express-endpoints'; 2 | import {z} from 'zod'; 3 | 4 | const router = new ExpressRoute(); 5 | 6 | router.body('auto', { 7 | maxFileSize: 10 * 1024 * 1024 // 10MB 8 | }); 9 | 10 | // this validation will only apply to the next route (the PUT route) 11 | router.validate({ 12 | body: z.object({ 13 | name: z.string() 14 | }) 15 | }); 16 | 17 | export const PUT = router.route(async (req, res) => { 18 | await new Promise(res => setTimeout(res, 1000)); 19 | res.json({ 20 | name: req.body.name, 21 | url: 'This is a PUT request' 22 | }); 23 | }); 24 | 25 | export const POST = router.route((req, res) => { 26 | const myFile = req.filesOne.myFile; 27 | 28 | res.json({ 29 | name: myFile?.originalFilename || 'No file', 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/button.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import {BButton, BindForm} from '@astro-utils/forms/forms.js'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import {Button} from 'reactstrap'; 5 | import Layout from '../layouts/Layout.astro'; 6 | 7 | 8 | let showSubmitText: string; 9 | function formSubmit() { 10 | Astro.locals.session.counter ??= 0; 11 | Astro.locals.session.counter++; 12 | showSubmitText = `You clicked ${Astro.locals.session.counter} times`; 13 | } 14 | --- 15 | 16 | 17 | 18 | Submit 19 | {showSubmitText} 20 | 21 | 22 | 23 | {showSubmitText} 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/counter.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, Bind, BindForm } from "@astro-utils/forms/forms.js"; 3 | import { Button } from 'reactstrap'; 4 | import Layout from "../layouts/Layout.astro"; 5 | import sleep from "sleep-promise"; 6 | 7 | const { session } = Astro.locals; 8 | 9 | async function increaseCounter() { 10 | session.counter ??= 0 11 | session.counter++; 12 | console.log(session.counter) 13 | await sleep(1000 * 5); 14 | } 15 | 16 | --- 17 | 18 | 19 | ++ 20 | {session.counter} 21 | 22 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/default-submit.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BInput, BOption, BSelect, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | 5 | const bind = Bind(); 6 | 7 | let message = ''; 8 | function handleSubmit() { 9 | console.log(bind); 10 | message = 'Default submit works!'; 11 | } 12 | --- 13 | 14 | 15 | 16 |

{message}

17 | 18 | 19 | console.log('Do nothing')}>Do noting 20 | 21 | 1 22 | 2 23 | 3 24 | 25 | submit 26 |
27 |
28 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/forms.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BInput, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | 5 | const bind = Bind(); 6 | 7 | function handleSubmit() { 8 | console.log(bind); 9 | } 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | submit 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro'; 3 | import {BButton, Bind, BindForm, BInput, FormErrors} from '@astro-utils/forms/forms.js'; 4 | import {Button} from 'reactstrap'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | import Counter from '../components/Counter.astro'; 7 | 8 | 9 | const form = Bind({age: 0, name: '', about: ''}); 10 | let showSubmitText: string; 11 | 12 | form.on.newState = () => console.log('New state loaded'); 13 | form.on.pagePostBack = () => console.log('pagePostBack'); 14 | form.on.stateLoaded = () => console.log('stateLoaded'); 15 | 16 | function formSubmit(){ 17 | Astro.locals.session.counter ??= 0; 18 | Astro.locals.session.counter++; 19 | showSubmitText = `Your name is ${form.name}, you are ${form.age} years old. `; 20 | form.age++; 21 | } 22 | 23 | let value = 4; 24 | function makeBackgroundRed() { 25 | this.style = 'background-color: red'; 26 | value = 5; 27 | this.innerHTML = 'Clicked ' + (++this.extra || ++this.state.counter); 28 | form.about = 'This is a form about something'; 29 | } 30 | --- 31 | 32 | Should click 33 | 34 | 35 | {[1, 2, 3].map(key => 36 | Should click 37 | )} 38 | 39 |

Value: {value}

40 |

About: {form.about}

41 | 42 | 43 | {showSubmitText} 44 | 45 | {Astro.locals.session.counter && 46 |

You have submitted {Astro.locals.session.counter} times.

47 | } 48 | 49 |

What you name*

50 | 51 | 52 |

Enter age*

53 | 54 | 55 | Submit 56 | 57 |
58 |
59 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/multi-file-upload.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro'; 3 | import {BButton, Bind, BindForm, BInput, FormErrors} from '@astro-utils/forms/forms.js'; 4 | import {Button} from 'reactstrap'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | 7 | type Form = { 8 | files: File[]; 9 | } 10 | 11 | const form = Bind
(); 12 | let showSubmitText: string; 13 | 14 | function formSubmit() { 15 | showSubmitText = `You upload "${form.files.map(file => file.name).join(', ')}"`; 16 | } 17 | --- 18 | 19 | 20 | 21 | {showSubmitText} 22 | 23 |

File to upload*

24 | 25 | 26 | Submit 27 |
28 |
29 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/multi-forms.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BInput, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | 5 | const bind1 = Bind({ bind: 1, num: 1 }); 6 | const bind2 = Bind({ bind: 2, num: 2 }); 7 | const bind3 = Bind({ bind: 3, num: 3 }); 8 | const bind4 = Bind({ bind: 4, num: 0 }); 9 | 10 | function submit() { 11 | this.extra.num++; 12 | this.style = `background-color: #${Math.floor(Math.random() * 16777215).toString(16)}`; 13 | 14 | console.log(this.extra.bind); 15 | } 16 | --- 17 | 18 | 19 | 20 | 21 | Increase 22 | 23 | 24 | 25 | Increase 26 | 27 | 28 | 29 | 30 | 31 | Increase 32 | 33 | 34 | 35 | 36 | 37 | { 38 | bind4.num > 3 ? null : ( 39 | 40 | Increase 41 | 42 | ) 43 | } 44 | 45 | 46 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/multi-selects.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BOption, BSelect, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | 5 | const bind = Bind(); 6 | function onSubmit() { 7 | console.log(bind); 8 | } 9 | 10 | const date = new Date('2021-01-01'); 11 | --- 12 | 13 | 14 | 15 | 16 | Option A 17 | Option B 18 | Option C 19 | 20 | 21 | 22 | 2a 23 | 2b 24 | 2c 25 | 26 | 27 | 28 | _1_ 29 | 2 30 | 3 31 | 32 | 33 | 34 | number 1 35 | number 2 36 | number 3 37 | 38 | 39 | Submit 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/state-check.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BInput, BOption, BSelect, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | 5 | const bind = Bind({}); 6 | 7 | bind.on.newState = () => { 8 | bind.num = [1, 2, 3, 9]; 9 | }; 10 | 11 | function onSubmit() { 12 | console.log(bind.num); 13 | } 14 | --- 15 | 16 | 17 | 18 | {bind.num?.map((value, index) => )} 19 | Submit 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/state-json-upload.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BInput, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | import { ReactComponent } from '../components/ReactComponent.tsx'; 5 | 6 | const bind = Bind({ json: { a: 2 } }); 7 | 8 | function onSubmit() { 9 | console.log(bind.json); 10 | } 11 | --- 12 | 13 | 14 | 15 | 16 | 17 | Submit 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/state-lifecycle.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { BButton, BInput, BOption, BSelect, Bind, BindForm } from '@astro-utils/forms/forms.js'; 3 | import Layout from '../layouts/Layout.astro'; 4 | 5 | const bind = Bind(async () => { 6 | console.log('defaults'); 7 | return {input: 'Hi'}; 8 | }); 9 | 10 | bind.on.newState = () => { 11 | console.log('new state'); 12 | } 13 | 14 | bind.on.pagePostBack = () => { 15 | console.log('page post back'); 16 | } 17 | 18 | bind.on.stateLoaded = () => { 19 | console.log('state loaded'); 20 | } 21 | 22 | function onSubmit() { 23 | console.log(bind.input); 24 | } 25 | --- 26 | 27 | 28 | 29 | 30 | 31 | Submit 32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/throw.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { ThrowOverrideResponse } from '@astro-utils/forms/forms.js'; 3 | 4 | throw new ThrowOverrideResponse(new Response('Throwed Hi')); 5 | --- 6 | 7 | Hi 8 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/upload.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro'; 3 | import {BButton, Bind, BindForm, BInput, FormErrors} from '@astro-utils/forms/forms.js'; 4 | import {Button} from 'reactstrap'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | 7 | type Form = { 8 | name: string; 9 | file: File; 10 | } 11 | 12 | const form = Bind(); 13 | let showSubmitText: string; 14 | 15 | function formSubmit() { 16 | Astro.locals.session.counter ??= 0; 17 | Astro.locals.session.counter++; 18 | showSubmitText = `Your name is ${form.name}, you upload "${form.file.name}"`; 19 | } 20 | --- 21 | 22 | 23 | 24 | {showSubmitText} 25 | 26 | {Astro.locals.session.counter && 27 |

You have submitted {Astro.locals.session.counter} times.

28 | } 29 | 30 |

What you name*

31 | 32 | 33 |

File to upload*

34 | 35 | 36 | Submit 37 |
38 |
39 | -------------------------------------------------------------------------------- /examples/simple-form/src/pages/uploadBigFileTest.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro'; 3 | import {BButton, Bind, BindForm, BInput, FormErrors, UploadBigFile, UploadBigFileProgress} from '@astro-utils/forms/forms.js'; 4 | import {Button} from 'reactstrap'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | import type { BigFile } from '../../../../packages/forms/dist/components/form/UploadBigFile/BigFile.ts'; 7 | 8 | type Form = { 9 | file: BigFile; 10 | } 11 | 12 | const form = Bind(); 13 | let showSubmitText: string; 14 | 15 | function formSubmit() { 16 | showSubmitText = `you upload "${form.file.name}"`; 17 | } 18 | --- 19 | 20 | 21 | 22 | {showSubmitText} 23 | 24 |

File to upload*

25 | 26 |
27 | 28 | 29 |
30 | 31 | Submit 32 |
33 |
34 | -------------------------------------------------------------------------------- /examples/simple-form/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/base", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react" 6 | } 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-utils", 3 | "workspaces": [ 4 | "packages/*", 5 | "examples/*" 6 | ], 7 | "workspaceRelease": { 8 | "npmRelease": true, 9 | "extendsReleaseRules": [ 10 | { 11 | "type": "bump", 12 | "release": "patch" 13 | } 14 | ] 15 | }, 16 | "scripts": { 17 | "create-release": "semantic-release-npm-workspaces-monorepo" 18 | }, 19 | "devDependencies": { 20 | "semantic-release-npm-workspaces-monorepo": "^2.2.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/context/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tsconfig.json 3 | .gitignore -------------------------------------------------------------------------------- /packages/context/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.15](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.14...@astro-utils/context@1.1.15) (2024-07-07) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **context:** name based ([3e99189](https://github.com/withastro-utils/utils/commit/3e991896f61ca6128d6b284f0ac87b5eeefadb13)) 7 | 8 | ## [1.1.14](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.13...@astro-utils/context@1.1.14) (2024-07-01) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **context:** astro state ([c3c8bf4](https://github.com/withastro-utils/utils/commit/c3c8bf4c99aff7dd2a3eeabef77fca6349f794a8)) 14 | 15 | ## [1.1.13](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.12...@astro-utils/context@1.1.13) (2024-06-30) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **context:** per request lock ([af66bce](https://github.com/withastro-utils/utils/commit/af66bce16c6411e787ad9a40e98c6b6499fcba63)) 21 | 22 | ## [1.1.12](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.11...@astro-utils/context@1.1.12) (2024-05-08) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **nested-forms:** stack loading nested forms ([b2caa6e](https://github.com/withastro-utils/utils/commit/b2caa6e06a2b3e17207572e4e1829d3757883a95)) 28 | 29 | ## [1.1.11](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.10...@astro-utils/context@1.1.11) (2024-04-02) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **context:** lock ([1dfdb4d](https://github.com/withastro-utils/utils/commit/1dfdb4d003af3582de117c9acad32d35d7cac831)) 35 | 36 | ## [1.1.10](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.9...@astro-utils/context@1.1.10) (2024-04-01) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * **select:** lock context ([7c8e37f](https://github.com/withastro-utils/utils/commit/7c8e37f377840e9324585f21a8db9760fb9b6015)) 42 | 43 | ## [1.1.9](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.8...@astro-utils/context@1.1.9) (2024-04-01) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **slot:** wait for render to finish ([5deebe0](https://github.com/withastro-utils/utils/commit/5deebe07daf04c08acdb34d0ebd487e9bbb2e623)) 49 | 50 | ## [1.1.8](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.7...@astro-utils/context@1.1.8) (2023-12-18) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * context import types ([70dd270](https://github.com/withastro-utils/utils/commit/70dd27012c9729179e6e602c0307a0bf7bc44d80)) 56 | 57 | ## [1.1.7](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.6...@astro-utils/context@1.1.7) (2023-12-18) 58 | 59 | ## [1.1.6](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.5...@astro-utils/context@1.1.6) (2023-11-23) 60 | 61 | ## [1.1.5](https://github.com/withastro-utils/utils/compare/@astro-utils/context@1.1.4...@astro-utils/context@1.1.5) (2023-11-21) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * packages astro keywords ([04faf55](https://github.com/withastro-utils/utils/commit/04faf559ea1326936e137c2783894b2792cfa9af)) 67 | -------------------------------------------------------------------------------- /packages/context/Context.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { asyncContext } from "./dist/index.js"; 3 | 4 | export interface Props { 5 | [key: string]: any 6 | contextName?: string 7 | lock?: string 8 | } 9 | 10 | const {contextName, lock, ...props} = Astro.props; 11 | const htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, {name: contextName, lock, context: props}); 12 | --- 13 | 14 | -------------------------------------------------------------------------------- /packages/context/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 Ido S. (ido.pluto) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /packages/context/README.md: -------------------------------------------------------------------------------- 1 | # Astro Context 2 | 3 | Save context between components 4 | 5 | Allow you to add extra props without the need to manually add them every time 6 | 7 | ## Usage 8 | 9 | `layouts/Layout.astro` 10 | ```astro 11 | --- 12 | import Context from '@astro-utils/context/context.js'; 13 | 14 | function consoleIt(){ 15 | console.log('Hi'); 16 | } 17 | --- 18 | 19 | 20 | 21 | ``` 22 | 23 | `components/LayoutTitle.astro` 24 | ```astro 25 | --- 26 | import getContextProps from '@astro-utils'; 27 | 28 | const {title, consoleIt} = getContextProps(Astro); 29 | consoleIt(); 30 | --- 31 |

{title}

32 | ``` 33 | 34 | `pages/index.astro` 35 | 36 | ```astro 37 | --- 38 | import Layout from '../layouts/Layout.astro'; 39 | import LayoutTitle from '../components/LayoutTitle.astro'; 40 | --- 41 | 42 | 43 | 44 | ``` 45 | 46 | ## Functions 47 | 48 | ```ts 49 | // remember to change the name if you have multiple contexts 50 | function getContextProps(astro: AstroGlobal, name = "default"): {[key: string]: any} 51 | ``` 52 | 53 | Every new context inherits the last one 54 | 55 | 56 | ```ts 57 | async function readerInContext(promise: () => Promise, astro: AstroGlobal, name = "default"): Promise 58 | ``` 59 | 60 | Same as `Context.astro`, help you render astro inside the props context 61 | -------------------------------------------------------------------------------- /packages/context/context.ts: -------------------------------------------------------------------------------- 1 | import Context from './Context.astro'; 2 | import getContext, {asyncContext} from './dist/index'; 3 | 4 | export {getContext, asyncContext}; 5 | export default Context; 6 | -------------------------------------------------------------------------------- /packages/context/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astro-utils/context", 3 | "version": "0.0.1", 4 | "description": "React like Context for astro.js", 5 | "type": "module", 6 | "scripts": { 7 | "build": "rm -r dist; tsc", 8 | "prepack": "npm run build" 9 | }, 10 | "keywords": [ 11 | "astro", 12 | "props", 13 | "react", 14 | "context", 15 | "withastro", 16 | "astro-utils", 17 | "jsx" 18 | ], 19 | "homepage": "https://withastro-utils.github.io/docs/", 20 | "author": "Ido S.", 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/withastro-utils/utils.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/withastro-utils/utils/issues" 28 | }, 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "files": [ 33 | "dist/*", 34 | "README.md", 35 | "Context.astro", 36 | "context.ts", 37 | "LICENSE" 38 | ], 39 | "main": "./dist/index.js", 40 | "exports": { 41 | ".": "./dist/index.js", 42 | "./Context.astro": "./Context.astro", 43 | "./context.js": "./context.js" 44 | }, 45 | "devDependencies": { 46 | "astro": "^4.0.6", 47 | "typescript": "^5.2.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/context/src/index.ts: -------------------------------------------------------------------------------- 1 | import { AstroGlobal } from 'astro'; 2 | 3 | declare global { 4 | export namespace App { 5 | export interface Locals { 6 | [key: string]: any; 7 | } 8 | } 9 | } 10 | 11 | type ContextAstro = AstroGlobal | { 12 | request: Request, 13 | locals: any, 14 | props: any; 15 | }; 16 | 17 | type ContextHistory = { 18 | history: any[], 19 | lock: Map>; 20 | }; 21 | 22 | function getAMContextFromAstro(astro: ContextAstro, name: string): ContextHistory { 23 | const amContext = astro.locals.amContext ??= new Map(); 24 | 25 | const namedContext = amContext.get(name) ?? { 26 | history: [], 27 | lock: new Map(), 28 | }; 29 | 30 | amContext.set(name, namedContext); 31 | return namedContext; 32 | } 33 | 34 | export default function getContext(astro: ContextAstro, name = "default") { 35 | const contexts = getAMContextFromAstro(astro, name); 36 | return contexts.history.at(-1) ?? {}; 37 | } 38 | 39 | type AsyncContextOptions = { name?: string, context?: any, lock?: string; }; 40 | 41 | export async function asyncContext(promise: () => Promise, astro: ContextAstro, { name = "default", context = null, lock }: AsyncContextOptions = {}): Promise { 42 | const contextState = getAMContextFromAstro(astro, name); 43 | 44 | while (contextState.lock.get(lock)) { 45 | await contextState.lock.get(lock); 46 | } 47 | 48 | contextState.history.push({ 49 | ...contextState.history.at(-1), 50 | ...(context ?? astro.props) 51 | }); 52 | 53 | let resolver: () => void | null; 54 | if (lock) contextState.lock.set(lock, new Promise(resolve => resolver = resolve)); 55 | 56 | try { 57 | return await promise(); 58 | } finally { 59 | contextState.history.pop(); 60 | contextState.lock.delete(lock); 61 | resolver?.(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/context/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "nodenext", 6 | "noImplicitAny": false, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "sourceMap": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "allowSyntheticDefaultImports": true, 12 | "outDir": "dist", 13 | "rootDir": "src", 14 | "declaration": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": [ 18 | "src/**/*.ts" 19 | ], 20 | "exclude": [ 21 | "src/**/*.spec.ts", 22 | "node_modules" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/express-endpoints/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.1.1](https://github.com/withastro-utils/utils/compare/@astro-utils/express-endpoints@2.1.0...@astro-utils/express-endpoints@2.1.1) (2024-04-04) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * min & max date ([e8a4b9b](https://github.com/withastro-utils/utils/commit/e8a4b9ba570cac5924fa3e8ebd5d0e27002f558b)) 7 | 8 | # [2.1.0](https://github.com/withastro-utils/utils/compare/@astro-utils/express-endpoints@2.0.0...@astro-utils/express-endpoints@2.1.0) (2023-12-24) 9 | 10 | 11 | ### Features 12 | 13 | * form state (ASPX) ([#3](https://github.com/withastro-utils/utils/issues/3)) ([1f71d80](https://github.com/withastro-utils/utils/commit/1f71d8035b4251f133333cfa35660070a5423492)) 14 | 15 | # [2.0.0](https://github.com/withastro-utils/utils/compare/@astro-utils/express-endpoints@1.0.3...@astro-utils/express-endpoints@2.0.0) (2023-12-18) 16 | 17 | 18 | ### Features 19 | 20 | * multipart using native form parser ([9bbc717](https://github.com/withastro-utils/utils/commit/9bbc71760cfc0ce99daebe7cff62f8e433d4ee6d)) 21 | 22 | 23 | ### BREAKING CHANGES 24 | 25 | * cannot configure formidable options 26 | 27 | ## [1.0.3](https://github.com/withastro-utils/utils/compare/@astro-utils/express-endpoints@1.0.2...@astro-utils/express-endpoints@1.0.3) (2023-11-21) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * packages astro keywords ([04faf55](https://github.com/withastro-utils/utils/commit/04faf559ea1326936e137c2783894b2792cfa9af)) 33 | -------------------------------------------------------------------------------- /packages/express-endpoints/README.md: -------------------------------------------------------------------------------- 1 | # Astro Express Endpoints 2 | 3 | Let you use an express-like framework in astro endpoints, with builtin: 4 | 5 | - Cookie parser 6 | - Body parser 7 | - Express middleware 8 | - Body validation (via [`zod-express-middleware`](https://www.npmjs.com/package/expree)) 9 | - Session (When using [`@astro-utils/forms`](https://www.npmjs.com/package/@astro-utils/forms)) 10 | 11 | ## Usage 12 | 13 | ```ts 14 | const router = new ExpressRoute(); 15 | 16 | router.validate({ 17 | body: z.object({ 18 | name: z.string() 19 | }) 20 | }); 21 | 22 | export const POST = router.route(async (req, res) => { 23 | await new Promise(res => setTimeout(res, 1000)); 24 | res.json({ 25 | name: req.body.name, 26 | url: 'This is a POST request' 27 | }); 28 | }); 29 | ``` 30 | 31 | When using the `validate` method it will be applied only to the next route. 32 | 33 | Meaning that you can use the same router for multiple methods. 34 | 35 | ## Body parser 36 | 37 | The default body-parser is `auto` meaning that it will parse the body no matter the type of it 38 | including `multipart/form-data`. 39 | 40 | You can configure the body parser by calling the `body` method. 41 | 42 | ```ts 43 | const router = new ExpressRoute(); 44 | 45 | router.body('multipart'); 46 | 47 | export const POST = router.route((req, res) => { 48 | const myFile = req.filesOne.myFile; 49 | 50 | res.json({ 51 | name: myFile?.name || 'No file', 52 | }); 53 | }); 54 | ``` 55 | 56 | ## Cookie parser 57 | 58 | Use the default astro cookie parser for that 59 | -------------------------------------------------------------------------------- /packages/express-endpoints/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astro-utils/express-endpoints", 3 | "version": "0.0.1", 4 | "description": "Express-like framework for Astro Endpoints", 5 | "type": "module", 6 | "scripts": { 7 | "build": "rm -r dist; tsc", 8 | "prepack": "npm run build" 9 | }, 10 | "files": [ 11 | "dist/*", 12 | "README.md", 13 | "LICENSE" 14 | ], 15 | "main": "./dist/index.js", 16 | "exports": { 17 | ".": "./dist/index.js" 18 | }, 19 | "keywords": [ 20 | "astro", 21 | "express", 22 | "astro-utils", 23 | "rest", 24 | "restful", 25 | "http", 26 | "api", 27 | "endpoints", 28 | "withastro" 29 | ], 30 | "homepage": "https://withastro-utils.github.io/docs/", 31 | "author": "Ido S.", 32 | "license": "MIT", 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/withastro-utils/utils.git" 36 | }, 37 | "bugs": { 38 | "url": "https://github.com/withastro-utils/utils/issues" 39 | }, 40 | "publishConfig": { 41 | "access": "public" 42 | }, 43 | "devDependencies": { 44 | "@types/mime": "^3.0.4", 45 | "@types/statuses": "^2.0.4", 46 | "astro": "^4.0.6", 47 | "typescript": "^5.2.2" 48 | }, 49 | "dependencies": { 50 | "@tinyhttp/accepts": "^2.2.0", 51 | "cookie": "^0.6.0", 52 | "express-serve-static-core": "0.1.1", 53 | "mime": "^3.0.0", 54 | "statuses": "^2.0.1", 55 | "zod-express-middleware": "^1.4.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/express-endpoints/src/express-route.ts: -------------------------------------------------------------------------------- 1 | import ExpressRequest from './http/express-request.js'; 2 | import ExpressResponse from './http/express-response.js'; 3 | import {APIRoute} from 'astro'; 4 | import {RequestValidation, validateRequest} from 'zod-express-middleware'; 5 | import {RequestHandlerParams} from 'express-serve-static-core'; 6 | 7 | export type ExpressRouteBodyType = 'json' | 'multipart' | 'urlencoded' | 'text' | 'auto'; 8 | export type ExpressRouteCallback = (req: ExpressRequest, res: ExpressResponse, next?: () => any) => any; 9 | export type ExpressRouteBodyOptions = { 10 | type?: ExpressRouteBodyType, 11 | default?: boolean 12 | }; 13 | 14 | export default class ExpressRoute { 15 | private _middleware: ExpressRouteCallback[] = []; 16 | private _lastValidation: ExpressRouteCallback[] = []; 17 | private _bodyOptions: ExpressRouteBodyOptions = {type: 'auto', default: true}; 18 | 19 | public constructor() { 20 | } 21 | 22 | use(middleware: ExpressRouteCallback | RequestHandlerParams | ExpressRoute) { 23 | if (middleware instanceof ExpressRoute) { 24 | this._middleware = this._middleware.concat(middleware._middleware); 25 | if (!middleware._bodyOptions.default) { 26 | this._bodyOptions = middleware._bodyOptions; 27 | } 28 | return this; 29 | } 30 | this._middleware.push(middleware as any); 31 | return this; 32 | } 33 | 34 | body(type: ExpressRouteBodyType | null) { 35 | this._bodyOptions = {type}; 36 | return this; 37 | } 38 | 39 | /** 40 | * Add validation middleware 41 | * 42 | * Check out [zod-express-middleware](https://www.npmjs.com/package/zod-express-middleware) 43 | */ 44 | validate(schemas: RequestValidation) { 45 | this._lastValidation.push(validateRequest(schemas) as any); 46 | return this; 47 | } 48 | 49 | route(...middlewares: ExpressRouteCallback[]): APIRoute { 50 | const bodyOptions = this._bodyOptions; 51 | const validation = this._lastValidation.pop(); 52 | if (validation) { 53 | middlewares.unshift(validation); 54 | } 55 | return async (context) => { 56 | try { 57 | const request = new ExpressRequest(context, bodyOptions); 58 | await request._parse(); 59 | 60 | await this._runMiddleware(request, middlewares); 61 | request.emit('close'); 62 | request.emit('finish'); 63 | return request._response._createResponseNativeObject(); 64 | } catch (error: any) { 65 | return new Response(error.message, {status: error.status ?? 500}); 66 | } 67 | }; 68 | } 69 | 70 | private async _runMiddleware(req: ExpressRequest, extraMiddleware: ExpressRouteCallback[] = []) { 71 | for (const middleware of this._middleware.concat(extraMiddleware)) { 72 | let runNext = false; 73 | let okToRunNext = () => runNext = true; 74 | await middleware(req, req._response, okToRunNext); 75 | 76 | if (!runNext) { 77 | break; 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/express-endpoints/src/http/express-request.ts: -------------------------------------------------------------------------------- 1 | import ExpressResponse from './express-response.js'; 2 | import {APIContext, Props} from 'astro'; 3 | import {parse as cookieParse} from 'cookie'; 4 | import mime from 'mime'; 5 | import ExpressBodyError from './http-errors/express-body-error.js'; 6 | import {EventEmitter} from 'events'; 7 | import type {ExpressRouteBodyType} from '../express-route.js'; 8 | import {ExpressRouteBodyOptions} from '../express-route.js'; 9 | import {Accepts} from '@tinyhttp/accepts'; 10 | 11 | interface ExpressRequestEventEmitterTypes { 12 | on(event: 'close', listener: (error?: Error) => void): this; 13 | 14 | emit(event: 'close', error?: Error): boolean; 15 | } 16 | 17 | const BODY_REQUEST_TYPES_MAP = { 18 | json: 'application/json', 19 | multipart: 'multipart/form-data', 20 | urlencoded: 'application/x-www-form-urlencoded', 21 | text: 'text/plain' 22 | } as const; 23 | 24 | const BODY_METHODS = ['POST', 'PUT', 'PATCH'] as const; 25 | 26 | type StringMap = { [key: string]: string }; 27 | export default class ExpressRequest extends EventEmitter implements ExpressRequestEventEmitterTypes { 28 | private _accepts: Accepts; 29 | 30 | /** 31 | * @internal 32 | */ 33 | public _response: ExpressResponse; 34 | 35 | public query: StringMap = {}; 36 | public cookies: StringMap = {}; 37 | public session: StringMap = {}; 38 | public body: any = {}; 39 | public headers: StringMap = {}; 40 | public params: StringMap = {}; 41 | public filesOne: { 42 | [key: string]: File 43 | } = {}; 44 | public filesMany: { 45 | [key: string]: File[] 46 | } = {}; 47 | public method: string = ''; 48 | public url: string = ''; 49 | public path: string = ''; 50 | public subdomains: string[] = []; 51 | public hostname: string = ''; 52 | public ip: string = ''; 53 | public locals: APIContext['locals'] = {}; 54 | public error?: Error; 55 | 56 | 57 | constructor(public astroContext: APIContext, private _bodyOptions: ExpressRouteBodyOptions) { 58 | super(); 59 | this._response = new ExpressResponse(astroContext); 60 | } 61 | 62 | /** 63 | * @internal 64 | */ 65 | public async _parse() { 66 | this.query = Object.fromEntries(this.astroContext.url.searchParams.entries()); 67 | this.headers = Object.fromEntries([...this.astroContext.request.headers].map(([key, value]) => [key.toLowerCase(), value])); 68 | this.method = this.astroContext.request.method; 69 | this.url = this.astroContext.url.href; 70 | this.path = this.astroContext.url.pathname; 71 | this.cookies = cookieParse(this.headers.cookie ?? ''); 72 | this.locals = this.astroContext.locals; 73 | this.session = this.astroContext.locals.session; 74 | this.params = this.astroContext.params; 75 | this.subdomains = this.astroContext.url.hostname.split('.').slice(0, -2); 76 | this.hostname = this.astroContext.url.hostname; 77 | this.ip = this.astroContext.clientAddress; 78 | this._accepts = new Accepts(this); 79 | 80 | if (this._bodyOptions.type && BODY_METHODS.includes(this.method as any)) { 81 | await this.parseBody(this._bodyOptions.type); 82 | } 83 | } 84 | 85 | async parseBody(type: ExpressRouteBodyType) { 86 | if (!BODY_METHODS.includes(this.method as any)) { 87 | throw new ExpressBodyError(`Body parsing only available for ${BODY_METHODS.join(', ')}`, 500); 88 | } 89 | 90 | if (this.astroContext.request.bodyUsed) { 91 | throw new ExpressBodyError('Request body already used', 500); 92 | } 93 | 94 | if (type === 'auto') { 95 | const contentType = this.get('content-type').split(';').shift().trim(); 96 | type = Object.entries(BODY_REQUEST_TYPES_MAP).find(([, value]) => value === contentType)?.[0] as ExpressRouteBodyType ?? contentType as any; 97 | } 98 | 99 | switch (type) { 100 | case 'json': 101 | this.body = await this.astroContext.request.json(); 102 | break; 103 | case 'multipart': 104 | await this._parseBodyMultiPart(); 105 | break; 106 | case 'urlencoded': 107 | this.body = await this.astroContext.request.formData(); 108 | break; 109 | case 'text': 110 | this.body = await this.astroContext.request.text(); 111 | break; 112 | default: 113 | throw new ExpressBodyError(`Unknown body type ${type}`); 114 | } 115 | 116 | return this.body; 117 | } 118 | 119 | private async _parseBodyMultiPart() { 120 | try { 121 | const formData = await this.astroContext.request.formData(); 122 | 123 | for (const [key, value] of formData) { 124 | if (typeof value === 'string') { 125 | if (this.body[key]) { 126 | if (!Array.isArray(this.body[key])) { 127 | this.body[key] = [this.body[key]]; 128 | } 129 | 130 | this.body[key].push(value); 131 | } else { 132 | this.body[key] = value; 133 | } 134 | continue; 135 | } 136 | 137 | this.filesOne[key] = value; 138 | this.filesMany[key] ??= []; 139 | this.filesMany[key].push(value); 140 | } 141 | } catch (error) { 142 | this.error = error; 143 | } 144 | 145 | } 146 | 147 | /** 148 | * Get the response header 149 | */ 150 | public get(headerName: string): string | undefined { 151 | return this.headers[headerName.toLowerCase()]; 152 | } 153 | 154 | /** 155 | * Check header content type 156 | * @example 157 | * request.is('json'); 158 | */ 159 | public is(type: string) { 160 | type = BODY_REQUEST_TYPES_MAP[type] ?? type; 161 | const contentType = this.get('content-type').split(';').shift().trim(); 162 | return contentType === mime.getType(type); 163 | } 164 | 165 | public accepts(types: string | string[], ...args: string[]) { 166 | return this._accepts.types(types, ...args); 167 | } 168 | 169 | public acceptsCharsets(types: string | string[], ...args: string[]) { 170 | return this._accepts.charsets(types, ...args); 171 | } 172 | 173 | public acceptsEncodings(types: string | string[], ...args: string[]) { 174 | return this._accepts.encodings(types, ...args); 175 | } 176 | 177 | public acceptsLanguages(types: string | string[], ...args: string[]) { 178 | return this._accepts.languages(types, ...args); 179 | } 180 | 181 | public param(name: string, defaultValue?: any) { 182 | return this.params[name] ?? this.body[name] ?? this.query[name] ?? defaultValue; 183 | } 184 | 185 | public header(name: string, defaultValue?: any) { 186 | return this.get(name) ?? defaultValue; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /packages/express-endpoints/src/http/express-response.ts: -------------------------------------------------------------------------------- 1 | import {APIContext, Props, ValidRedirectStatus} from 'astro'; 2 | import mime from 'mime'; 3 | import statuses from 'statuses'; 4 | import * as fs from 'fs/promises'; 5 | import * as path from 'path'; 6 | import ExpressError from './http-errors/express-error.js'; 7 | 8 | type ExpressResponseCookieDeleteOptions = { 9 | domain?: string; 10 | path?: string; 11 | } 12 | 13 | type ExpressResponseCookieSetOptions = { 14 | expires?: Date; 15 | httpOnly?: boolean; 16 | maxAge?: number; 17 | sameSite?: boolean | 'lax' | 'none' | 'strict'; 18 | secure?: boolean; 19 | } & ExpressResponseCookieDeleteOptions; 20 | 21 | type CacheControlOptions = { 22 | minuets?: number; 23 | days?: number; 24 | months?: number; 25 | } 26 | 27 | export default class ExpressResponse extends Headers { 28 | private _responseClosed = false; 29 | public responseBody: string | Buffer | Uint8Array = ''; 30 | public statusCode: number = 200; 31 | 32 | public constructor(public astroContext: APIContext) { 33 | super(); 34 | } 35 | 36 | /** 37 | * Set a cookie 38 | */ 39 | public cookie(key: string, value: string | Record, options?: ExpressResponseCookieSetOptions) { 40 | this.astroContext.cookies.set(key, value, options); 41 | return this; 42 | } 43 | 44 | public clearCookie(key: string, options?: ExpressResponseCookieDeleteOptions) { 45 | this.astroContext.cookies.delete(key, options); 46 | return this; 47 | } 48 | 49 | /** 50 | * Set the content type 51 | */ 52 | public type(type: string) { 53 | type = mime.getType(type) || type; 54 | this.set('Content-Type', `${type}; charset=utf-8`); 55 | return this; 56 | } 57 | 58 | /** 59 | * Set the status code 60 | */ 61 | public status(status: number) { 62 | this.statusCode = status; 63 | return this; 64 | } 65 | 66 | /** 67 | * Send a json response 68 | */ 69 | public json(body: any) { 70 | this.responseBody = JSON.stringify(body); 71 | this.type('json'); 72 | return this; 73 | } 74 | 75 | /** 76 | * Send an html response 77 | */ 78 | public html(body: string) { 79 | this.responseBody = body; 80 | this.type('html'); 81 | return this; 82 | } 83 | 84 | /** 85 | * Send a body response 86 | */ 87 | public send(body: string | Buffer | Uint8Array | any) { 88 | if (this._responseClosed) { 89 | throw new ExpressError('Response already closed'); 90 | } 91 | 92 | if (body instanceof Buffer || body instanceof Uint8Array) { 93 | this.responseBody = body; 94 | } else if (typeof body === 'object' && body !== undefined) { 95 | return this.json(body); 96 | } 97 | 98 | this.responseBody = body; 99 | return this; 100 | } 101 | 102 | /** 103 | * End the response 104 | */ 105 | public end(body: string | Buffer | Uint8Array | any) { 106 | this.send(body); 107 | this._responseClosed = true; 108 | return this; 109 | } 110 | 111 | /** 112 | * Sets the response HTTP status code to statusCode and sends the registered status message as the text response body 113 | */ 114 | public sendStatus(status: number) { 115 | this.status(status); 116 | this.responseBody = statuses(status) || status.toString(); 117 | return this; 118 | } 119 | 120 | /** 121 | * Redirect to a url 122 | */ 123 | public redirect(url: string, status: ValidRedirectStatus = 302) { 124 | this.set('Location', url); 125 | this.status(status); 126 | return this; 127 | } 128 | 129 | /** 130 | * Send a file 131 | */ 132 | public async sendFile(filePath: string) { 133 | const content = await fs.readFile(filePath); 134 | this.responseBody = content; 135 | this.type(filePath); 136 | return this; 137 | } 138 | 139 | /** 140 | * Send a file as an attachment 141 | */ 142 | public async attachment(filePath: string, fileName = path.parse(filePath).name) { 143 | await this.sendFile(filePath); 144 | this.set('Content-Disposition', `attachment; filename="${fileName}"`); 145 | return this; 146 | } 147 | 148 | /** 149 | * Set the cache control header 150 | */ 151 | public cacheControl({days = 0, months = 0, minuets = 0}: CacheControlOptions) { 152 | const totalSeconds = days * 24 * 60 * 60 + months * 30 * 24 * 60 * 60 + minuets * 60; 153 | this.set('Cache-Control', `max-age=${totalSeconds}`); 154 | return this; 155 | } 156 | 157 | /** 158 | * @internal 159 | */ 160 | public _createResponseNativeObject() { 161 | return new Response(this.responseBody, { 162 | headers: this, 163 | status: this.statusCode 164 | }); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /packages/express-endpoints/src/http/http-errors/express-body-error.ts: -------------------------------------------------------------------------------- 1 | import ExpressError from './express-error.js'; 2 | 3 | export default class ExpressBodyError extends ExpressError { 4 | } 5 | -------------------------------------------------------------------------------- /packages/express-endpoints/src/http/http-errors/express-error.ts: -------------------------------------------------------------------------------- 1 | export default class ExpressError extends Error { 2 | constructor(message: string, public code: number = 400) { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/express-endpoints/src/index.ts: -------------------------------------------------------------------------------- 1 | import ExpressRoute from './express-route.js'; 2 | import ExpressRequest from './http/express-request.js'; 3 | import ExpressResponse from './http/express-response.js'; 4 | import ExpressBodyError from './http/http-errors/express-body-error.js'; 5 | import ExpressError from './http/http-errors/express-error.js'; 6 | 7 | export { 8 | ExpressRoute, 9 | ExpressRequest, 10 | ExpressResponse, 11 | ExpressBodyError, 12 | ExpressError 13 | }; 14 | -------------------------------------------------------------------------------- /packages/express-endpoints/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "nodenext", 6 | "noImplicitAny": false, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "sourceMap": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "allowSyntheticDefaultImports": true, 12 | "outDir": "dist", 13 | "rootDir": "src", 14 | "declaration": true, 15 | "skipLibCheck": true, 16 | "stripInternal": true 17 | }, 18 | "include": [ 19 | "src/**/*.ts" 20 | ], 21 | "exclude": [ 22 | "src/**/*.spec.ts", 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/formidable/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tsconfig.json 3 | .gitignore -------------------------------------------------------------------------------- /packages/formidable/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.3](https://github.com/withastro-utils/utils/compare/@astro-utils/formidable@2.0.2...@astro-utils/formidable@2.0.3) (2024-08-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **README:** docs ([e8bdd0e](https://github.com/withastro-utils/utils/commit/e8bdd0e3d89932c555e57768719e43bec06584e6)) 7 | 8 | ## [2.0.2](https://github.com/withastro-utils/utils/compare/@astro-utils/formidable@2.0.1...@astro-utils/formidable@2.0.2) (2024-08-30) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **formidable:** parse files ([6bb8c34](https://github.com/withastro-utils/utils/commit/6bb8c3414c47fbe86d071d18e5663f1e68917806)) 14 | 15 | ## [2.0.1](https://github.com/withastro-utils/utils/compare/@astro-utils/formidable@2.0.0...@astro-utils/formidable@2.0.1) (2023-12-18) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * deprecation warning ([18b825c](https://github.com/withastro-utils/utils/commit/18b825ce1c5786760f60c766bd6b060807f07ea2)) 21 | 22 | # [2.0.0](https://github.com/withastro-utils/utils/compare/@astro-utils/formidable@1.1.4...@astro-utils/formidable@2.0.0) (2023-11-23) 23 | 24 | ## [1.1.4](https://github.com/withastro-utils/utils/compare/@astro-utils/formidable@1.1.3...@astro-utils/formidable@1.1.4) (2023-11-21) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * packages astro keywords ([04faf55](https://github.com/withastro-utils/utils/commit/04faf559ea1326936e137c2783894b2792cfa9af)) 30 | -------------------------------------------------------------------------------- /packages/formidable/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 Ido S. (ido.pluto) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /packages/formidable/README.md: -------------------------------------------------------------------------------- 1 | # Astro Formidable 2 | 3 | Allow you to use formidable for request parse. 4 | 5 | 6 | > If parsing form data does not work with the default `Astro.request.formData()`. 7 | 8 | ## Usage 9 | 10 | `pages/upload.json.ts` 11 | ```ts 12 | import {parseAstroForm, isFormidableFile} from '@astro-utils/formidable'; 13 | import fs from 'fs/promises'; 14 | 15 | export const post: APIRoute = async ({request}) => { 16 | const formData: FormData = await parseAstroForm(Astro.request); 17 | let name = 'Not-File' 18 | 19 | const file = formData.getFile('file'); 20 | if(isFormidableFile(file)){ 21 | const content = await fs.readFile(file.filepath); 22 | name = file.originalFilename + ' - ' + content.length; 23 | } 24 | 25 | return { 26 | body: name 27 | } 28 | } 29 | ``` 30 | 31 | `pages/index.page` 32 | ```astro 33 | --- 34 | import {parseAstroForm, isFormidableFile} from '@astro-utils/formidable'; 35 | 36 | if(Astro.request.method === "POST"){ 37 | const formData: FormData = await parseAstroForm(Astro.request); 38 | 39 | const file = formData.getFile('my-file'); 40 | if(isFormidableFile(file)){ 41 | console.log('The user upload a file'); 42 | } 43 | } 44 | --- 45 | ``` 46 | -------------------------------------------------------------------------------- /packages/formidable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astro-utils/formidable", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "description": "A wrapper for formidable to use with Astro", 6 | "main": "./dist/index.js", 7 | "exports": { 8 | ".": "./dist/index.js" 9 | }, 10 | "files": [ 11 | "dist/*", 12 | "README.md", 13 | "LICENSE" 14 | ], 15 | "scripts": { 16 | "build": "rm -r dist; tsc", 17 | "prepack": "npm run build" 18 | }, 19 | "keywords": [ 20 | "astro", 21 | "forms", 22 | "formidable", 23 | "request", 24 | "withastro", 25 | "withastro-utils" 26 | ], 27 | "homepage": "https://withastro-utils.github.io/docs/", 28 | "author": "Ido S.", 29 | "license": "MIT", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/withastro-utils/utils.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/withastro-utils/utils/issues" 36 | }, 37 | "publishConfig": { 38 | "access": "public" 39 | }, 40 | "dependencies": { 41 | "formidable": "^3.2.5" 42 | }, 43 | "devDependencies": { 44 | "@types/formidable": "^2.0.5", 45 | "@types/node": "^18.11.10", 46 | "typescript": "^4.9.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/formidable/src/ExtendedFormData.ts: -------------------------------------------------------------------------------- 1 | import {VolatileFile, PersistentFile} from 'formidable'; 2 | 3 | export type FormFile = { 4 | filepath: string; 5 | lastModifiedDate: Date; 6 | mimetype: string; 7 | newFilename: string; 8 | originalFilename: string; 9 | size: number; 10 | } 11 | 12 | export type FormDataValue = string | FormFile; 13 | 14 | export default class ExtendedFormData { 15 | #data = new Map(); 16 | 17 | #validateFileType(value: FormDataValue) { 18 | if (typeof value != 'string' && !ExtendedFormData.isFormidableFile(value)) { 19 | return String(value); 20 | } 21 | return value; 22 | } 23 | 24 | append(name: string, value: FormDataValue) { 25 | if (this.#data.has(name)) { 26 | this.#data.get(name).push( 27 | this.#validateFileType(value) 28 | ); 29 | } else { 30 | this.set(name, value); 31 | } 32 | } 33 | 34 | delete(name: string) { 35 | this.#data.delete(name); 36 | } 37 | 38 | get(name: string): FormDataValue | null { 39 | return this.#data.get(name)?.[0]; 40 | } 41 | 42 | getAll(name: string): FormDataValue[] { 43 | return this.#data.get(name) ?? []; 44 | } 45 | 46 | getText(name: string): string | null { 47 | const value = this.get(name); 48 | return typeof value == 'string' ? value : null; 49 | } 50 | 51 | getAllText(name: string): string[] { 52 | return this.getAll(name).filter(value => typeof value == 'string') as string[]; 53 | } 54 | 55 | getFile(name: string): FormFile | null { 56 | const value = this.get(name); 57 | return ExtendedFormData.isFormidableFile(value) ? value as FormFile : null; 58 | } 59 | 60 | getAllFiles(name: string): (FormFile)[] { 61 | return this.getAll(name).filter(value => ExtendedFormData.isFormidableFile(value)) as FormFile[]; 62 | } 63 | 64 | has(name: string): boolean { 65 | return this.get(name) != null; 66 | } 67 | 68 | set(name: string, value: FormDataValue): void { 69 | this.#data.set(name, [this.#validateFileType(value)]); 70 | } 71 | 72 | entries(): IterableIterator<[string, FormDataValue[]]> { 73 | return this.#data.entries(); 74 | } 75 | 76 | keys(): IterableIterator { 77 | return this.#data.keys(); 78 | } 79 | 80 | values(): IterableIterator { 81 | return this.#data.values(); 82 | } 83 | 84 | forEach(callbackfn: (value: FormDataValue[], key: string, parent: ExtendedFormData) => void, thisArg?: any) { 85 | for (const [key, value] of this.entries()) { 86 | callbackfn(value, key, thisArg ?? this); 87 | } 88 | } 89 | 90 | [Symbol.iterator](): IterableIterator<[string, FormDataValue[]]> { 91 | return this.#data[Symbol.iterator](); 92 | } 93 | 94 | static isFormidableFile(object: any) { 95 | return object instanceof VolatileFile || object instanceof PersistentFile; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/formidable/src/index.ts: -------------------------------------------------------------------------------- 1 | import formidable, {VolatileFile, PersistentFile} from 'formidable'; 2 | import {EventEmitter} from 'node:events'; 3 | import ExtendedFormData, {FormDataValue, FormFile} from './ExtendedFormData.js'; 4 | 5 | class FormidableRequest extends EventEmitter { 6 | headers: { [key: string]: string }; 7 | 8 | constructor(request: Request) { 9 | super(); 10 | this.headers = Object.fromEntries(request.headers.entries()); 11 | } 12 | 13 | pause() { 14 | } 15 | 16 | resume() { 17 | } 18 | } 19 | 20 | /** 21 | * Parse form data - urlencoded, multipart and all other plugins of formidable, 22 | * you can specify witch plugins to use in the options 23 | * @param request 24 | * @param options 25 | * @returns 26 | */ 27 | export default async function parseAstroForm(request: Request, options?: formidable.Options) { 28 | const formData = new ExtendedFormData(); 29 | const form = formidable(options); 30 | 31 | const formidableRequest = new FormidableRequest(request); 32 | const dataFinish = new Promise((res: any) => { 33 | form.parse(formidableRequest, (err, fields, files) => { 34 | if (err) return res(); 35 | 36 | for (const [key, values] of Object.entries(fields)) { 37 | for (const value of values) { 38 | formData.append(key, value); 39 | } 40 | } 41 | 42 | for (const [key, values] of Object.entries(files)) { 43 | for (const value of values) { 44 | formData.append(key, value); 45 | } 46 | } 47 | 48 | res(); 49 | }); 50 | }); 51 | 52 | 53 | const bodyData = await request.arrayBuffer(); 54 | const sizeLimit = (options?.maxFieldsSize ?? 0) + (options?.maxTotalFileSize ?? 0); 55 | if (!sizeLimit || sizeLimit >= bodyData.byteLength) { 56 | formidableRequest.emit('data', new Uint8Array(bodyData)); 57 | } 58 | 59 | formidableRequest.emit('end'); 60 | await dataFinish; 61 | 62 | return formData; 63 | } 64 | 65 | const isFormidableFile = ExtendedFormData.isFormidableFile; 66 | 67 | export type { 68 | FormFile, 69 | FormDataValue 70 | } 71 | 72 | export { 73 | parseAstroForm, 74 | VolatileFile, 75 | PersistentFile, 76 | ExtendedFormData, 77 | isFormidableFile, 78 | }; 79 | -------------------------------------------------------------------------------- /packages/formidable/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "noImplicitAny": false, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "sourceMap": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "allowSyntheticDefaultImports": true, 12 | "outDir": "dist", 13 | "rootDir": "src", 14 | "declaration": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": [ 18 | "src/**/*.ts" 19 | ], 20 | "exclude": [ 21 | "src/**/*.spec.ts", 22 | "node_modules" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/forms/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /packages/forms/.idea/forms.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/forms/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/forms/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/forms/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tsconfig.json 3 | .gitignore -------------------------------------------------------------------------------- /packages/forms/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.13.19](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.18...@astro-utils/forms@3.13.19) (2024-11-17) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **cookie:** secure type ([697340a](https://github.com/withastro-utils/utils/commit/697340a9901ad1196f8cee2eae925a8cf4b061b0)) 7 | 8 | ## [3.13.18](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.17...@astro-utils/forms@3.13.18) (2024-09-20) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **uploadFile:** null ref ([e06368a](https://github.com/withastro-utils/utils/commit/e06368af25684b58a567d782a8578d8162e67d27)) 14 | 15 | ## [3.13.17](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.16...@astro-utils/forms@3.13.17) (2024-09-17) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **uploadBigFile:** workaround timeouts when closing big file ([25f28fc](https://github.com/withastro-utils/utils/commit/25f28fc1b8daf85b9d51abf7508c171d94cf12d0)) 21 | 22 | ## [3.13.16](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.15...@astro-utils/forms@3.13.16) (2024-09-12) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **bigFileUpload:** process ([1278672](https://github.com/withastro-utils/utils/commit/127867252abd6f63d2ac3dc1acf04f327fe87e89)) 28 | 29 | ## [3.13.15](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.14...@astro-utils/forms@3.13.15) (2024-09-12) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **bigFileUpload:** export processBigFileUpload ([d5b87d3](https://github.com/withastro-utils/utils/commit/d5b87d3fdb995df279ad4760910c3f426731e3ae)) 35 | 36 | ## [3.13.14](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.13...@astro-utils/forms@3.13.14) (2024-08-30) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * **cookie:** default settings ([9c2a83b](https://github.com/withastro-utils/utils/commit/9c2a83ba76f9cf7e12b39e864b0a86ecabfe8540)) 42 | 43 | ## [3.13.13](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.12...@astro-utils/forms@3.13.13) (2024-08-28) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **state:** save state ([945b02e](https://github.com/withastro-utils/utils/commit/945b02e00ab7171a45f35f3648de92104da005b6)) 49 | 50 | ## [3.13.12](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.11...@astro-utils/forms@3.13.12) (2024-08-16) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * **files:** parse multi file upload ([0a5c0db](https://github.com/withastro-utils/utils/commit/0a5c0dbf49fef4b75a8ea3c4a2a25241ffa726b0)) 56 | 57 | ## [3.13.11](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.10...@astro-utils/forms@3.13.11) (2024-08-16) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * **file:** parse empty files ([a88f4a6](https://github.com/withastro-utils/utils/commit/a88f4a6b07771d2f76ef81f6ff3058c42a16a30c)) 63 | 64 | ## [3.13.10](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.9...@astro-utils/forms@3.13.10) (2024-08-13) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * **BinForm:** map, unique 'key' ([ab49a83](https://github.com/withastro-utils/utils/commit/ab49a8311f86df1d0b6d8ba63900690e4968336f)) 70 | 71 | ## [3.13.9](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.8...@astro-utils/forms@3.13.9) (2024-08-07) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * **state:** disable state ([c1c497d](https://github.com/withastro-utils/utils/commit/c1c497da578dd7168dd15ad1cf22f33af2003aad)) 77 | 78 | ## [3.13.8](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.7...@astro-utils/forms@3.13.8) (2024-07-28) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * **bigFile:** max upload size ([0abe5da](https://github.com/withastro-utils/utils/commit/0abe5da629fa35ddb52fb8a71a2785434a12fc3e)) 84 | 85 | ## [3.13.7](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.6...@astro-utils/forms@3.13.7) (2024-07-28) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * **bigFile:** race condition ([10a7121](https://github.com/withastro-utils/utils/commit/10a7121a756dee79ff3d57d8b95fe981f37e7a46)) 91 | 92 | ## [3.13.6](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.5...@astro-utils/forms@3.13.6) (2024-07-26) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * **bigFiles:** missing chunks ([fb9e763](https://github.com/withastro-utils/utils/commit/fb9e763a4afa263232fc8bfb99b07afa981fc86c)) 98 | 99 | ## [3.13.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.4...@astro-utils/forms@3.13.5) (2024-07-26) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * **bigFile:** auto fix missing chunks ([6fb76d5](https://github.com/withastro-utils/utils/commit/6fb76d5442cc7f51245b04f9f1e1da8547661c30)) 105 | 106 | ## [3.13.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.3...@astro-utils/forms@3.13.4) (2024-07-26) 107 | 108 | 109 | ### Bug Fixes 110 | 111 | * **bigFile:** calc directory size ([4ab5311](https://github.com/withastro-utils/utils/commit/4ab5311aacf7268dce5866580ad6d44a5c680e04)) 112 | 113 | ## [3.13.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.2...@astro-utils/forms@3.13.3) (2024-07-26) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * **bigFile:** write error ([a04bef8](https://github.com/withastro-utils/utils/commit/a04bef80f680fdba46aaca040e747742b8adbf55)) 119 | 120 | ## [3.13.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.1...@astro-utils/forms@3.13.2) (2024-07-26) 121 | 122 | 123 | ### Bug Fixes 124 | 125 | * **bigFile:** connecting chunks ([6caa768](https://github.com/withastro-utils/utils/commit/6caa768fc01e15e1af23b136aaf4054d818a84a9)) 126 | 127 | ## [3.13.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.13.0...@astro-utils/forms@3.13.1) (2024-07-26) 128 | 129 | 130 | ### Bug Fixes 131 | 132 | * **bigFile:** export type ([27d3438](https://github.com/withastro-utils/utils/commit/27d3438fd265524b0384556294c0938bc5ac48c5)) 133 | 134 | # [3.13.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.12.1...@astro-utils/forms@3.13.0) (2024-07-26) 135 | 136 | 137 | ### Features 138 | 139 | * **bigFile:** built in component for big files uploads ([ceb9b4d](https://github.com/withastro-utils/utils/commit/ceb9b4d23641bf0229a4be98ef51710254298934)) 140 | 141 | ## [3.12.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.12.0...@astro-utils/forms@3.12.1) (2024-07-07) 142 | 143 | 144 | ### Bug Fixes 145 | 146 | * **render:** race conditioning when rendering ([f442303](https://github.com/withastro-utils/utils/commit/f44230330935a1cf4bab315cc808f3803c97f329)) 147 | 148 | # [3.12.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.11.5...@astro-utils/forms@3.12.0) (2024-07-06) 149 | 150 | 151 | ### Features 152 | 153 | * **button:** disable button while submitting ([53e0fe5](https://github.com/withastro-utils/utils/commit/53e0fe517e746bc1ffa1f95019fee4f15f5a00cf)) 154 | 155 | ## [3.11.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.11.4...@astro-utils/forms@3.11.5) (2024-07-01) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * update context ([b06538d](https://github.com/withastro-utils/utils/commit/b06538dedea3f93d869dd3acb8e32a163b0dc2a6)) 161 | 162 | ## [3.11.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.11.3...@astro-utils/forms@3.11.4) (2024-07-01) 163 | 164 | 165 | ### Bug Fixes 166 | 167 | * **deps:** context ([7938432](https://github.com/withastro-utils/utils/commit/7938432f65313bddeac975c487d6747078a83971)) 168 | 169 | ## [3.11.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.11.2...@astro-utils/forms@3.11.3) (2024-06-30) 170 | 171 | 172 | ### Bug Fixes 173 | 174 | * **json:** cast value ([cb853b9](https://github.com/withastro-utils/utils/commit/cb853b978a8ed159d752a7cc63e13b8a1b8a5e7d)) 175 | 176 | ## [3.11.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.11.1...@astro-utils/forms@3.11.2) (2024-06-30) 177 | 178 | 179 | ### Bug Fixes 180 | 181 | * **deps:** context ([b603d0c](https://github.com/withastro-utils/utils/commit/b603d0c2ceb7eed6818956fa5f605bc966abd539)) 182 | 183 | ## [3.11.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.11.0...@astro-utils/forms@3.11.1) (2024-06-30) 184 | 185 | 186 | ### Bug Fixes 187 | 188 | * **time:** parse ([f51281d](https://github.com/withastro-utils/utils/commit/f51281d30a5bf7cb2f4cd41aa6ceaea8e9c23978)) 189 | * **json:** stringify ([c3b1cb4](https://github.com/withastro-utils/utils/commit/c3b1cb4e7af2736216a211ed1394cb3cab344eb1)) 190 | 191 | # [3.11.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.6...@astro-utils/forms@3.11.0) (2024-06-29) 192 | 193 | 194 | ### Features 195 | 196 | * **BInput:** support json value ([16701d6](https://github.com/withastro-utils/utils/commit/16701d6115fcb661deceaae229ad430422a8a95d)) 197 | 198 | ## [3.10.6](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.5...@astro-utils/forms@3.10.6) (2024-06-20) 199 | 200 | 201 | ### Bug Fixes 202 | 203 | * **dates:** stringify ([a4aa9f3](https://github.com/withastro-utils/utils/commit/a4aa9f3c709e803b8cc1d9b107df7a1d70c0129b)) 204 | 205 | ## [3.10.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.4...@astro-utils/forms@3.10.5) (2024-05-28) 206 | 207 | 208 | ### Bug Fixes 209 | 210 | * **secret:** custom ([955f12f](https://github.com/withastro-utils/utils/commit/955f12f3fdfa3e52573e1378d61ca7e4f8b7b631)) 211 | 212 | ## [3.10.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.3...@astro-utils/forms@3.10.4) (2024-05-28) 213 | 214 | 215 | ### Bug Fixes 216 | 217 | * **settings:** default ([04613aa](https://github.com/withastro-utils/utils/commit/04613aad93ab95f4d2636c61a354b4bff9a4f25c)) 218 | 219 | ## [3.10.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.2...@astro-utils/forms@3.10.3) (2024-05-20) 220 | 221 | 222 | ### Bug Fixes 223 | 224 | * **submit:** textarea shortcut ([06b262d](https://github.com/withastro-utils/utils/commit/06b262d1e06545470d32353cac722168e3672a99)) 225 | 226 | ## [3.10.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.1...@astro-utils/forms@3.10.2) (2024-05-20) 227 | 228 | 229 | ### Bug Fixes 230 | 231 | * **input:** readonly state ([cd1ec68](https://github.com/withastro-utils/utils/commit/cd1ec68d1dca6587b06c796963b084bd0594b289)) 232 | 233 | ## [3.10.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.10.0...@astro-utils/forms@3.10.1) (2024-05-20) 234 | 235 | 236 | ### Bug Fixes 237 | 238 | * **state:** not save on first get ([e83b889](https://github.com/withastro-utils/utils/commit/e83b889cff9182289c056ec6cbe626664cc56ed9)) 239 | 240 | # [3.10.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.9...@astro-utils/forms@3.10.0) (2024-05-14) 241 | 242 | 243 | ### Features 244 | 245 | * **errors:** strict errors ([4687773](https://github.com/withastro-utils/utils/commit/4687773c12a63600109732123aa74cad8913c4a8)) 246 | 247 | ## [3.9.9](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.8...@astro-utils/forms@3.9.9) (2024-05-12) 248 | 249 | 250 | ### Bug Fixes 251 | 252 | * **state:** build state ([902540d](https://github.com/withastro-utils/utils/commit/902540df9e58c5aca8a1ac64cdfe97936d5b94c0)) 253 | 254 | ## [3.9.8](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.7...@astro-utils/forms@3.9.8) (2024-05-12) 255 | 256 | 257 | ### Bug Fixes 258 | 259 | * **state:** identity, know when this is new state ([a2b3900](https://github.com/withastro-utils/utils/commit/a2b390061275fab1e427ec27ad4e32bfed8c68dc)) 260 | 261 | ## [3.9.7](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.6...@astro-utils/forms@3.9.7) (2024-05-11) 262 | 263 | 264 | ### Bug Fixes 265 | 266 | * **props:** clone ([ad276b0](https://github.com/withastro-utils/utils/commit/ad276b027e563907201a931b0595fc7d1cb7e644)) 267 | 268 | ## [3.9.6](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.5...@astro-utils/forms@3.9.6) (2024-05-10) 269 | 270 | 271 | ### Bug Fixes 272 | 273 | * **astro:** first render bug ([8741184](https://github.com/withastro-utils/utils/commit/8741184e7a67e04c4243d8284598b8069ecfaf3d)) 274 | 275 | ## [3.9.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.4...@astro-utils/forms@3.9.5) (2024-05-10) 276 | 277 | 278 | ### Bug Fixes 279 | 280 | * **nest-formBind:** rerender only once ([520adc1](https://github.com/withastro-utils/utils/commit/520adc10280ad06a235532a72c3e65f8d6320e15)) 281 | 282 | ## [3.9.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.3...@astro-utils/forms@3.9.4) (2024-05-08) 283 | 284 | 285 | ### Bug Fixes 286 | 287 | * **nested-forms:** stack loading nested forms ([b2caa6e](https://github.com/withastro-utils/utils/commit/b2caa6e06a2b3e17207572e4e1829d3757883a95)) 288 | 289 | ## [3.9.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.2...@astro-utils/forms@3.9.3) (2024-05-07) 290 | 291 | 292 | ### Bug Fixes 293 | 294 | * **textarea:** name ([0991b16](https://github.com/withastro-utils/utils/commit/0991b16c60cbbee20c424eca1b3a7554152a89a2)) 295 | 296 | ## [3.9.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.1...@astro-utils/forms@3.9.2) (2024-05-07) 297 | 298 | 299 | ### Bug Fixes 300 | 301 | * **name:** the same name in different forms override one another ([1f2ebf7](https://github.com/withastro-utils/utils/commit/1f2ebf7b165ec04227af41dbf86db6f56deedede)) 302 | 303 | ## [3.9.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.9.0...@astro-utils/forms@3.9.1) (2024-04-29) 304 | 305 | 306 | ### Bug Fixes 307 | 308 | * **form:** default action ([697f0b5](https://github.com/withastro-utils/utils/commit/697f0b56342f4bd186c29a22d69106047c358e2b)) 309 | 310 | # [3.9.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.8.3...@astro-utils/forms@3.9.0) (2024-04-29) 311 | 312 | 313 | ### Features 314 | 315 | * **forms:** easy form submit ([4caf5de](https://github.com/withastro-utils/utils/commit/4caf5de2844eae5aeb19cc4664be87c94d3b27ec)) 316 | 317 | ## [3.8.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.8.2...@astro-utils/forms@3.8.3) (2024-04-28) 318 | 319 | 320 | ### Bug Fixes 321 | 322 | * **select:** empty value ([5c8f6f1](https://github.com/withastro-utils/utils/commit/5c8f6f13c9b95874bf1d7f72cc01853baad50852)) 323 | 324 | ## [3.8.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.8.1...@astro-utils/forms@3.8.2) (2024-04-28) 325 | 326 | 327 | ### Bug Fixes 328 | 329 | * **set:** empty input update ([b67e2fc](https://github.com/withastro-utils/utils/commit/b67e2fc1d94c15b9516805f68f232740cd22bcf2)) 330 | 331 | ## [3.8.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.8.0...@astro-utils/forms@3.8.1) (2024-04-28) 332 | 333 | 334 | ### Bug Fixes 335 | 336 | * **submit:** default submit button ([d223c28](https://github.com/withastro-utils/utils/commit/d223c28fd29a6a3caac3ba065073d515d1f08b32)) 337 | 338 | # [3.8.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.7.0...@astro-utils/forms@3.8.0) (2024-04-27) 339 | 340 | 341 | ### Features 342 | 343 | * **input:** redirect onEnter to a button ([5a05302](https://github.com/withastro-utils/utils/commit/5a053025c31c429bbc604fd6a54eb4c40a530019)) 344 | 345 | # [3.7.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.6.2...@astro-utils/forms@3.7.0) (2024-04-27) 346 | 347 | 348 | ### Features 349 | 350 | * **form:** omit from state ([663274e](https://github.com/withastro-utils/utils/commit/663274e41927cc860332264e909d7d5782899de8)) 351 | 352 | ## [3.6.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.6.1...@astro-utils/forms@3.6.2) (2024-04-20) 353 | 354 | 355 | ### Bug Fixes 356 | 357 | * **BSelect:** parse date & numbers ([9b70002](https://github.com/withastro-utils/utils/commit/9b700023a0f607d6128edb1278e706b06d3483ec)) 358 | 359 | ## [3.6.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.6.0...@astro-utils/forms@3.6.1) (2024-04-13) 360 | 361 | 362 | ### Bug Fixes 363 | 364 | * **defaults:** load ([b8d8c31](https://github.com/withastro-utils/utils/commit/b8d8c31da968dbcc432c691adb1121636cb48cf7)) 365 | 366 | # [3.6.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.5.2...@astro-utils/forms@3.6.0) (2024-04-13) 367 | 368 | 369 | ### Features 370 | 371 | * **state:** lifecycle events ([03ebfb4](https://github.com/withastro-utils/utils/commit/03ebfb437f5ee7414ce3bc6b138dfa3aed795cf8)) 372 | 373 | ## [3.5.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.5.1...@astro-utils/forms@3.5.2) (2024-04-09) 374 | 375 | 376 | ### Bug Fixes 377 | 378 | * **bind:** astro render before bind state has loaded ([cab5cfd](https://github.com/withastro-utils/utils/commit/cab5cfdc3c5871e1b832784fba72df30c56c3cbf)) 379 | 380 | ## [3.5.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.5.0...@astro-utils/forms@3.5.1) (2024-04-09) 381 | 382 | 383 | ### Bug Fixes 384 | 385 | * **input-parse:** date ([5d70012](https://github.com/withastro-utils/utils/commit/5d700125c6e67492d6ae9d062e50867843ff892c)) 386 | 387 | # [3.5.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.7...@astro-utils/forms@3.5.0) (2024-04-09) 388 | 389 | 390 | ### Features 391 | 392 | * **forms:** nested value setters ([6debdcd](https://github.com/withastro-utils/utils/commit/6debdcda718a2a94365dbec12947f35b4b3a9bb3)) 393 | 394 | ## [3.4.7](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.6...@astro-utils/forms@3.4.7) (2024-04-05) 395 | 396 | 397 | ### Bug Fixes 398 | 399 | * **input:** stringify min max ([33cba2d](https://github.com/withastro-utils/utils/commit/33cba2d681ebfb98207c4a0713415fd3b4cf854e)) 400 | 401 | ## [3.4.6](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.5...@astro-utils/forms@3.4.6) (2024-04-04) 402 | 403 | 404 | ### Bug Fixes 405 | 406 | * min & max date ([e8a4b9b](https://github.com/withastro-utils/utils/commit/e8a4b9ba570cac5924fa3e8ebd5d0e27002f558b)) 407 | 408 | ## [3.4.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.4...@astro-utils/forms@3.4.5) (2024-04-02) 409 | 410 | 411 | ### Bug Fixes 412 | 413 | * **date:** parse & stringify forms dates input ([92997b2](https://github.com/withastro-utils/utils/commit/92997b2d4d06a05d8f0cdec9c6ab638f8108e3eb)) 414 | * **select:** read options ([241e350](https://github.com/withastro-utils/utils/commit/241e35091183acaabec49b992b8119b4838c8eaa)) 415 | 416 | ## [3.4.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.3...@astro-utils/forms@3.4.4) (2024-04-02) 417 | 418 | 419 | ### Bug Fixes 420 | 421 | * **context:** lock ([1dfdb4d](https://github.com/withastro-utils/utils/commit/1dfdb4d003af3582de117c9acad32d35d7cac831)) 422 | 423 | ## [3.4.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.2...@astro-utils/forms@3.4.3) (2024-04-01) 424 | 425 | 426 | ### Bug Fixes 427 | 428 | * **select:** lock context ([7c8e37f](https://github.com/withastro-utils/utils/commit/7c8e37f377840e9324585f21a8db9760fb9b6015)) 429 | 430 | ## [3.4.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.1...@astro-utils/forms@3.4.2) (2024-04-01) 431 | 432 | 433 | ### Bug Fixes 434 | 435 | * **slot:** wait for render to finish ([5deebe0](https://github.com/withastro-utils/utils/commit/5deebe07daf04c08acdb34d0ebd487e9bbb2e623)) 436 | 437 | ## [3.4.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.4.0...@astro-utils/forms@3.4.1) (2024-03-31) 438 | 439 | 440 | ### Bug Fixes 441 | 442 | * **session:** default cookie path ([b0372e0](https://github.com/withastro-utils/utils/commit/b0372e0b7e529cd695a4fc3d71e91fa5a3b148d3)) 443 | 444 | # [3.4.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.3.0...@astro-utils/forms@3.4.0) (2024-03-30) 445 | 446 | 447 | ### Features 448 | 449 | * throw override response ([80862ce](https://github.com/withastro-utils/utils/commit/80862ce9c5e394947f33bf7b02eea632bd4eeec1)) 450 | 451 | # [3.3.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.7...@astro-utils/forms@3.3.0) (2023-12-30) 452 | 453 | 454 | ### Features 455 | 456 | * override response ([767d31a](https://github.com/withastro-utils/utils/commit/767d31ae42836dd96444777acad488a8d24493c1)) 457 | 458 | ## [3.2.7](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.6...@astro-utils/forms@3.2.7) (2023-12-30) 459 | 460 | 461 | ### Bug Fixes 462 | 463 | * typo ([d535ae9](https://github.com/withastro-utils/utils/commit/d535ae975ecabb884aedd8aa0dce1d377b5e391d)) 464 | 465 | ## [3.2.6](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.5...@astro-utils/forms@3.2.6) (2023-12-26) 466 | 467 | 468 | ### Bug Fixes 469 | 470 | * default bind ([e2c9184](https://github.com/withastro-utils/utils/commit/e2c91849fcd70d3bc14d006806f78a179f8573f2)) 471 | 472 | ## [3.2.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.4...@astro-utils/forms@3.2.5) (2023-12-26) 473 | 474 | 475 | ### Bug Fixes 476 | 477 | * crypto algorithm to aes-256-ctr ([57060eb](https://github.com/withastro-utils/utils/commit/57060eb1387d393b5306104b1c1029be7d4ebaa0)) 478 | 479 | ## [3.2.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.3...@astro-utils/forms@3.2.4) (2023-12-26) 480 | 481 | 482 | ### Bug Fixes 483 | 484 | * **view-state:** crypto ([811da77](https://github.com/withastro-utils/utils/commit/811da7797108facbc0cb3a09044a3d672902c4db)) 485 | 486 | ## [3.2.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.2...@astro-utils/forms@3.2.3) (2023-12-26) 487 | 488 | 489 | ### Bug Fixes 490 | 491 | * no change in session ([6581a18](https://github.com/withastro-utils/utils/commit/6581a183c66f47bf8da2c4c2d05b25fc3c695b85)) 492 | * saving session ([4535f21](https://github.com/withastro-utils/utils/commit/4535f21e4703dff5b1bd3c7a85074c85b07aa28f)) 493 | 494 | ## [3.2.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.1...@astro-utils/forms@3.2.2) (2023-12-25) 495 | 496 | 497 | ### Bug Fixes 498 | 499 | * wait only for HTML content ([331937b](https://github.com/withastro-utils/utils/commit/331937b75a2d40639a11dc6e57e9f8e10d47c6db)) 500 | 501 | ## [3.2.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.2.0...@astro-utils/forms@3.2.1) (2023-12-24) 502 | 503 | 504 | ### Bug Fixes 505 | 506 | * parse first state ([e6dd31f](https://github.com/withastro-utils/utils/commit/e6dd31f9ad95ee69485c32ae2645b1ff02fa0e9f)) 507 | 508 | # [3.2.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.5...@astro-utils/forms@3.2.0) (2023-12-24) 509 | 510 | 511 | ### Features 512 | 513 | * form state (ASPX) ([#3](https://github.com/withastro-utils/utils/issues/3)) ([1f71d80](https://github.com/withastro-utils/utils/commit/1f71d8035b4251f133333cfa35660070a5423492)) 514 | 515 | ## [3.1.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.4...@astro-utils/forms@3.1.5) (2023-12-19) 516 | 517 | 518 | ### Bug Fixes 519 | 520 | * astro local types ([c72171f](https://github.com/withastro-utils/utils/commit/c72171f6ac0b147be7c13f68381b038203f6ca11)) 521 | * astro locals - any ([6eb5ab1](https://github.com/withastro-utils/utils/commit/6eb5ab17c19b46ddc6d39a0f281a29bbd576b628)) 522 | 523 | ## [3.1.5](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.4...@astro-utils/forms@3.1.5) (2023-12-19) 524 | 525 | 526 | ### Bug Fixes 527 | 528 | * astro local types ([c72171f](https://github.com/withastro-utils/utils/commit/c72171f6ac0b147be7c13f68381b038203f6ca11)) 529 | 530 | ## [3.1.4](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.3...@astro-utils/forms@3.1.4) (2023-12-18) 531 | 532 | 533 | ### Reverts 534 | 535 | * Revert "style: better types" ([fc17859](https://github.com/withastro-utils/utils/commit/fc178598c28aa32d1f99b1a41490f5def509a3b2)) 536 | 537 | ## [3.1.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.2...@astro-utils/forms@3.1.3) (2023-12-18) 538 | 539 | 540 | ### Bug Fixes 541 | 542 | * deps ([2beb19c](https://github.com/withastro-utils/utils/commit/2beb19ca3b6be5af9fff46617d8ff48511167ca5)) 543 | 544 | ## [3.1.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.1...@astro-utils/forms@3.1.2) (2023-12-18) 545 | 546 | ## [3.1.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.1.0...@astro-utils/forms@3.1.1) (2023-12-18) 547 | 548 | 549 | ### Bug Fixes 550 | 551 | * remove @astro-utils/formidable deps ([16e95a2](https://github.com/withastro-utils/utils/commit/16e95a2bcea38e0de07c285d73ef6a6a07f4b468)) 552 | 553 | # [3.1.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.0.3...@astro-utils/forms@3.1.0) (2023-12-18) 554 | 555 | 556 | ### Features 557 | 558 | * native form parser ([dbef97d](https://github.com/withastro-utils/utils/commit/dbef97db6d71f138edb8829d79b532b468d46e33)) 559 | 560 | ## [3.0.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.0.2...@astro-utils/forms@3.0.3) (2023-12-04) 561 | 562 | 563 | ### Bug Fixes 564 | 565 | * better errors ([b415f40](https://github.com/withastro-utils/utils/commit/b415f40f17653e452ea81620d77fb507db598541)) 566 | 567 | ## [3.0.2](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.0.1...@astro-utils/forms@3.0.2) (2023-12-04) 568 | 569 | 570 | ### Bug Fixes 571 | 572 | * forms close ([5dd2955](https://github.com/withastro-utils/utils/commit/5dd2955cb62a20d2e1587c83c5ce5e262207d2a4)) 573 | 574 | ## [3.0.1](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@3.0.0...@astro-utils/forms@3.0.1) (2023-12-04) 575 | 576 | 577 | ### Bug Fixes 578 | 579 | * jwt save session ([ecc6c76](https://github.com/withastro-utils/utils/commit/ecc6c767d706b649f90997eaf32cd1e70fb316c4)) 580 | 581 | # [3.0.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@2.0.0...@astro-utils/forms@3.0.0) (2023-11-23) 582 | 583 | 584 | ### Bug Fixes 585 | 586 | * package description ([33dc830](https://github.com/withastro-utils/utils/commit/33dc830495710c2f8cd8e32ee1d0b3457d9efdd2)) 587 | 588 | 589 | ### BREAKING CHANGES 590 | 591 | * components renamed 592 | 593 | # [2.0.0](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@1.1.3...@astro-utils/forms@2.0.0) (2023-11-23) 594 | 595 | ## [1.1.3](https://github.com/withastro-utils/utils/compare/@astro-utils/forms@1.1.2...@astro-utils/forms@1.1.3) (2023-11-21) 596 | 597 | 598 | ### Bug Fixes 599 | 600 | * packages astro keywords ([04faf55](https://github.com/withastro-utils/utils/commit/04faf559ea1326936e137c2783894b2792cfa9af)) 601 | -------------------------------------------------------------------------------- /packages/forms/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 Ido S. (ido.pluto) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /packages/forms/README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Astro Forms Utils 4 | 5 | Astro Utils 6 | 7 | 8 | [![Build](https://github.com/withastro-utils/utils/actions/workflows/release.yml/badge.svg)](https://github.com/withastro-utils/utils/actions/workflows/build.yml) 9 | [![License](https://badgen.net/badge/color/MIT/green?label=license)](https://www.npmjs.com/package/@astro-utils/forms) 10 | [![License](https://badgen.net/badge/color/TypeScript/blue?label=types)](https://www.npmjs.com/package/@astro-utils/forms) 11 | [![Version](https://badgen.net/npm/v/@astro-utils/forms)](https://www.npmjs.com/package/@astro-utils/forms) 12 |
13 | 14 | > Server component for Astro (validation and state management) 15 | 16 | 17 | # Full feature server components for Astro.js 18 | 19 | This package is a framework for Astro.js that allows you to create forms and manage their state without any JavaScript. 20 | 21 | It also allows you to validate the form on the client side and server side, and protect against CSRF attacks. 22 | 23 | ### More features 24 | - JWT session management 25 | - Override response at runtime (useful for error handling) 26 | - Custom server validation with `zod` 27 | - Multiples app states at the same time 28 | 29 | # Show me the code 30 | ```astro 31 | --- 32 | import { Bind, BindForm, BButton, BInput } from "@astro-utils/forms/forms.js"; 33 | import Layout from "../layouts/Layout.astro"; 34 | 35 | const form = Bind(); 36 | let showSubmitText: string; 37 | 38 | function formSubmit(){ 39 | showSubmitText = `You name is ${form.name}, you are ${form.age} years old. `; 40 | } 41 | --- 42 | 43 | 44 | {showSubmitText} 45 | 46 |

What you name*

47 | 48 | 49 |

Enter age*

50 | 51 | 52 | Submit 53 |
54 |
55 | ``` 56 | 57 | ## Usage 58 | 59 | ### Add the middleware to your server 60 | 61 | ``` 62 | npm install @astro-utils/forms 63 | ``` 64 | 65 | Add the middleware to your server 66 | 67 | 68 | `src/middleware.ts` 69 | ```ts 70 | import astroForms from "@astro-utils/forms"; 71 | import {sequence} from "astro/middleware"; 72 | 73 | export const onRequest = sequence(astroForms()); 74 | ``` 75 | 76 | ### Add to Layout 77 | Add the `WebForms` component in the layout 78 | 79 | `layouts/Layout.astro` 80 | ```astro 81 | --- 82 | import {WebForms} from '@astro-utils/forms/forms.js'; 83 | --- 84 | 85 | 86 | 87 | ``` 88 | 89 | ### Code Integration 90 | This changes astro behavior to allow the form to work, it ensure the components render by the order they are in the file. 91 | 92 | `astro.config.mjs` 93 | ```js 94 | import { defineConfig } from 'astro/config'; 95 | import astroForms from "@astro-utils/forms/dist/integration.js"; 96 | 97 | export default defineConfig({ 98 | output: 'server', 99 | integrations: [astroForms] 100 | }); 101 | ``` 102 | 103 | ### Complex Form Validation 104 | 105 | `pages/index.astro` 106 | ```astro 107 | --- 108 | import { Bind, BindForm, FormErrors, BButton, BInput, BOption, BSelect, BTextarea } from "@astro-utils/forms/forms.js"; 109 | import Layout from "../layouts/Layout.astro"; 110 | 111 | type formType = { 112 | name: string, 113 | age: number, 114 | about?: string 115 | favoriteFood?: 'Pizaa' | 'Salad' | 'Lasagna' 116 | } 117 | 118 | const form = Bind(); 119 | let showSubmitText: string; 120 | 121 | function formSubmit(){ 122 | showSubmitText = `You name is ${form.name}, you are ${form.age} years old. `; 123 | 124 | if(form.about){ 125 | showSubmitText += `\n\n${form.about}\n\n`; 126 | } 127 | 128 | if(form.favoriteFood){ 129 | showSubmitText += `Your favorite food is ${form.favoriteFood}`; 130 | } 131 | } 132 | --- 133 | 134 | 135 | 136 | 137 |

What you name*

138 | 139 | 140 |

Enter age*

141 | 142 | 143 |

Tell about yourself

144 | 145 | 146 |

What you favorite food?

147 | 148 | Idk 149 | Pizaa 150 | Salad 151 | Lasagna 152 | 153 | 154 | Submit 155 | 156 | {showSubmitText && <> 157 |

You submitted the form:

158 |
{showSubmitText}
159 | } 160 |
161 |
162 | ``` 163 | 164 | ### Button Hook 165 | 166 | You can also use this as a simple on click hook 167 | 168 | ```astro 169 | --- 170 | import { BButton } from "@astro-utils/forms/forms.js"; 171 | import { Button } from 'reactstrap'; 172 | 173 | const { session } = Astro.locals; 174 | 175 | function increaseCounter() { 176 | session.counter ??= 0 177 | session.counter++ 178 | } 179 | --- 180 | 181 | ++ 182 | {session.counter} 183 | 184 | ``` 185 | 186 | The `session.counter` will show the **last value** and not the **update value**. 187 | 188 | This is because the output is **not reactive**. You can use it inside `BindForm` to make it **reactive**. -------------------------------------------------------------------------------- /packages/forms/forms.ts: -------------------------------------------------------------------------------- 1 | import BindForm from './dist/components/form/BindForm.astro'; 2 | import BButton from './dist/components/form/BButton.astro'; 3 | import FormErrors from './dist/components/form/FormErrors.astro'; 4 | import BInput from './dist/components/form/BInput.astro'; 5 | import BTextarea from './dist/components/form/BTextarea.astro'; 6 | import BOption from './dist/components/form/BOption.astro'; 7 | import BSelect from './dist/components/form/BSelect.astro'; 8 | import WebForms from './dist/components/WebForms.astro'; 9 | import Bind, {type BindTypes} from './dist/components-control/form-utils/bind-form.js'; 10 | import ThrowOverrideResponse from "./dist/throw-action/throwOverrideResponse.js"; 11 | import UploadBigFile from './dist/components/form/UploadBigFile/UploadBigFile.astro'; 12 | import UploadBigFileProgress from './dist/components/form/UploadBigFile/UploadBigFileProgress.astro'; 13 | import {BigFile} from './dist/components/form/UploadBigFile/BigFile.js'; 14 | import { processBigFileUpload } from './dist/components/form/UploadBigFile/uploadBigFileServer.js'; 15 | 16 | export { 17 | Bind, 18 | BindForm, 19 | BButton, 20 | FormErrors, 21 | BInput, 22 | BTextarea, 23 | BOption, 24 | BSelect, 25 | WebForms, 26 | BigFile, 27 | UploadBigFile, 28 | UploadBigFileProgress, 29 | ThrowOverrideResponse, 30 | processBigFileUpload 31 | } 32 | 33 | export type { 34 | BindTypes 35 | } -------------------------------------------------------------------------------- /packages/forms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astro-utils/forms", 3 | "version": "0.0.1", 4 | "description": "Server component for Astro (call server functions from client side with validation and state management)", 5 | "type": "module", 6 | "scripts": { 7 | "watch": "onchange 'src/**/*' -- npm run build", 8 | "build": "rm -r dist/*; tsc; mkdir dist/components; cp -r src/components/* dist/components/; find dist/components/ -name '*.ts' -delete ", 9 | "prepack": "npm run build" 10 | }, 11 | "keywords": [ 12 | "ASPX", 13 | "astro", 14 | "astro-component", 15 | "forms", 16 | "react", 17 | "hooks", 18 | "validation", 19 | "astro-utils", 20 | "on-click", 21 | "on-submit", 22 | "server-components", 23 | "next.js", 24 | "zod" 25 | ], 26 | "funding": "https://github.com/sponsors/ido-pluto", 27 | "homepage": "https://withastro-utils.github.io/docs/", 28 | "author": "Ido S.", 29 | "license": "MIT", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/withastro-utils/utils.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/withastro-utils/utils/issues" 36 | }, 37 | "publishConfig": { 38 | "access": "public" 39 | }, 40 | "main": "./dist/index.js", 41 | "exports": { 42 | ".": "./dist/index.js", 43 | "./forms.js": "./forms.js", 44 | "./dist/settings.js": "./dist/settings.js", 45 | "./dist/integration.js": "./dist/integration.js" 46 | }, 47 | "files": [ 48 | "dist/*", 49 | "components/*", 50 | "README.md", 51 | "forms.ts", 52 | "LICENSE" 53 | ], 54 | "devDependencies": { 55 | "@types/cookie": "^0.5.1", 56 | "@types/formidable": "^2.0.5", 57 | "@types/fs-extra": "^11.0.4", 58 | "@types/jsonwebtoken": "^9.0.1", 59 | "@types/node": "^18.11.10", 60 | "@types/object-assign-deep": "^0.4.3", 61 | "@types/promise-timeout": "^1.3.3", 62 | "@types/react": "^18.2.45", 63 | "@types/uuid": "^9.0.1", 64 | "onchange": "^7.1.0", 65 | "semantic-release-commit-filter": "^1.0.2", 66 | "typescript": "^5.2.2", 67 | "vite": "^4.1.2" 68 | }, 69 | "dependencies": { 70 | "@astro-utils/context": "0.0.1", 71 | "await-lock": "^2.2.2", 72 | "cookie": "^0.5.0", 73 | "csrf": "^3.1.0", 74 | "dot-prop": "^8.0.2", 75 | "fs-extra": "^11.2.0", 76 | "jsonwebtoken": "^9.0.0", 77 | "object-assign-deep": "^0.4.0", 78 | "snappy": "^7.2.2", 79 | "superjson": "^2.2.1", 80 | "uuid": "^9.0.0", 81 | "zod": "^3.19.1" 82 | }, 83 | "peerDependencies": { 84 | "astro": "^4.0.6" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/about-form-name.ts: -------------------------------------------------------------------------------- 1 | import {ZodError, type ZodFirstPartySchemaTypes} from 'zod'; 2 | import {BindForm} from './bind-form.js'; 3 | import {setProperty} from 'dot-prop'; 4 | 5 | export default class AboutFormName { 6 | hadError = false; 7 | 8 | constructor(public form: BindForm, public originalName: string, public formValue?: any, public errorMessage?: string) { 9 | 10 | } 11 | 12 | pushError(zodError: ZodError, overrideMessage?: string) { 13 | this.hadError = true; 14 | const topMessage = overrideMessage ?? zodError.issues.at(0).message; 15 | 16 | this.form.errors.push({ 17 | name: this.originalName, 18 | value: this.formValue, 19 | message: this.errorMessage ?? topMessage, 20 | issues: zodError.issues.map(x => ({code: x.code, message: x.message})) 21 | }); 22 | } 23 | 24 | pushErrorManually(code: string, errorMessage: string) { 25 | this.hadError = true; 26 | this.form.errors.push({ 27 | name: this.originalName, 28 | value: this.formValue, 29 | message: this.errorMessage ?? errorMessage, 30 | issues: [{code, message: errorMessage}] 31 | }); 32 | } 33 | 34 | catchParse(zObject: ZodFirstPartySchemaTypes, overrideMessage?: string) { 35 | try { 36 | this.formValue = zObject.parse(this.formValue); 37 | return true; 38 | } catch (err) { 39 | this.pushError(err, overrideMessage); 40 | } 41 | } 42 | 43 | setValue() { 44 | if (this.hadError) return; 45 | setProperty(this.form, this.originalName, this.formValue); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/bind-form-plugins/iform-plugin.ts: -------------------------------------------------------------------------------- 1 | import type {BindForm} from '../bind-form.js'; 2 | import AboutFormName from '../about-form-name.js'; 3 | 4 | export abstract class IHTMLFormPlugin { 5 | storage: Map = new Map(); 6 | 7 | constructor(protected form: BindForm) { 8 | 9 | } 10 | 11 | abstract createOneValidation(key: string, value: any): void; 12 | 13 | abstract addNewValue(about: AboutFormName, ...any: any[]): void; 14 | 15 | createValidation() { 16 | for (const [key, value] of this.storage) { 17 | this.createOneValidation(key, value); 18 | this.storage.delete(key); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/bind-form-plugins/input-radio.ts: -------------------------------------------------------------------------------- 1 | import AboutFormName from '../about-form-name.js'; 2 | import {IHTMLFormPlugin} from './iform-plugin.js'; 3 | 4 | type RadioItem = { 5 | about: AboutFormName, 6 | options: Set 7 | } 8 | type RadioValidation = Map 9 | 10 | export default class HTMLInputRadioPlugin extends IHTMLFormPlugin { 11 | storage: RadioValidation = new Map(); 12 | 13 | createOneValidation(name: string, keyData: any): void { 14 | const {options, about}: RadioItem = keyData; 15 | 16 | if (!options.has(about.formValue)) { 17 | about.pushErrorManually('radio-invalid-value', 'Radio value invalid'); 18 | return; 19 | } 20 | 21 | this.form[name] = about.formValue; 22 | } 23 | 24 | private createRadioDefault(about: AboutFormName): RadioItem { 25 | return { 26 | about, 27 | options: new Set() 28 | }; 29 | } 30 | 31 | addNewValue(about: AboutFormName, originalValue: string): void { 32 | if (!this.storage.has(about.originalName)) { 33 | this.storage.set(about.originalName, this.createRadioDefault(about)); 34 | } else { 35 | this.storage.get(about.originalName).options.add(originalValue); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/bind-form-plugins/select.ts: -------------------------------------------------------------------------------- 1 | import { setProperty } from 'dot-prop'; 2 | import type AboutFormName from '../about-form-name.js'; 3 | import {IHTMLFormPlugin} from './iform-plugin.js'; 4 | 5 | type SelectObject = { 6 | about: AboutFormName 7 | value: string | string[], 8 | options: Set, 9 | multiOptions: boolean 10 | required: boolean 11 | } 12 | 13 | type SelectValidation = Map 14 | 15 | export default class HTMLSelectPlugin extends IHTMLFormPlugin { 16 | storage: SelectValidation = new Map(); 17 | 18 | static errorOptionNotValid(about: AboutFormName) { 19 | about.pushErrorManually('option-not-valid', 'Select option not valid'); 20 | } 21 | 22 | createOneValidation(name: string, keyData: any): void { 23 | const {options, multiOptions, value, about, required}: SelectObject = keyData; 24 | 25 | if (multiOptions) { 26 | const arrayValue = Array.isArray(value) ? value : [value]; 27 | 28 | if (!arrayValue.every(x => options.has(x))) { 29 | required && HTMLSelectPlugin.errorOptionNotValid(about); 30 | return; 31 | } 32 | 33 | setProperty(this.form, name, about.formValue); // the parsed values 34 | return; 35 | } 36 | 37 | if (!options.has(value[0])) { 38 | required && HTMLSelectPlugin.errorOptionNotValid(about); 39 | return; 40 | } 41 | 42 | setProperty(this.form, name, about.formValue[0]); // the parsed value 43 | } 44 | 45 | private createSelectDefault(about: AboutFormName, value: string | string[], multiOptions: boolean, required: boolean): SelectObject { 46 | return { 47 | about, 48 | options: new Set(), 49 | value, 50 | multiOptions, 51 | required 52 | }; 53 | } 54 | 55 | addNewValue(about: AboutFormName, value: string | string[], multiOptions: boolean, required = true): void { 56 | if (!this.storage.has(about.originalName)) { 57 | this.storage.set(about.originalName, this.createSelectDefault(about, value, multiOptions, required)); 58 | } 59 | } 60 | 61 | addOption(originalName: string, option: string) { 62 | this.storage.get(originalName)?.options.add(option); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/bind-form.ts: -------------------------------------------------------------------------------- 1 | import { IHTMLFormPlugin } from './bind-form-plugins/iform-plugin.js'; 2 | import HTMLInputRadioPlugin from './bind-form-plugins/input-radio.js'; 3 | import HTMLSelectPlugin from './bind-form-plugins/select.js'; 4 | 5 | const DEFAULT_PLUGINS = [HTMLInputRadioPlugin, HTMLSelectPlugin]; 6 | type PluginsNames = 'HTMLInputRadioPlugin' | 'HTMLSelectPlugin'; 7 | 8 | export class BindForm { 9 | errors: { 10 | name: string, 11 | value: string, 12 | issues: { 13 | code: string, 14 | message: string; 15 | }[], 16 | message: string; 17 | }[] = []; 18 | 19 | /** 20 | * Events that will be triggered when the form is in a specific state 21 | */ 22 | public on: { 23 | newState?: () => void | Promise; 24 | stateLoaded?: () => void | Promise; 25 | pagePostBack?: () => void | Promise; 26 | } = {}; 27 | 28 | private _plugins: IHTMLFormPlugin[]; 29 | 30 | constructor(private _defaults?: BindValues) { 31 | this.defaults(); 32 | this.initializePlugins(); 33 | } 34 | 35 | private initializePlugins() { 36 | this._plugins = DEFAULT_PLUGINS.map(plugin => new plugin(this)); 37 | } 38 | 39 | getPlugin(name: PluginsNames) { 40 | return this._plugins.find(x => x.constructor.name == name); 41 | } 42 | 43 | async defaults() { 44 | Object.assign(this, this._defaults); 45 | } 46 | 47 | /** 48 | * @internal 49 | */ 50 | __finishFormValidation() { 51 | for (const plugin of this._plugins) { 52 | plugin.createValidation(); 53 | } 54 | } 55 | 56 | /** 57 | * @internal 58 | */ 59 | __getState() { 60 | const state = { ...this }; 61 | delete state._defaults; 62 | delete state._plugins; 63 | delete state.errors; 64 | delete state.on; 65 | 66 | return state as any; 67 | } 68 | } 69 | 70 | export type BindTypes = BindForm & BindValues & { [key: string]: any; }; 71 | export default function Bind(defaults?: BindValues): BindTypes { 72 | return new BindForm(defaults); 73 | } 74 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/parse-multi.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import AboutFormName from './about-form-name.js'; 3 | 4 | export function parseMultiNumber(about: AboutFormName) { 5 | const numArray = z.array(z.number()); 6 | 7 | about.formValue = about.formValue.map(Number); 8 | about.catchParse(numArray); 9 | } 10 | 11 | export function parseMultiDate(about: AboutFormName) { 12 | const dateArray = z.array(z.date()); 13 | 14 | about.formValue = about.formValue.map((date: string) => new Date(Number(date))); 15 | about.catchParse(dateArray); 16 | } 17 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/parse.ts: -------------------------------------------------------------------------------- 1 | import type { AstroGlobal } from 'astro'; 2 | import { ZodIssueCode, z } from 'zod'; 3 | import { getFormMultiValue } from '../../form-tools/post.js'; 4 | import AboutFormName from './about-form-name.js'; 5 | import { FORM_OPTIONS } from '../../settings.js'; 6 | import path from 'path'; 7 | import { BigFile } from '../../components/form/UploadBigFile/BigFile.js'; 8 | import getContext from '@astro-utils/context'; 9 | import fs from 'fs/promises'; 10 | import fsExtra from 'fs-extra/esm'; 11 | 12 | const HEX_COLOR_REGEX = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i; 13 | const BIG_FILE_START = 'big-file:'; 14 | 15 | export function parseCheckbox(about: AboutFormName, originalValue?: string) { 16 | if (originalValue == null) { 17 | about.formValue = about.formValue === 'on'; 18 | } 19 | } 20 | 21 | export function parseNumber(about: AboutFormName, type: 'number' | 'int' | 'range', min?: number, max?: number) { 22 | let num = z.number(); 23 | 24 | if (type === 'int') { 25 | num = num.int(); 26 | } 27 | 28 | if (min != null) { 29 | num = num.min(min); 30 | } 31 | 32 | if (max != null) { 33 | num = num.max(max); 34 | } 35 | 36 | about.formValue = Number(about.formValue); 37 | about.catchParse(num); 38 | } 39 | 40 | 41 | type DateTypes = 'date' | 'datetime-local' | 'month' | 'week' | 'time'; 42 | function parseFormDate(date: Date | string, type?: DateTypes) { 43 | if (date instanceof Date) { 44 | return date; 45 | } 46 | 47 | if (type === 'date' || type === 'datetime-local') { 48 | date = new Date(date); 49 | } else if (type === 'time') { 50 | date = new Date(`1970-01-01T${date}`); 51 | } else if (type === 'month') { 52 | date = new Date(`${date}-01`); 53 | } else if (type === 'week') { 54 | const year = parseInt(date.substring(0, 4), 10); 55 | const week = parseInt(date.substring(6, 8), 10) - 1; // Subtract 1 to convert to 0-indexed 56 | const janFirst = new Date(year, 0, 1); 57 | const days = (week * 7) - janFirst.getDay() + 1; 58 | date = new Date(year, 0, days); 59 | } else { 60 | date = new Date(date); 61 | } 62 | 63 | return date; 64 | } 65 | 66 | export function parseDate(about: AboutFormName, type: DateTypes, min?: string | Date, max?: string | Date) { 67 | let date = z.date(); 68 | 69 | if (min != null) { 70 | date = date.min(parseFormDate(min, type)); 71 | } 72 | 73 | if (max != null) { 74 | date = date.max(parseFormDate(max, type)); 75 | } 76 | 77 | about.formValue = parseFormDate(about.formValue, type); 78 | about.catchParse(date); 79 | } 80 | 81 | export function parseJSON(about: AboutFormName) { 82 | const EMPTY_OBJECT = {}; 83 | 84 | about.catchParse(z.string() 85 | .transform((str, ctx): z.infer> => { 86 | try { 87 | return JSON.parse(str, (key: string, value: any) => { 88 | if (EMPTY_OBJECT[key] !== undefined) { 89 | return; 90 | } 91 | return value; 92 | }); 93 | } catch (e) { 94 | ctx.addIssue({ code: ZodIssueCode.custom, message: 'Invalid JSON' }); 95 | return z.NEVER; 96 | } 97 | })); 98 | } 99 | 100 | export function parseEmail(about: AboutFormName) { 101 | about.catchParse(z.string().email()); 102 | } 103 | 104 | export function parseURL(about: AboutFormName) { 105 | about.catchParse(z.string().url()); 106 | } 107 | 108 | export function parseColor(about: AboutFormName) { 109 | about.catchParse(z.string().regex(HEX_COLOR_REGEX), 'Invalid hex color'); 110 | } 111 | 112 | 113 | const zodValidationInfo = 114 | z.preprocess((str: any, ctx) => { 115 | try { 116 | return JSON.parse(str); 117 | } catch { 118 | ctx.addIssue({ 119 | code: z.ZodIssueCode.custom, 120 | message: "Invalid JSON", 121 | }); 122 | return z.NEVER; 123 | } 124 | }, z.object({ 125 | id: z.string().uuid(), 126 | name: z.string().min(1), 127 | failed: z.boolean().optional(), 128 | })); 129 | 130 | 131 | async function isBigFile(value: string) { 132 | if (typeof value !== 'string' || !value.startsWith(BIG_FILE_START)) { 133 | return; 134 | } 135 | 136 | const tempDirectory = FORM_OPTIONS.forms.bigFilesUpload.bigFileServerOptions.tempDirectory; 137 | const bigFileInfo = value.substring(BIG_FILE_START.length); 138 | 139 | const { success, data } = zodValidationInfo.safeParse(bigFileInfo); 140 | if (!success) { 141 | return; 142 | } 143 | 144 | if (data.failed) { 145 | const chunksDir = path.join(tempDirectory, "chunks_" + data.id); 146 | const errorMessage = path.join(chunksDir, "error.txt"); 147 | try { 148 | return await fs.readFile(errorMessage, 'utf8'); 149 | } catch { 150 | return "upload failed"; 151 | } finally { 152 | await fsExtra.remove(chunksDir); 153 | } 154 | } 155 | 156 | const filePath = path.join(tempDirectory, data.id); 157 | try { 158 | const fileSize = await BigFile.loadFileSize(filePath); 159 | return new BigFile(data.name, filePath, fileSize); 160 | } catch { } 161 | } 162 | 163 | export async function parseFiles(about: AboutFormName, astro: AstroGlobal, multiple: boolean, readonly: boolean) { 164 | if (readonly) return; 165 | 166 | const { disposeFiles, bindId = '' } = getContext(astro, '@astro-utils/forms'); 167 | let values = [about.formValue]; 168 | 169 | let hasFailed = false; 170 | if (multiple) { 171 | values = about.formValue = await getFormMultiValue(astro.request, bindId + about.originalName); 172 | 173 | const promises: Promise[] = []; 174 | for (let i = 0; i < values.length; i++) { 175 | const promise = isBigFile(values[i]).then((bigFile) => { 176 | if (!bigFile || hasFailed) { 177 | return; 178 | } 179 | 180 | if (typeof bigFile === "string") { 181 | hasFailed = true; 182 | about.pushErrorManually('upload-failed', bigFile); 183 | return; 184 | } 185 | 186 | values[i] = bigFile; 187 | disposeFiles.push(bigFile.path); 188 | }); 189 | promises.push(promise); 190 | } 191 | await Promise.all(promises); 192 | } else { 193 | const bigFile = await isBigFile(about.formValue); 194 | if (bigFile) { 195 | if (typeof bigFile === "string") { 196 | about.pushErrorManually('upload-failed', bigFile); 197 | return; 198 | } 199 | 200 | values = [about.formValue = bigFile]; 201 | disposeFiles.push(bigFile.path); 202 | } 203 | } 204 | 205 | for (const value of values) { 206 | if (value instanceof File === false && value instanceof BigFile === false) { 207 | about.pushErrorManually('upload-not-file', 'The upload value is not a file'); 208 | break; 209 | } 210 | } 211 | } 212 | 213 | 214 | export function parseEmptyFiles(about: AboutFormName, astro: AstroGlobal) { 215 | if(astro.props.readonly) return; 216 | 217 | if (!about.formValue || about.formValue.size === 0) { 218 | about.formValue = null; 219 | } 220 | 221 | if (astro.props.multiple) { 222 | about.formValue = []; 223 | } 224 | } -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/validate.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | import AboutFormName from '../form-utils/about-form-name.js'; 3 | 4 | function validateEmptyFile(about: AboutFormName) { 5 | const value = about.formValue; 6 | return value instanceof File && value.size == 0; 7 | } 8 | 9 | export function validateRequire(about: AboutFormName, required: boolean) { 10 | if (about.formValue == null || about.formValue === '' || about.formValue instanceof Array && about.formValue.length === 0 || validateEmptyFile(about)) { 11 | if (required) { 12 | about.pushErrorManually('missing-require-filed', 'Missing required filed'); 13 | } 14 | return false; 15 | } 16 | 17 | return true; 18 | } 19 | 20 | export function validateStringPatters(about: AboutFormName, minlength: number, maxlength: number, pattern: RegExp) { 21 | let text = z.string(); 22 | 23 | if (minlength) { 24 | text = text.min(minlength); 25 | } 26 | 27 | if (maxlength) { 28 | text = text.max(maxlength); 29 | } 30 | 31 | if (pattern) { 32 | text = text.regex(pattern); 33 | } 34 | 35 | return about.catchParse(text); 36 | } 37 | 38 | export async function validateFunc(about: AboutFormName, method: Function) { 39 | try { 40 | const response = await method(about.formValue); 41 | if (!response) return; 42 | 43 | if (response.error) { 44 | about.pushErrorManually(response.code, response.error); 45 | } else if (response.value) { 46 | about.formValue = response.value; 47 | } 48 | } catch (err) { 49 | about.pushErrorManually(err.code ?? err.name, err.message); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/form-utils/view-state.ts: -------------------------------------------------------------------------------- 1 | import { AstroGlobal } from 'astro'; 2 | import superjson from 'superjson'; 3 | import { parseFormData } from '../../form-tools/post.js'; 4 | import { FormsSettings, getFormOptions } from '../../settings.js'; 5 | import { BindForm } from './bind-form.js'; 6 | import snappy from 'snappy'; 7 | import { getSomeProps, omitProps } from '../props-utils.js'; 8 | import crypto from 'crypto'; 9 | 10 | const CRYPTO_ALGORITHM = 'aes-256-ctr'; 11 | 12 | export default class ViewStateManager { 13 | private readonly _FORM_OPTIONS: FormsSettings; 14 | private _VALID_KEY: string; 15 | 16 | get filedName() { 17 | if (!this._FORM_OPTIONS.forms) { 18 | throw new Error('Forms options not set'); 19 | } 20 | 21 | return this._FORM_OPTIONS.forms.viewStateFormFiled + this._bindId; 22 | } 23 | 24 | get stateProp() { 25 | return this._astro.props.state ?? true; 26 | } 27 | 28 | get omitProps() { 29 | return this._astro.props.omitState; 30 | } 31 | 32 | get useState() { 33 | return this.stateProp; 34 | } 35 | 36 | constructor(private _bind: BindForm, private _elementsState: any, private _astro: AstroGlobal, private _bindId: string | number) { 37 | this._FORM_OPTIONS = getFormOptions(_astro); 38 | 39 | if (!this._FORM_OPTIONS.secret) { 40 | throw new Error('Secret not set in form options'); 41 | } 42 | this._initKey(); 43 | } 44 | 45 | private _initKey() { 46 | const repeat = Math.ceil(32 / this._FORM_OPTIONS.secret.length); 47 | this._VALID_KEY = this._FORM_OPTIONS.secret.repeat(repeat).slice(0, 32); 48 | } 49 | 50 | private async _extractStateFromForm() { 51 | const form = await parseFormData(this._astro.request); 52 | return form.get(this.filedName)?.toString(); 53 | } 54 | 55 | private async _parseState() { 56 | try { 57 | const state = await this._extractStateFromForm(); 58 | if (state == null) return; 59 | 60 | const [iv, content] = state.split('.'); 61 | 62 | const decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, this._VALID_KEY, Buffer.from(iv, 'base64')); 63 | const decrypted = Buffer.concat([decipher.update(Buffer.from(content, 'base64')), decipher.final()]); 64 | 65 | const uncompress = await snappy.uncompress(decrypted); 66 | return superjson.parse(uncompress.toString()); 67 | } catch (error: any) { 68 | this._FORM_OPTIONS.logs?.('warn', `ViewStateManager: ${error.message}`); 69 | } 70 | } 71 | 72 | public async loadState() { 73 | if (!this.useState || this._astro.request.method !== 'POST') { 74 | return false; 75 | } 76 | 77 | const state: any = await this._parseState(); 78 | if (!state) return false; 79 | 80 | if (state.bind && state.elements) { 81 | Object.assign(this._bind, state.bind); 82 | Object.assign(this._elementsState, state.elements); 83 | } 84 | return true; 85 | } 86 | 87 | public async createViewState(): Promise { 88 | const data = this.useState ? { 89 | bind: this.omitProps ? 90 | omitProps(this._bind.__getState(), this.omitProps) : 91 | getSomeProps(this._bind.__getState(), this.stateProp), 92 | elements: this._elementsState 93 | } : {}; 94 | 95 | const stringify = superjson.stringify(data); 96 | const compress = await snappy.compress(stringify, {}); 97 | 98 | const iv = crypto.randomBytes(16); 99 | const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, this._VALID_KEY, iv); 100 | const encrypted = Buffer.concat([cipher.update(compress), cipher.final()]); 101 | 102 | return `${iv.toString('base64')}.${encrypted.toString('base64')}`; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/input-parse.ts: -------------------------------------------------------------------------------- 1 | import type { AstroGlobal } from 'astro'; 2 | import { getFormValue } from '../form-tools/post.js'; 3 | import AboutFormName from './form-utils/about-form-name.js'; 4 | import type HTMLInputRadioPlugin from './form-utils/bind-form-plugins/input-radio.js'; 5 | import { BindForm } from './form-utils/bind-form.js'; 6 | import { parseCheckbox, parseColor, parseDate, parseEmail, parseEmptyFiles, parseFiles, parseJSON, parseNumber, parseURL } from './form-utils/parse.js'; 7 | import { validateFunc, validateRequire, validateStringPatters } from './form-utils/validate.js'; 8 | import { getProperty } from 'dot-prop'; 9 | import { ZodType } from 'zod'; 10 | 11 | const OK_NOT_STRING_VALUE = ['checkbox', 'file']; 12 | const OK_INPUT_VALUE_NULL = ['checkbox']; 13 | const DAY_IN_MS = 86400000; 14 | 15 | type InputTypes = 16 | | 'button' 17 | | 'checkbox' 18 | | 'color' 19 | | 'date' 20 | | 'datetime-local' 21 | | 'email' 22 | | 'file' 23 | | 'hidden' 24 | | 'image' 25 | | 'month' 26 | | 'number' 27 | | 'password' 28 | | 'radio' 29 | | 'range' 30 | | 'reset' 31 | | 'search' 32 | | 'submit' 33 | | 'tel' 34 | | 'text' 35 | | 'time' 36 | | 'url' 37 | | 'week'; 38 | 39 | type ExtendedInputTypes = InputTypes | 'int' | 'json'; 40 | 41 | export async function getInputValue(astro: AstroGlobal, bindId: string, bind: BindForm) { 42 | const { value, name, readonly } = astro.props; 43 | if (readonly) { 44 | return getProperty(bind, name, value); 45 | } 46 | 47 | return await getFormValue(astro.request, bindId + name); 48 | } 49 | 50 | export async function validateFormInput(astro: AstroGlobal, bind: BindForm, bindId: string) { 51 | const { type, value: originalValue, minlength, maxlength, pattern, required, name, errorMessage, validate } = astro.props; 52 | 53 | const parseValue: any = await getInputValue(astro, bindId, bind); 54 | const aboutInput = new AboutFormName(bind, name, parseValue, errorMessage); 55 | 56 | // validate filed exits 57 | if (!OK_INPUT_VALUE_NULL.includes(type) && !validateRequire(aboutInput, required)) { 58 | if(type === 'file') { 59 | parseEmptyFiles(aboutInput, astro); 60 | } 61 | aboutInput.setValue(); 62 | return; 63 | } 64 | 65 | // validate string patters 66 | const checkStringPatterns = originalValue != null && !OK_NOT_STRING_VALUE.includes(type); 67 | if (checkStringPatterns && !validateStringPatters(aboutInput, minlength, maxlength, pattern)) { 68 | return; 69 | } 70 | 71 | // specific validation by type / function 72 | await validateByInputType(astro, aboutInput, bind); 73 | if (!aboutInput.hadError) { 74 | if (typeof validate == 'function') { 75 | await validateFunc(aboutInput, validate); 76 | } else if (validate instanceof ZodType) { 77 | aboutInput.catchParse(validate); 78 | } 79 | } 80 | 81 | aboutInput.setValue(); 82 | } 83 | 84 | async function validateByInputType(astro: AstroGlobal, aboutInput: AboutFormName, bind: BindForm) { 85 | const { type, min, max, value: originalValue, multiple, readonly } = astro.props; 86 | 87 | switch (type) { 88 | case 'checkbox': 89 | parseCheckbox(aboutInput, originalValue); 90 | break; 91 | 92 | case 'color': 93 | parseColor(aboutInput); 94 | break; 95 | 96 | case 'date': 97 | case 'datetime-local': 98 | case 'month': 99 | case 'week': 100 | case 'time': 101 | parseDate(aboutInput, type, min, max); 102 | break; 103 | 104 | case 'email': 105 | parseEmail(aboutInput); 106 | break; 107 | 108 | case 'number': 109 | case 'range': 110 | case 'int': 111 | parseNumber(aboutInput, type, min, max); 112 | break; 113 | 114 | case 'radio': 115 | const plugin = bind.getPlugin('HTMLInputRadioPlugin') as HTMLInputRadioPlugin; 116 | plugin.addNewValue(aboutInput, originalValue); 117 | break; 118 | 119 | case 'url': 120 | parseURL(aboutInput); 121 | break; 122 | 123 | case 'json': 124 | parseJSON(aboutInput); 125 | break; 126 | 127 | case 'file': 128 | await parseFiles(aboutInput, astro, multiple, readonly); 129 | break; 130 | } 131 | } 132 | 133 | function toDateTimeLocal(date: Date) { 134 | const year = date.getFullYear(); 135 | const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based 136 | const day = String(date.getDate()).padStart(2, '0'); 137 | const hours = String(date.getHours()).padStart(2, '0'); 138 | const minutes = String(date.getMinutes()).padStart(2, '0'); 139 | 140 | return `${year}-${month}-${day}T${hours}:${minutes}`; 141 | } 142 | 143 | 144 | function stringifyCustomValue(date?: Date | string, type?: ExtendedInputTypes) { 145 | if (typeof date === 'string' || !date) { 146 | return date; 147 | } 148 | 149 | switch (type) { 150 | case 'date': 151 | return toDateTimeLocal(date).slice(0, 10); 152 | case 'datetime-local': 153 | return toDateTimeLocal(date).slice(0, 16); 154 | case 'time': 155 | return date.toTimeString().slice(0, 5); 156 | case 'month': 157 | return toDateTimeLocal(date).slice(0, 7); 158 | case 'week': 159 | return formatToDateWeek(date); 160 | case 'json': 161 | return JSON.stringify(date); 162 | } 163 | 164 | return date; 165 | } 166 | 167 | export function inputReturnValueAttr(astro: AstroGlobal, bind: BindForm) { 168 | const value = stringifyCustomValue(getProperty(bind, astro.props.name, astro.props.value), astro.props.type); 169 | const min = stringifyCustomValue(astro.props.min, astro.props.type); 170 | const max = stringifyCustomValue(astro.props.max, astro.props.type); 171 | 172 | switch (astro.props.type as ExtendedInputTypes) { 173 | case 'checkbox': 174 | return { checked: value ?? astro.props.checked }; 175 | case 'file': 176 | return {}; 177 | } 178 | 179 | return { value, min, max }; 180 | } 181 | 182 | function formatToDateWeek(date: Date): string { 183 | const year = date.getFullYear(); 184 | const firstDayOfYear = new Date(year, 0, 1); 185 | const daysSinceStartOfYear = (date.getTime() - firstDayOfYear.getTime()) / DAY_IN_MS; 186 | const weekNumber = Math.ceil((daysSinceStartOfYear + firstDayOfYear.getDay() + 1) / 7); 187 | return `${year}-W${weekNumber.toString().padStart(2, '0')}`; 188 | } 189 | 190 | 191 | export function caseTypes(type: ExtendedInputTypes): { type: ExtendedInputTypes; } & { [key: string]: string; } { 192 | if (type == 'int') { 193 | return { 194 | type: 'number', 195 | pattern: '\\d+', 196 | step: '1' 197 | }; 198 | } else if (type == 'json') { 199 | return { 200 | type: 'text' 201 | }; 202 | } 203 | 204 | return { type }; 205 | } 206 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/props-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the difference between two objects 3 | */ 4 | export function diffProps(object1: any, object2: any, skipKeys: string[] = []) { 5 | const diff = {}; 6 | for (const [key, value] of Object.entries(object2)) { 7 | if(skipKeys.includes(key)) continue; 8 | if (JSON.stringify(object1[key]) !== JSON.stringify(value)) { 9 | diff[key] = value; 10 | } 11 | } 12 | return diff; 13 | } 14 | 15 | export function getSomeProps(object: any, props: string[] | true) { 16 | if (props === true) { 17 | return object; 18 | } 19 | const result = {}; 20 | for (const prop of props) { 21 | result[prop] = object[prop]; 22 | } 23 | return result; 24 | } 25 | 26 | /** 27 | * Omit properties from an object, any property that starts with an underscore or is in the props array will be omitted 28 | */ 29 | export function omitProps(object: any, props: string[] | true) { 30 | if (props === true) { 31 | return {}; 32 | } 33 | 34 | const result = {...object}; 35 | for (const prop in object) { 36 | if(props.includes(prop) || prop[0] === '_'){ 37 | delete result[prop]; 38 | } 39 | } 40 | return result; 41 | } 42 | -------------------------------------------------------------------------------- /packages/forms/src/components-control/select.ts: -------------------------------------------------------------------------------- 1 | import type { AstroGlobal } from 'astro'; 2 | import { getFormMultiValue } from '../form-tools/post.js'; 3 | import AboutFormName from './form-utils/about-form-name.js'; 4 | import HTMLSelectPlugin from './form-utils/bind-form-plugins/select.js'; 5 | import { BindForm } from './form-utils/bind-form.js'; 6 | import { parseMultiDate, parseMultiNumber } from './form-utils/parse-multi.js'; 7 | import { validateRequire } from './form-utils/validate.js'; 8 | import { getProperty } from 'dot-prop'; 9 | 10 | type InputTypes = 'number' | 'date' | 'text'; 11 | 12 | 13 | export function stringifySelectValue(value: Date | Number | string) { 14 | if (value instanceof Date) { 15 | value = value.getTime(); 16 | } 17 | return String(value); 18 | } 19 | 20 | async function getSelectValue(astro: AstroGlobal, bindId: string) { 21 | const { value: originalValue, readonly, name } = astro.props; 22 | if (readonly) { 23 | return [originalValue].flat().map(stringifySelectValue); 24 | } 25 | return (await getFormMultiValue(astro.request, bindId + name)).map(String); 26 | } 27 | 28 | export async function validateSelect(astro: AstroGlobal, bind: BindForm, bindId: string) { 29 | const { type, required, name, multiple, errorMessage } = astro.props; 30 | 31 | const parseValue = await getSelectValue(astro, bindId); 32 | const aboutSelect = new AboutFormName(bind, name, parseValue, errorMessage); 33 | 34 | if (!validateRequire(aboutSelect, required)) { 35 | aboutSelect.setValue(); 36 | return []; 37 | } 38 | 39 | const selectPlugin = bind.getPlugin('HTMLSelectPlugin') as HTMLSelectPlugin; 40 | selectPlugin.addNewValue(aboutSelect, parseValue, multiple, required); 41 | 42 | switch (type as InputTypes) { 43 | case 'date': 44 | parseMultiDate(aboutSelect); 45 | break; 46 | 47 | case 'number': 48 | parseMultiNumber(aboutSelect); 49 | break; 50 | } 51 | 52 | aboutSelect.setValue(); 53 | 54 | return parseValue; 55 | } 56 | 57 | export function validateSelectOption(bind: BindForm, name: string, stringifyValue: string) { 58 | const selectPlugin = bind.getPlugin('HTMLSelectPlugin') as HTMLSelectPlugin; 59 | selectPlugin.addOption(name, stringifyValue); 60 | } 61 | 62 | 63 | export function getSelectValueFromBind(bind: BindForm, astro: AstroGlobal) { 64 | const newValue = [getProperty(bind, astro.props.name, astro.props.value)].flat(); 65 | 66 | return newValue.map(stringifySelectValue); 67 | } -------------------------------------------------------------------------------- /packages/forms/src/components/WebForms.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { asyncContext } from '@astro-utils/context'; 3 | import { createFormToken } from '../form-tools/csrf.js'; 4 | import { getFormOptions } from '../settings.js'; 5 | import fs from 'fs/promises'; 6 | 7 | export interface Props extends astroHTML.JSX.FormHTMLAttributes { 8 | loadingClassName?: string; 9 | } 10 | 11 | const context = { 12 | ...Astro.props, 13 | webFormsSettings: { haveFileUpload: false }, 14 | tempValues: {}, 15 | disposeFiles: [], 16 | }; 17 | 18 | const htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, { name: '@astro-utils/forms', context, lock: 'webForms' }); 19 | 20 | const { webFormsSettings, tempValues, disposeFiles, loadingClassName = '', ...props } = context; 21 | if (webFormsSettings.haveFileUpload) { 22 | props.enctype = 'multipart/form-data'; 23 | } 24 | 25 | await Promise.all( 26 | disposeFiles.map(file => fs.unlink(file).catch(() => {})) 27 | ); 28 | 29 | const useSession = getFormOptions(Astro).session?.cookieOptions?.maxAge; 30 | const formRequestToken = useSession && (await createFormToken(Astro)); 31 | const clientScript = Astro.locals.forms.scriptToRun; 32 | 33 | const bigFileClientOptions = Astro.locals.__formsInternalUtils.FORM_OPTIONS.forms?.bigFilesUpload?.bigFileClientOptions; 34 | const clientWFS = { loadingClassName, bigFileUploadOptions: bigFileClientOptions, csrf: formRequestToken }; 35 | --- 36 | 37 | 38 | {formRequestToken && } 39 | 40 | {clientScript && 46 | 47 | 105 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/BButton.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext from '@astro-utils/context'; 3 | import { createUniqueContinuanceName } from '../../form-tools/connectId.js'; 4 | import { isPost, validateAction } from '../../form-tools/post.js'; 5 | import { diffProps } from '../../components-control/props-utils.js'; 6 | import objectAssignDeep from 'object-assign-deep'; 7 | import { MissingClickActionError } from '../../errors/MissingClickActionError.js'; 8 | 9 | export interface Props> extends astroHTML.JSX.ButtonHTMLAttributes { 10 | onClick: Function; 11 | connectId?: string; 12 | whenFormOK?: boolean; 13 | as?: T; 14 | props?: React.ComponentProps; 15 | state?: any; 16 | extra?: any; 17 | } 18 | 19 | const { as: asComponent = 'button', props: componentProps, onClick, whenFormOK, connectId, ...props } = Astro.props; 20 | if (typeof onClick !== 'function') { 21 | throw new MissingClickActionError(); 22 | } 23 | 24 | componentProps && Object.assign(props, componentProps); 25 | 26 | const { bind, executeAfter, elementsState, tempValues, tempBindValues, method, buttonIds, settings, bindId = '' } = getContext(Astro, '@astro-utils/forms'); 27 | const tempCounter = tempBindValues || tempValues; 28 | const elementPropsState = elementsState || tempValues; 29 | 30 | let buttonUniqueId = connectId; 31 | if (!connectId) { 32 | const idBaseFunction = createUniqueContinuanceName(onClick) + (method ? 'form' : ''); 33 | tempCounter[idBaseFunction] ??= 0; 34 | 35 | const counter = ++tempCounter[idBaseFunction]; 36 | buttonUniqueId = `${bindId + idBaseFunction}-${counter}`; 37 | } 38 | 39 | const allProps = { ...props, ...elementPropsState[buttonUniqueId] }; 40 | 41 | // add this button to list of button for the default submit action 42 | if (buttonIds && !allProps.disabled) { 43 | buttonIds.push([buttonUniqueId, allProps.id, whenFormOK]); 44 | } 45 | 46 | async function executeFormAction(callback: Function = onClick) { 47 | const validAction = isPost(Astro) && (await validateAction(Astro, 'button-callback', buttonUniqueId)); 48 | 49 | if (validAction && whenFormOK && settings) { 50 | settings.showValidationErrors = true; 51 | } 52 | 53 | const checkFormValidation = (whenFormOK && !bind?.errors.length) || !whenFormOK; 54 | if (checkFormValidation && validAction) { 55 | const originalProps = objectAssignDeep({}, { ...props, extra: null }); 56 | 57 | if (!allProps.disabled) { 58 | await callback.call(allProps); 59 | } 60 | 61 | elementPropsState[buttonUniqueId] = diffProps(originalProps, allProps, ['extra']); 62 | } 63 | } 64 | 65 | if (executeAfter) { 66 | executeAfter.push(executeFormAction); 67 | } else if (whenFormOK) { 68 | throw new Error('Use BButton with `whenFormOK` inside a BindForm component'); 69 | } else { 70 | await executeFormAction(); 71 | } 72 | 73 | const { innerText, innerHTML, remove: doNotWriteHTML, ...changedProps }: any = allProps; 74 | delete changedProps.extra; 75 | delete changedProps.state; 76 | 77 | const Component = asComponent as any; 78 | const slotData = innerHTML ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : ''); 79 | --- 80 | 81 | { 82 | !doNotWriteHTML && ( 83 | 84 | {innerText ?? } 85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/BInput.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext from '@astro-utils/context'; 3 | import { caseTypes, inputReturnValueAttr, validateFormInput } from '../../components-control/input-parse.js'; 4 | import { validateFrom } from '../../form-tools/csrf.js'; 5 | import { ModifyDeep } from '../../utils.js'; 6 | import { addOnSubmitClickEvent } from '../../form-tools/events.js'; 7 | import { MissingNamePropError } from '../../errors/MissingNamePropError.js'; 8 | import { ZodType } from 'zod'; 9 | 10 | type inputTypes = astroHTML.JSX.InputHTMLAttributes['type'] | 'int' | 'json'; 11 | 12 | interface ModifyInputProps { 13 | type?: inputTypes; 14 | pattern?: RegExp; 15 | minlength?: number; 16 | maxlength?: number; 17 | value?: string | string[] | number | Date | null | undefined | boolean; 18 | max?: number | string | undefined | null | Date; 19 | min?: number | string | undefined | null | Date; 20 | onSubmitClick?: string; 21 | [key: `data-${string}`]: any; 22 | } 23 | 24 | export interface Props> extends Partial> { 25 | name: string; 26 | errorMessage?: string; 27 | validate?: Function | ZodType; 28 | as?: T; 29 | props?: React.ComponentProps; 30 | } 31 | 32 | if (!Astro.props.name) { 33 | throw new MissingNamePropError('BInput'); 34 | } 35 | 36 | const { bind, method, webFormsSettings, onSubmitClickGlobal, bindId = '' } = getContext(Astro, '@astro-utils/forms'); 37 | if (!Astro.props.disabled && method === 'POST' && (await validateFrom(Astro))) { 38 | await validateFormInput(Astro, bind, bindId); 39 | } 40 | 41 | const { as: asComponent = 'input', props: componentProps, type = 'text', onSubmitClick = onSubmitClickGlobal, pattern, ...props } = Astro.props; 42 | const castProps = caseTypes(type); 43 | const inputValue = inputReturnValueAttr(Astro, bind); 44 | 45 | const allProps = { ...props, ...inputValue, ...componentProps, pattern: pattern?.toString(), ...castProps }; 46 | allProps.name = bindId + allProps.name; 47 | addOnSubmitClickEvent(onSubmitClick, allProps); 48 | 49 | webFormsSettings.haveFileUpload ||= allProps.type === 'file'; 50 | 51 | const Component = asComponent as any; 52 | --- 53 | 54 | 55 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/BOption.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext from '@astro-utils/context'; 3 | import { stringifySelectValue, validateSelectOption } from '../../components-control/select.js'; 4 | import { validateFrom } from '../../form-tools/csrf.js'; 5 | 6 | export interface Props> extends Omit { 7 | as?: T; 8 | props?: React.ComponentProps; 9 | value?: string | number | undefined | null | Date; 10 | } 11 | 12 | const { bind, name, stringifySelectdOptions, method } = getContext(Astro, '@astro-utils/forms'); 13 | 14 | let htmlSolt = await Astro.slots.render('default'); 15 | const stringisfyOptionValue = stringifySelectValue(Astro.props.value ?? htmlSolt); 16 | 17 | if (!Astro.props.disabled && method === 'POST' && (await validateFrom(Astro))) { 18 | validateSelectOption(bind, name, stringisfyOptionValue); 19 | } 20 | 21 | if(Astro.props.value != null){ 22 | Astro.props.value = stringisfyOptionValue; 23 | } 24 | 25 | const { as: asComponent = 'option', props: componentProps, selected, ...props } = Astro.props; 26 | const Component = asComponent as any; 27 | --- 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/BSelect.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext, { asyncContext } from '@astro-utils/context'; 3 | import { getSelectValueFromBind, validateSelect } from '../../components-control/select.js'; 4 | import { validateFrom } from '../../form-tools/csrf.js'; 5 | import { addOnSubmitClickEvent } from '../../form-tools/events.js'; 6 | import { MissingNamePropError } from '../../errors/MissingNamePropError.js'; 7 | 8 | export interface Props> extends Omit { 9 | name: string; 10 | errorMessage?: string; 11 | type?: 'string' | 'number' | 'date'; 12 | as?: T; 13 | props?: React.ComponentProps; 14 | value?: string | number | undefined | null | Date | (Date | number | string | null | undefined)[]; 15 | onSubmitClick?: string; 16 | } 17 | 18 | if (!Astro.props.name) { 19 | throw new MissingNamePropError('BInput'); 20 | } 21 | 22 | const { bind, method, onSubmitClickGlobal, bindId = '' } = getContext(Astro, '@astro-utils/forms'); 23 | 24 | const { as: asComponent = 'select', value: _, props: componentProps, onSubmitClick = onSubmitClickGlobal, ...props } = Astro.props; 25 | props.name = bindId + props.name; 26 | addOnSubmitClickEvent(onSubmitClick, props); 27 | 28 | let stringifySelectdOptions = getSelectValueFromBind(bind, Astro); 29 | 30 | if (!Astro.props.disabled && method === 'POST' && (await validateFrom(Astro))) { 31 | stringifySelectdOptions = await validateSelect(Astro, bind, bindId); 32 | } 33 | 34 | const Component = asComponent as any; 35 | const context = { name: Astro.props.name, stringifySelectdOptions }; 36 | const htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, { name: '@astro-utils/forms', context, lock: 'select' }); 37 | --- 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/BTextarea.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext from '@astro-utils/context'; 3 | import { validateFormInput } from '../../components-control/input-parse.js'; 4 | import { validateFrom } from '../../form-tools/csrf.js'; 5 | import { ModifyDeep } from '../../utils.js'; 6 | import { getProperty } from 'dot-prop'; 7 | import { addOnSubmitClickEvent } from '../../form-tools/events.js'; 8 | import { MissingNamePropError } from '../../errors/MissingNamePropError.js'; 9 | import { ZodType } from 'zod'; 10 | 11 | interface ModifyInputProps { 12 | minlength?: number; 13 | maxlength?: number; 14 | [key: `data-${string}`]: any; 15 | } 16 | 17 | export interface Props> extends Partial> { 18 | name: string; 19 | errorMessage?: string; 20 | validate?: Function | ZodType; 21 | as?: T; 22 | props?: React.ComponentProps; 23 | onSubmitClick?: string; 24 | [key: `data-${string}`]: any; 25 | } 26 | 27 | if (!Astro.props.name) { 28 | throw new MissingNamePropError('BInput'); 29 | } 30 | 31 | const { bind, method, onSubmitClickGlobal, bindId = '' } = getContext(Astro, '@astro-utils/forms'); 32 | if (!Astro.props.disabled && method === 'POST' && (await validateFrom(Astro))) { 33 | await validateFormInput(Astro, bind, bindId); 34 | } 35 | 36 | const { as: asComponent = 'textarea', props: componentProps, value: defaultValue, onSubmitClick = onSubmitClickGlobal, ...props } = Astro.props; 37 | props.name = bindId + props.name; 38 | addOnSubmitClickEvent(onSubmitClick, props); 39 | 40 | const value = getProperty(bind, Astro.props.name, (await Astro.slots.render('default')) ?? defaultValue); 41 | 42 | const Component = asComponent as any; 43 | --- 44 | 45 | {value} 46 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/BindForm.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { asyncContext } from '@astro-utils/context'; 3 | import getContext from '@astro-utils/context'; 4 | import ViewStateManager from '../../components-control/form-utils/view-state.js'; 5 | import Bind from '../../components-control/form-utils/bind-form.js'; 6 | 7 | export interface Props { 8 | bind?: ReturnType; 9 | state?: boolean | string[]; 10 | omitState?: string[]; 11 | defaultSubmitClick?: string | false; 12 | key?: string 13 | } 14 | 15 | const { lastRender: parantLastRender, bindId: parantbindId = '' } = getContext(Astro, '@astro-utils/forms'); 16 | if (parantLastRender === false) { 17 | return; 18 | } 19 | 20 | const { bind = Bind(), defaultSubmitClick, key = '' } = Astro.props; 21 | 22 | const context = { 23 | executeAfter: [], 24 | method: 'GET', 25 | bind, 26 | tempBindValues: {}, 27 | elementsState: {} as any, 28 | onSubmitClickGlobal: defaultSubmitClick, 29 | buttonIds: [] as [string, string | null, boolean][], 30 | settings: { showValidationErrors: false }, 31 | bindId: key + (Astro.locals.__formsInternalUtils.bindFormCounter++), 32 | lastRender: false, 33 | newState: false, 34 | }; 35 | 36 | const resetContext = () => { 37 | context.buttonIds = []; 38 | context.tempBindValues = {}; 39 | context.executeAfter = []; 40 | }; 41 | 42 | const viewState = new ViewStateManager(bind, context.elementsState, Astro, context.bindId); 43 | context.newState = !(await viewState.loadState()); 44 | await bind.on.stateLoaded?.(); 45 | if (context.newState) { 46 | await bind.on.newState?.(); 47 | } 48 | 49 | // For some resone the first render, in astro appenning in the oppesit direction, memning it first return the html and than runs the logic? 50 | await asyncContext(() => Astro.slots.render('default'), Astro, { name: '@astro-utils/forms', context, lock: 'bindForm' + parantbindId }); 51 | resetContext(); 52 | // Get information about the form 53 | context.method = context.newState ? 'GET' : Astro.request.method; 54 | await asyncContext(() => Astro.slots.render('default'), Astro, { name: '@astro-utils/forms', context, lock: 'bindForm' + parantbindId }); 55 | bind.__finishFormValidation(); 56 | 57 | if (context.method == 'POST') { 58 | if (!context.newState) { 59 | await bind.on.pagePostBack?.(); 60 | } 61 | for (const func of context.executeAfter) { 62 | await (func as any)(); 63 | } 64 | 65 | context.method = 'GET'; 66 | resetContext(); 67 | 68 | await asyncContext(() => Astro.slots.render('default'), Astro, { name: '@astro-utils/forms', context, lock: 'bindForm' + parantbindId }); 69 | } 70 | 71 | // Edit form render, add default submit button 72 | if (context.onSubmitClickGlobal == null && context.buttonIds.length > 0) { 73 | const [buttonFormId, HTMLButtonId] = context.buttonIds.findLast(([, , whenFormOk]) => whenFormOk) ?? context.buttonIds.at(-1)!; 74 | const state = (context.elementsState[buttonFormId] ??= {}); 75 | 76 | state.id = HTMLButtonId ?? `_${buttonFormId}`; 77 | context.onSubmitClickGlobal = state.id; 78 | } 79 | 80 | resetContext(); 81 | context.lastRender = true; 82 | const htmlSolt = await asyncContext(() => Astro.slots.render('default'), Astro, { name: '@astro-utils/forms', context, lock: 'bindForm' + parantbindId }); 83 | --- 84 | 85 | 86 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/FormErrors.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext from '@astro-utils/context'; 3 | 4 | export interface Props extends astroHTML.JSX.HTMLAttributes { 5 | title?: string; 6 | }; 7 | 8 | const {bind, settings} = getContext(Astro, '@astro-utils/forms'); 9 | const {title, ...props} = Astro.props; 10 | --- 11 | {bind.errors.length && settings.showValidationErrors && 12 |
13 | {title &&

{title}

} 14 |
    15 | {bind.errors.map(x => 16 |
  1. {x.message}: "{x.name}"
  2. )} 17 |
18 |
|| ''} 19 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/UploadBigFile/BigFile.ts: -------------------------------------------------------------------------------- 1 | import { createReadStream } from "fs"; 2 | import { type CreateReadStreamOptions, stat, copyFile } from "fs/promises"; 3 | 4 | export class BigFile { 5 | /** 6 | * @internal 7 | */ 8 | constructor(public readonly name: string, public readonly path: string, public readonly size: number) { 9 | 10 | } 11 | 12 | stream(options?: BufferEncoding | CreateReadStreamOptions) { 13 | return createReadStream(this.path, options); 14 | } 15 | 16 | copyTo(destination: string) { 17 | return copyFile(this.path, destination); 18 | } 19 | 20 | /** 21 | * @internal 22 | */ 23 | static async loadFileSize(path: string) { 24 | const file = await stat(path); 25 | if (!file.isFile()) { 26 | throw new Error('Not a file'); 27 | } 28 | return file.size; 29 | } 30 | } -------------------------------------------------------------------------------- /packages/forms/src/components/form/UploadBigFile/UploadBigFile.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { ComponentProps } from 'astro/types'; 3 | import BInput from '../BInput.astro'; 4 | import { processBigFileUpload } from './uploadBigFileServer.js'; 5 | 6 | export interface Props> extends ComponentProps> { 7 | } 8 | 9 | const { class: className, tempDirectory, name, ...props } = Astro.props; 10 | await processBigFileUpload(Astro); 11 | --- 12 | 13 | 14 | 15 | 40 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/UploadBigFile/UploadBigFileProgress.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import getContext from '../../../../../context/dist/index.js'; 3 | 4 | interface Props extends astroHTML.JSX.ProgressHTMLAttributes { 5 | for: string; 6 | onActiveClass?: string; 7 | }; 8 | 9 | const { bindId = ''} = getContext(Astro, '@astro-utils/forms'); 10 | const { for: forName, onActiveClass, ...props } = Astro.props; 11 | const fullForName = bindId + forName; 12 | --- 13 | -------------------------------------------------------------------------------- /packages/forms/src/components/form/UploadBigFile/uploadBigFileClient.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | 3 | const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); 4 | 5 | type ProgressCallback = (progress: number, total: number) => void; 6 | 7 | export type BigFileUploadOptions = { 8 | retryChunks: number; 9 | retryDelay?: number; 10 | chunkSize: number; 11 | parallelChunks: number; 12 | parallelUploads: number; 13 | waitFinishDelay?: number; 14 | }; 15 | 16 | const UPLOAD_BIG_FILE_OPTIONS: BigFileUploadOptions = { 17 | retryChunks: 5, 18 | retryDelay: 1000, 19 | chunkSize: 1024 * 1024 * 5, 20 | parallelChunks: 3, 21 | parallelUploads: 3, 22 | waitFinishDelay: 1000, 23 | }; 24 | 25 | const clientWFS = (window as any).clientWFS; 26 | 27 | async function uploadChunkWithXHR(file: Blob, info: Record, progressCallback: ProgressCallback) { 28 | return new Promise((resolve, reject) => { 29 | const xhr = new XMLHttpRequest(); 30 | const formData = new FormData(); 31 | xhr.responseType = "text"; 32 | 33 | formData.append('file', file); 34 | formData.append("astroBigFileUpload", "true"); 35 | formData.append('info', JSON.stringify(info)); 36 | 37 | if (clientWFS.csrf) { 38 | formData.append(clientWFS.csrf.filed, clientWFS.csrf.token); 39 | } 40 | 41 | xhr.upload.onprogress = (event) => { 42 | progressCallback(event.loaded, event.total); 43 | }; 44 | 45 | xhr.onload = () => { 46 | if (xhr.status >= 200 && xhr.status < 300) { 47 | resolve(JSON.parse(xhr.responseText)); 48 | } else { 49 | reject({ ok: false, error: xhr.responseText }); 50 | } 51 | }; 52 | xhr.onerror = () => { 53 | reject({ ok: false, error: xhr.responseText }); 54 | }; 55 | 56 | xhr.open('POST', location.href, true); 57 | xhr.send(formData); 58 | }); 59 | } 60 | 61 | async function finishUpload(uploadId: string, options: BigFileUploadOptions) { 62 | let maxError = options.retryChunks; 63 | while (true) { 64 | try { 65 | const response = await new Promise((resolve, reject) => { 66 | const xhr = new XMLHttpRequest(); 67 | const formData = new FormData(); 68 | xhr.responseType = "text"; 69 | 70 | formData.append('wait', uploadId); 71 | formData.append("astroBigFileUpload", "true"); 72 | 73 | if (clientWFS.csrf) { 74 | formData.append(clientWFS.csrf.filed, clientWFS.csrf.token); 75 | } 76 | 77 | xhr.onload = () => { 78 | if (xhr.status >= 200 && xhr.status < 300) { 79 | resolve(JSON.parse(xhr.responseText)); 80 | } else { 81 | reject({ ok: false, error: xhr.responseText }); 82 | } 83 | }; 84 | xhr.onerror = () => { 85 | reject({ ok: false, error: xhr.responseText }); 86 | }; 87 | 88 | xhr.open('POST', location.href, true); 89 | xhr.send(formData); 90 | }); 91 | 92 | if (!response.wait) { 93 | break; 94 | } 95 | 96 | await sleep(options.waitFinishDelay); 97 | } catch (error) { 98 | if(maxError === 0){ 99 | throw error; 100 | } 101 | maxError--; 102 | await sleep(options.retryChunks); 103 | } 104 | } 105 | } 106 | 107 | async function uploadBigFile(fileId: string, file: File, progressCallback: ProgressCallback, options: BigFileUploadOptions) { 108 | const totalSize = file.size; 109 | const totalChunks = Math.ceil(totalSize / options.chunkSize); 110 | 111 | const activeChunks = new Set>(); 112 | const activeLoads = new Map(); 113 | let finishedSize = 0; 114 | 115 | const uploadChunk = async (i: number) => { 116 | while (activeChunks.size >= options.parallelChunks) { 117 | await Promise.race(activeChunks); 118 | } 119 | 120 | // last chunks should wait for all active chunks to finish 121 | if (i + 1 === totalChunks) { 122 | await Promise.all(activeChunks); 123 | } 124 | 125 | const start = i * options.chunkSize; 126 | const end = Math.min(totalSize, start + options.chunkSize); 127 | const chunk = file.slice(start, end); 128 | 129 | const info = { 130 | uploadId: fileId, 131 | uploadSize: totalSize, 132 | part: i + 1, 133 | total: totalChunks, 134 | }; 135 | 136 | const uploadPromiseWithRetry = retry(async () => { 137 | const upload = await uploadChunkWithXHR(chunk, info, (loaded) => { 138 | activeLoads.set(i, loaded); 139 | const loadedSize = Array.from(activeLoads.values()).reduce((a, b) => a + b, 0); 140 | progressCallback(finishedSize + loadedSize, totalSize); 141 | }); 142 | 143 | const response: any = await upload; 144 | if (response?.missingChunks && activeChunks.size < options.parallelChunks) { 145 | const promises: Promise[] = []; 146 | for (const chunk of response.missingChunks) { 147 | const { promise } = await uploadChunk(chunk - 1); 148 | promises.push(promise); 149 | } 150 | await Promise.all(promises); 151 | } 152 | 153 | if (!response?.ok) { 154 | throw new Error(response.error); 155 | } 156 | }, { retries: options.retryChunks, delay: options.retryDelay }) 157 | .then(() => { 158 | activeLoads.delete(i); 159 | activeChunks.delete(uploadPromiseWithRetry); 160 | finishedSize += chunk.size; 161 | }); 162 | 163 | activeChunks.add(uploadPromiseWithRetry); 164 | return { promise: uploadPromiseWithRetry }; 165 | }; 166 | 167 | for (let i = 0; i < totalChunks; i++) { 168 | await uploadChunk(i); 169 | } 170 | 171 | await Promise.all(activeChunks); 172 | await finishUpload(fileId, options); 173 | } 174 | 175 | export async function uploadAllFiles(els: NodeListOf, options: BigFileUploadOptions = { ...UPLOAD_BIG_FILE_OPTIONS, ...clientWFS.bigFileUploadOptions }) { 176 | const activeUploads = new Map>(); 177 | const filesToUpload = new Map(); 178 | 179 | let failed = false; 180 | for (const el of els) { 181 | el.disabled = true; 182 | 183 | const files = el.files; 184 | if (!files || files.length === 0) { 185 | continue; 186 | } 187 | 188 | const progress = document.querySelector(`progress[data-for="${el.name}"]`) as HTMLProgressElement; 189 | const progressCallback = (loaded: number, total: number) => { 190 | if (!progress) return; 191 | progress.value = Math.round((loaded / total) * 100); 192 | }; 193 | 194 | for (const file of files) { 195 | while (activeUploads.size >= options.parallelUploads) { 196 | await Promise.race(activeUploads.values()); 197 | } 198 | 199 | if (progress) { 200 | const onActiveClasses = progress.getAttribute('data-onactive-class'); 201 | 202 | if (onActiveClasses) { 203 | const addClass = onActiveClasses.split(' ').filter(Boolean); 204 | if (addClass.length > 0) { 205 | progress.classList.add(...addClass); 206 | } 207 | } 208 | } 209 | 210 | const fileId = uuid(); 211 | if (failed) { 212 | onUploadFinished(el, file, fileId, true); 213 | continue; 214 | } 215 | 216 | const upload = uploadBigFile(fileId, file, progressCallback, options).then(() => { 217 | activeUploads.delete(file.name); 218 | filesToUpload.set(el, fileId); 219 | onUploadFinished(el, file, fileId); 220 | }).catch(() => { 221 | failed = true; 222 | onUploadFinished(el, file, fileId, true); 223 | }); 224 | 225 | activeUploads.set(file.name, upload); 226 | } 227 | } 228 | 229 | await Promise.all(activeUploads.values()); 230 | return filesToUpload; 231 | } 232 | 233 | function onUploadFinished(el: HTMLInputElement, file: File, id: string, failed?: boolean) { 234 | const inputElement = document.createElement('input'); 235 | inputElement.type = 'hidden'; 236 | inputElement.name = el.name; 237 | inputElement.value = `big-file:${JSON.stringify({ id, name: file.name, failed })}`; 238 | el.required = false; 239 | el.removeAttribute('name'); 240 | el.after(inputElement); 241 | } 242 | 243 | export function countTotalUploads(els: NodeListOf): { count: number, totalSize: number; } { 244 | let count = 0; 245 | let totalSize = 0; 246 | 247 | for (const el of els) { 248 | const files = el.files; 249 | if (!files || files.length === 0) { 250 | continue; 251 | } 252 | 253 | for (const file of files) { 254 | count++; 255 | totalSize += file.size; 256 | } 257 | } 258 | 259 | return { count, totalSize }; 260 | } 261 | 262 | export function finishFormSubmission(form: HTMLFormElement, onClick?: string) { 263 | if (onClick) { 264 | const inputElement = document.createElement('input'); 265 | inputElement.type = 'hidden'; 266 | inputElement.name = 'button-callback'; 267 | inputElement.value = onClick; 268 | form.append(inputElement); 269 | } 270 | 271 | form.submit(); 272 | } 273 | 274 | async function retry(fn: () => Promise, options: { retries: number, delay: number; } = { retries: 5, delay: 1000 }) { 275 | let attempts = 0; 276 | while (attempts < options.retries) { 277 | try { 278 | await fn(); 279 | return; 280 | } catch (error) { 281 | attempts++; 282 | if (attempts >= options.retries) { 283 | throw error; 284 | } 285 | await sleep(options.delay); 286 | } 287 | } 288 | } -------------------------------------------------------------------------------- /packages/forms/src/components/form/UploadBigFile/uploadBigFileServer.ts: -------------------------------------------------------------------------------- 1 | import fsExtra from "fs-extra/esm"; 2 | import fs from "fs/promises"; 3 | import oldFs from "fs"; 4 | import path from "path"; 5 | import z from "zod"; 6 | import os from "os"; 7 | import { validateFrom } from "../../../form-tools/csrf.js"; 8 | import { AstroGlobal } from "astro"; 9 | import { getFormValue } from "../../../form-tools/post.js"; 10 | import ThrowOverrideResponse from '../../../throw-action/throwOverrideResponse.js'; 11 | 12 | const zodValidationInfo = 13 | z.preprocess((str: any, ctx) => { 14 | try { 15 | return JSON.parse(str); 16 | } catch { 17 | ctx.addIssue({ 18 | code: z.ZodIssueCode.custom, 19 | message: "Invalid JSON", 20 | }); 21 | return z.NEVER; 22 | } 23 | }, z.object({ 24 | uploadId: z.string().uuid(), 25 | uploadSize: z.number().min(1), 26 | part: z.number().min(1), 27 | total: z.number().min(1), 28 | })); 29 | 30 | export type LoadUploadFilesOptions = { 31 | allowUpload?: (file: File, info: z.infer) => boolean | Promise; 32 | onFinished?: (fileId: string, totalSize: number) => void | Promise; 33 | maxUploadTime?: number; 34 | maxUploadSize?: number; 35 | maxDirectorySize?: number; 36 | tempDirectory: string; 37 | }; 38 | 39 | export const DEFAULT_BIG_FILE_UPLOAD_OPTIONS_SERVER: LoadUploadFilesOptions = { 40 | maxUploadTime: 1000 * 60 * 60 * 1.5, // 1.5 hour 41 | maxUploadSize: 1024 * 1024 * 1024, // 1GB 42 | maxDirectorySize: 1024 * 1024 * 1024 * 50, // 50GB 43 | tempDirectory: path.join(os.tmpdir(), "astro_forms_big_files_uploads"), 44 | }; 45 | 46 | const ACTIVE_FINISHED_UPLOADS = new Set(); 47 | 48 | async function loadUploadFiles(astro: AstroGlobal, options: Partial = {}) { 49 | const { allowUpload, onFinished, maxUploadTime, maxUploadSize, maxDirectorySize, tempDirectory } = { ...DEFAULT_BIG_FILE_UPLOAD_OPTIONS_SERVER, ...options }; 50 | if (astro.request.method !== "POST" || !await validateFrom(astro)) { 51 | return false; 52 | } 53 | 54 | if (await getFormValue(astro.request, "astroBigFileUpload") !== "true") { 55 | return false; 56 | } 57 | 58 | const hasWait = await getFormValue(astro.request, "wait"); 59 | if (hasWait) { 60 | const thisWait = String(hasWait); 61 | return Response.json({ ok: true, wait: ACTIVE_FINISHED_UPLOADS.has(thisWait) }); 62 | } 63 | 64 | 65 | await fsExtra.ensureDir(tempDirectory); 66 | await deleteOldUploads(tempDirectory, maxUploadTime); 67 | const uploadInfo = await getFormValue(astro.request, "info"); 68 | const uploadFileMayBe = await getFormValue(astro.request, "file"); 69 | 70 | const { data, success } = zodValidationInfo.safeParse(uploadInfo); 71 | if (!success || uploadFileMayBe instanceof File === false) { 72 | return Response.json({ ok: false, error: "Invalid request" }); 73 | } 74 | const uploadFile = uploadFileMayBe as File; 75 | 76 | const { uploadId, uploadSize, part, total } = data; 77 | 78 | const uploadDir = path.join(tempDirectory, 'chunks_' + uploadId); 79 | await fsExtra.ensureDir(uploadDir); 80 | 81 | const sendError = async (errorMessage: string, emptyDir = true, extraInfo?: any) => { 82 | if (emptyDir) { 83 | await fsExtra.emptyDir(uploadDir); 84 | } 85 | const errorPath = path.join(uploadDir, 'error.txt'); 86 | if (!await checkIfFileExists(errorPath)) { 87 | await fs.writeFile(path.join(uploadDir, 'error.txt'), errorMessage); 88 | } 89 | return Response.json({ ok: false, error: errorMessage, ...extraInfo }); 90 | }; 91 | 92 | if (typeof allowUpload === "function") { 93 | if (!await allowUpload(uploadFile, data)) { 94 | return await sendError("File not allowed"); 95 | } 96 | } 97 | 98 | if (uploadSize > maxUploadSize) { 99 | return await sendError("File size exceeded"); 100 | } 101 | 102 | const totalDirectorySizeWithNewUpload = (await totalDirectorySize(tempDirectory)) + part === 1 ? uploadSize : uploadFile.size; 103 | if (totalDirectorySizeWithNewUpload > maxDirectorySize) { 104 | return await sendError("Directory size exceeded"); 105 | } 106 | 107 | const newTotalSize = (await totalDirectorySize(uploadDir)) + uploadFile.size; 108 | if (newTotalSize > maxUploadSize) { 109 | return await sendError("Upload size exceeded"); 110 | } 111 | 112 | const uploadFilePath = path.join(tempDirectory, uploadId); 113 | if (await checkIfFileExists(uploadFilePath)) { 114 | return await sendError("Upload already exists"); 115 | } 116 | 117 | 118 | const chunkSavePath = path.join(uploadDir, `${part}-${total}`); 119 | if (!await checkIfFileExists(chunkSavePath)) { 120 | const buffer = await uploadFile.arrayBuffer(); 121 | await fs.writeFile(chunkSavePath, Buffer.from(buffer)); 122 | } 123 | 124 | if (part !== total) { 125 | return Response.json({ ok: true }); 126 | } 127 | 128 | const files = await fs.readdir(uploadDir); 129 | const missingChunks = []; 130 | for (let i = 1; i <= total; i++) { 131 | if (!files.includes(`${i}-${total}`)) { 132 | missingChunks.push(i); 133 | } 134 | } 135 | if (missingChunks.length > 0) { 136 | return await sendError(`Missing chunks ${missingChunks}, upload failed`, false, { missingChunks }); 137 | } 138 | 139 | (async () => { 140 | try { 141 | ACTIVE_FINISHED_UPLOADS.add(uploadId); 142 | const outputStream = oldFs.createWriteStream(uploadFilePath, { flags: 'a' }); 143 | for (let i = 1; i <= total; i++) { 144 | const fileFullPath = path.join(uploadDir, `${i}-${total}`); 145 | const inputStream = oldFs.createReadStream(fileFullPath); 146 | await new Promise((resolve, reject) => { 147 | inputStream.on("data", (chunk) => { 148 | outputStream.write(chunk); 149 | }); 150 | inputStream.on("end", resolve); 151 | inputStream.on("error", reject); 152 | }); 153 | await fsExtra.remove(fileFullPath); 154 | } 155 | await fsExtra.remove(uploadDir); 156 | 157 | await onFinished?.(uploadId, files.length); 158 | } finally { 159 | ACTIVE_FINISHED_UPLOADS.delete(uploadId); 160 | } 161 | })(); 162 | 163 | return Response.json({ ok: true, finished: true }); 164 | } 165 | 166 | export async function processBigFileUpload(astro: AstroGlobal, options: Partial = astro.locals.__formsInternalUtils.FORM_OPTIONS.forms?.bigFilesUpload?.bigFileServerOptions) { 167 | const haveFileUpload = await loadUploadFiles(astro, options); 168 | if (haveFileUpload) { 169 | throw new ThrowOverrideResponse(haveFileUpload); 170 | } 171 | } 172 | 173 | async function deleteOldUploads(tempDirectory: string, maxUploadTime: number) { 174 | const files = await fs.readdir(tempDirectory); 175 | for (const file of files) { 176 | const fullPath = path.join(tempDirectory, file); 177 | 178 | try { 179 | const stat = await fs.stat(fullPath); 180 | if (Date.now() - stat.mtime.getTime() > maxUploadTime) { 181 | await fsExtra.remove(fullPath); 182 | } 183 | } catch (error) { 184 | if (error.code !== "ENOENT") { 185 | throw error; 186 | } 187 | } 188 | } 189 | } 190 | 191 | async function totalDirectorySize(directory: string) { 192 | const files = await fs.readdir(directory); 193 | let totalSize = 0; 194 | 195 | const promises = []; 196 | for (const file of files) { 197 | const fullPath = path.join(directory, file); 198 | try { 199 | const stat = await fs.stat(fullPath); 200 | 201 | if (stat.isDirectory()) { 202 | promises.push(totalDirectorySize(fullPath)); 203 | } else { 204 | totalSize += stat.size; 205 | } 206 | } catch (error) { 207 | if (error.code !== "ENOENT") { 208 | throw error; 209 | } 210 | } 211 | } 212 | 213 | totalSize += (await Promise.all(promises)).reduce((a, b) => a + b, 0); 214 | return totalSize; 215 | } 216 | 217 | export async function checkIfFileExists(filePath: string) { 218 | try { 219 | const file = await fs.stat(filePath); 220 | return file.isFile(); 221 | } catch { 222 | return false; 223 | } 224 | } -------------------------------------------------------------------------------- /packages/forms/src/errors/AstroFormsError.ts: -------------------------------------------------------------------------------- 1 | export class AstroFormsError extends Error { 2 | } -------------------------------------------------------------------------------- /packages/forms/src/errors/MissingClickActionError.ts: -------------------------------------------------------------------------------- 1 | import { AstroFormsError } from "./AstroFormsError.js"; 2 | 3 | export class MissingClickActionError extends AstroFormsError { 4 | constructor() { 5 | super(`The click action is missing in the \`BButton\` component`); 6 | } 7 | } -------------------------------------------------------------------------------- /packages/forms/src/errors/MissingNamePropError.ts: -------------------------------------------------------------------------------- 1 | import { AstroFormsError } from "./AstroFormsError.js"; 2 | 3 | export class MissingNamePropError extends AstroFormsError { 4 | constructor(public readonly componentName: string) { 5 | super(`Name prop is required for form components, missing in ${componentName}`); 6 | } 7 | } -------------------------------------------------------------------------------- /packages/forms/src/form-tools/connectId.ts: -------------------------------------------------------------------------------- 1 | import {createHash} from "node:crypto" 2 | 3 | export function createUniqueContinuanceName(func: Function, length = 10){ 4 | const uniqueText = func.toString() + func.name; 5 | return hashString(uniqueText, length); 6 | } 7 | 8 | export function hashString(uniqueText: string, length = 10){ 9 | return createHash('md5').update(uniqueText).digest('hex').substring(0, length); 10 | } -------------------------------------------------------------------------------- /packages/forms/src/form-tools/csrf.ts: -------------------------------------------------------------------------------- 1 | import Tokens from 'csrf'; 2 | import {promisify} from 'node:util'; 3 | import {type AstroLinkHTTP, createLock} from '../utils.js'; 4 | import {getFormValue, isPost} from './post.js'; 5 | import {FORM_OPTIONS, getFormOptions} from '../settings.js'; 6 | 7 | export type CSRFSettings = { 8 | formFiled: string, 9 | sessionFiled: string 10 | } 11 | 12 | export const DEFAULT_SETTINGS: CSRFSettings = { 13 | formFiled: 'request-validation-token', 14 | sessionFiled: 'request-validation-secret' 15 | }; 16 | 17 | const tokens = new Tokens(); 18 | const createSecret = () => promisify(tokens.secret.bind(tokens))(); 19 | 20 | export async function ensureValidationSecret(astro: AstroLinkHTTP, formOptions = getFormOptions(astro)) { 21 | const currentSession = astro.locals.session; 22 | return currentSession[formOptions.csrf.sessionFiled] ??= await createSecret(); 23 | } 24 | 25 | export async function validateFrom(astro: AstroLinkHTTP) { 26 | const lock = astro.request.validateFormLock ??= createLock(); 27 | await lock.acquireAsync(); 28 | 29 | try { 30 | if (!isPost(astro) || typeof astro.request.formData.requestFormValid === 'boolean') { 31 | return astro.request.formData.requestFormValid; 32 | } 33 | 34 | const validationSecret = await ensureValidationSecret(astro); 35 | const validateToken = await getFormValue(astro.request, getFormOptions(astro).csrf.formFiled); 36 | 37 | const requestValid = validateToken && validationSecret && typeof validateToken == 'string' && 38 | tokens.verify(validationSecret, validateToken); 39 | 40 | return astro.request.formData.requestFormValid = Boolean(requestValid); 41 | } finally { 42 | lock.release(); 43 | } 44 | } 45 | 46 | export async function createFormToken(astro: AstroLinkHTTP) { 47 | const validationSecret = await ensureValidationSecret(astro); 48 | const token = tokens.create(validationSecret); 49 | 50 | return { 51 | token, 52 | filed: FORM_OPTIONS.csrf.formFiled 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /packages/forms/src/form-tools/events.ts: -------------------------------------------------------------------------------- 1 | export function addOnSubmitClickEvent(buttonId?: string | false, allProps?: {[key: string]: string}){ 2 | if(buttonId){ 3 | allProps["data-submit"] = buttonId; 4 | allProps.onkeypress = `__enterToSubmit(event)${allProps.onkeypress ? ';' + allProps.onkeypress : ''}`; 5 | } 6 | } -------------------------------------------------------------------------------- /packages/forms/src/form-tools/forms-react.ts: -------------------------------------------------------------------------------- 1 | import type {ValidRedirectStatus} from 'astro'; 2 | import {AstroLinkHTTP} from 'src/utils.js'; 3 | 4 | export default class FormsReact { 5 | public scriptToRun = ''; 6 | public overrideResponse: Response | null = null; 7 | 8 | public constructor(private _astro: AstroLinkHTTP) { 9 | } 10 | 11 | /** 12 | * Redirects the user to the given URL after the given timeout. (using `setTimeout`) 13 | * @param location 14 | * @param timeoutSec - timeout in seconds 15 | */ 16 | public redirectTimeoutSeconds(location: string, timeoutSec = 2) { 17 | this.scriptToRun += ` 18 | setTimeout(function() { 19 | window.location.href = new URL("${this._escapeParentheses(location)}", window.location.href).href; 20 | }, ${timeoutSec * 1000}); 21 | `.trim(); 22 | } 23 | 24 | /** 25 | * Redirect the user to the given URL. 26 | * @param location 27 | * @param status - redirect status code 28 | */ 29 | public redirect(location: string, status?: ValidRedirectStatus) { 30 | this.overrideResponse = new Response(null, { 31 | status: status || 302, 32 | headers: { 33 | Location: location, 34 | }, 35 | }); 36 | } 37 | 38 | /** 39 | * Update the search parameters of the current URL and return `Response` object. 40 | */ 41 | public updateSearchParams() { 42 | const url = new URL(this._astro.request.url, 'http://example.com'); 43 | const search = url.searchParams; 44 | const self = this; 45 | 46 | return { 47 | search, 48 | redirect(status?: ValidRedirectStatus) { 49 | const pathWithSearch = url.pathname.split('/').pop() + search.toString(); 50 | self.overrideResponse = new Response(null, { 51 | status: status || 302, 52 | headers: { 53 | Location: pathWithSearch, 54 | }, 55 | }); 56 | } 57 | }; 58 | } 59 | 60 | /** 61 | * Update **one** search parameter of the current URL and return `Response` object. 62 | * @param key - search parameter key 63 | * @param value - search parameter value (if `null` the parameter will be removed) 64 | * @param status - redirect status code 65 | */ 66 | public updateOneSearchParam(key: string, value?: string, status?: ValidRedirectStatus) { 67 | const {search, redirect} = this.updateSearchParams(); 68 | 69 | if (value == null) { 70 | search.delete(key); 71 | } else { 72 | search.set(key, value); 73 | } 74 | 75 | redirect(status); 76 | } 77 | 78 | /** 79 | * Prompt alert message to the user with the `window.alert` function. 80 | * @param message 81 | */ 82 | public alert(message: string) { 83 | this.callFunction('alert', message); 84 | } 85 | 86 | /** 87 | * Print a message to the client console with the `console` class. 88 | */ 89 | public console(type: keyof Console, ...messages: any[]) { 90 | if (!(type in console)) { 91 | throw new Error(`Invalid console type: ${type}`); 92 | } 93 | 94 | this.callFunction(`console.${type}`, ...messages); 95 | } 96 | 97 | /** 98 | * Print a message to the client console with the `console.log` function. 99 | */ 100 | public consoleLog(...messages: any[]) { 101 | this.console('log', ...messages); 102 | } 103 | 104 | /** 105 | * Call a client side function with the given arguments. 106 | * @warning - this is **not** a safe function, make sure to validate the arguments before calling this function. 107 | */ 108 | public callFunction(func: string, ...args: any[]) { 109 | this.scriptToRun += ` 110 | ${func}(...${JSON.stringify(args)}); 111 | `.trim(); 112 | } 113 | 114 | private _escapeParentheses(str: string) { 115 | return str.replace(/"/g, '\\"'); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/forms/src/form-tools/post.ts: -------------------------------------------------------------------------------- 1 | import {type AstroLinkHTTP, createLock, type ExtendedRequest} from '../utils.js'; 2 | import {validateFrom} from './csrf.js'; 3 | 4 | 5 | export function isPost(astro: {request: Request}){ 6 | return astro.request.method === "POST"; 7 | } 8 | 9 | export async function parseFormData(request: ExtendedRequest): Promise { 10 | const lock = request.formDataLock ??= createLock(); 11 | await lock.acquireAsync(); 12 | 13 | try { 14 | const formData = await request.formData(); 15 | request.formData = () => Promise.resolve(formData); 16 | 17 | return formData; 18 | } finally { 19 | lock.release(); 20 | } 21 | } 22 | 23 | export async function getFormValue(request: ExtendedRequest, key: string): Promise { 24 | const data = await parseFormData(request); 25 | return data.get(key); 26 | } 27 | 28 | export async function getFormMultiValue(request: ExtendedRequest, key: string): Promise { 29 | const data = await parseFormData(request); 30 | return data.getAll(key); 31 | } 32 | 33 | export async function validateAction(astro: AstroLinkHTTP, formKey: string, value: string){ 34 | return await validateFrom(astro) && await getFormValue(astro.request, formKey) == value; 35 | } 36 | -------------------------------------------------------------------------------- /packages/forms/src/index.ts: -------------------------------------------------------------------------------- 1 | import astroMiddleware from './middleware.js'; 2 | 3 | export { 4 | astroMiddleware as default 5 | }; -------------------------------------------------------------------------------- /packages/forms/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "fs/promises"; 2 | import { refactorCodeInlineRenderComponent } from "./integration/codeTransform.js"; 3 | 4 | export default { 5 | name: '@astro-utils/forms', 6 | hooks: { 7 | 'astro:config:setup'({config, command}) { 8 | if(!command) return; 9 | config.vite ??= {}; 10 | config.vite.plugins ??= []; 11 | 12 | config.vite.plugins.push({ 13 | name: 'astro-utils-dev', 14 | async transform(code: string, id: string) { 15 | if(code.includes('class RenderTemplateResult')){ 16 | code = refactorCodeInlineRenderComponent(code); 17 | if(id.includes("/node_modules/astro/")){ 18 | await writeFile(id, code); 19 | } 20 | } 21 | 22 | if (id.endsWith('node_modules/vite/dist/client/client.mjs')) { 23 | return code.replace(/\blocation\.reload\(\)(([\s;])|\b)/g, "window.open(location.href, '_self')$1") 24 | } 25 | 26 | return code; 27 | } 28 | }); 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /packages/forms/src/integration/codeTransform.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | const expRenders = this.expressions.map(exp => { 4 | return renderToBufferDestination(bufferDestination => { 5 | if (exp || exp === 0) { 6 | return renderChild(bufferDestination, exp); 7 | } 8 | }); 9 | }); 10 | */ 11 | const ASYNC_RENDERS_REGEX = /const\s+expRenders\s*=\s*this\.expressions\.map\s*\(\s*\(?exp\)?\s*=>\s*{\s*return\s+renderToBufferDestination\s*\(\s*\(?bufferDestination\)?\s*=>\s*{\s*if\s*\(\s*exp\s*\|\|\s*exp\s*===\s*0\s*\)\s*{\s*return\s+renderChild\s*\(\s*bufferDestination\s*,\s*exp\s*\);\s*}\s*}\s*\);\s*}\s*\);/gs; 12 | 13 | const SYNC_RENDERS_CODE = ` 14 | const expRenders = []; 15 | for (const exp of this.expressions) { 16 | const promise = renderToBufferDestination(bufferDestination => { 17 | if (exp || exp === 0) { 18 | return renderChild(bufferDestination, exp); 19 | } 20 | }); 21 | 22 | await promise.renderPromise; 23 | expRenders.push(promise); 24 | } 25 | `; 26 | 27 | export function refactorCodeInlineRenderComponent(sourceCode: string): string { 28 | return sourceCode.replace(ASYNC_RENDERS_REGEX, SYNC_RENDERS_CODE); 29 | } -------------------------------------------------------------------------------- /packages/forms/src/jwt-session.ts: -------------------------------------------------------------------------------- 1 | import {FORM_OPTIONS} from './settings.js'; 2 | import type {AstroLinkHTTP} from './utils.js'; 3 | import jwt from 'jsonwebtoken'; 4 | import cookie from 'cookie'; 5 | import {deepStrictEqual} from 'assert'; 6 | 7 | export class JWTSession { 8 | private stringifyCookie: string 9 | private lastData = {}; 10 | sessionData: any = {}; 11 | 12 | constructor(private cookies: AstroLinkHTTP['cookies']) { 13 | this.loadData(); 14 | } 15 | 16 | private loadData() { 17 | const cookieContent = this.cookies.get(FORM_OPTIONS.session.cookieName)?.value; 18 | if (!cookieContent) return; 19 | 20 | try { 21 | this.sessionData = (jwt.verify(cookieContent, FORM_OPTIONS.secret)).session || {}; 22 | this.lastData = structuredClone(this.sessionData); 23 | } catch { } 24 | } 25 | 26 | private save() { 27 | try { 28 | deepStrictEqual(this.sessionData, this.lastData); 29 | } catch { 30 | const token = jwt.sign({session: this.sessionData}, FORM_OPTIONS.secret, { 31 | expiresIn: FORM_OPTIONS.session.cookieOptions.maxAge 32 | }); 33 | 34 | this.stringifyCookie = cookie.serialize(FORM_OPTIONS.session.cookieName, token, FORM_OPTIONS.session.cookieOptions); 35 | } 36 | } 37 | 38 | setCookieHeader(headers: Headers) { 39 | this.save(); 40 | if(this.stringifyCookie){ 41 | headers.set('Set-Cookie', this.stringifyCookie); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/forms/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { APIContext, MiddlewareHandler, MiddlewareNext } from 'astro'; 2 | import { DEFAULT_SETTINGS as DEFAULT_SETTINGS_CSRF, ensureValidationSecret } from './form-tools/csrf.js'; 3 | import { JWTSession } from './jwt-session.js'; 4 | import { FORM_OPTIONS, type FormsSettings } from './settings.js'; 5 | import { v4 as uuid } from 'uuid'; 6 | import objectAssignDeep from 'object-assign-deep'; 7 | import FormsReact from './form-tools/forms-react.js'; 8 | import ThrowOverrideResponse from './throw-action/throwOverrideResponse.js'; 9 | import { DEFAULT_BIG_FILE_UPLOAD_OPTIONS_SERVER } from './components/form/UploadBigFile/uploadBigFileServer.js'; 10 | 11 | const DEFAULT_FORM_OPTIONS: FormsSettings = { 12 | csrf: DEFAULT_SETTINGS_CSRF, 13 | forms: { 14 | viewStateFormFiled: '__view-state', 15 | bigFilesUpload: { 16 | bigFileServerOptions: DEFAULT_BIG_FILE_UPLOAD_OPTIONS_SERVER 17 | } 18 | }, 19 | session: { 20 | cookieName: 'session', 21 | cookieOptions: { 22 | httpOnly: true, 23 | sameSite: "lax", 24 | maxAge: 1000 * 60 * 60 * 24 * 7, 25 | path: '/' 26 | } 27 | }, 28 | secret: uuid(), 29 | logs: (type, message) => { 30 | console[type](message); 31 | }, 32 | }; 33 | 34 | export default function astroForms(settings: Partial = {}) { 35 | objectAssignDeep(FORM_OPTIONS, DEFAULT_FORM_OPTIONS, settings); 36 | 37 | return async function onRequest({ locals, request, cookies }: APIContext, next: MiddlewareNext) { 38 | const likeAstro = { locals, request, cookies }; 39 | const session = new JWTSession(cookies); 40 | locals.session = session.sessionData; 41 | locals.forms = new FormsReact(likeAstro); 42 | 43 | locals.__formsInternalUtils = { 44 | FORM_OPTIONS: FORM_OPTIONS, 45 | bindFormCounter: 0 46 | }; 47 | 48 | await ensureValidationSecret(likeAstro); 49 | try { 50 | const response = await next(); 51 | 52 | const isHTML = response.headers.get('Content-Type')?.includes('text/html'); 53 | if (locals.webFormOff || !isHTML) { 54 | return response; 55 | } 56 | 57 | const content = await response.text(); 58 | const newResponse = locals.forms.overrideResponse || new Response(content, response); 59 | if(!(newResponse instanceof Response)) { 60 | throw new Error('Astro.locals.forms.overrideResponse must be a Response instance'); 61 | } 62 | 63 | session.setCookieHeader(newResponse.headers); 64 | 65 | return newResponse; 66 | } catch (error: any) { 67 | if (!(error instanceof ThrowOverrideResponse)) { 68 | throw error; 69 | } 70 | 71 | const newResponse = error.response ?? locals.forms.overrideResponse ?? new Response(error.message, { status: 500 }); 72 | if(!(newResponse instanceof Response)) { 73 | throw new Error('ThrowOverrideResponse.response must be a Response instance'); 74 | } 75 | 76 | session.setCookieHeader(newResponse.headers); 77 | return newResponse; 78 | } 79 | } as MiddlewareHandler; 80 | } 81 | -------------------------------------------------------------------------------- /packages/forms/src/settings.ts: -------------------------------------------------------------------------------- 1 | import { BigFileUploadOptions } from './components/form/UploadBigFile/uploadBigFileClient.js'; 2 | import { LoadUploadFilesOptions } from './components/form/UploadBigFile/uploadBigFileServer.js'; 3 | import type {CSRFSettings} from './form-tools/csrf.js'; 4 | import {AstroLinkHTTP} from './utils.js'; 5 | 6 | export type FormsSettings = { 7 | csrf?: CSRFSettings 8 | forms?: { 9 | viewStateFormFiled?: string 10 | bigFilesUpload?: { 11 | bigFileClientOptions?: Partial; 12 | bigFileServerOptions?: Partial; 13 | } 14 | } 15 | session?: { 16 | cookieName?: string 17 | cookieOptions?: { 18 | httpOnly?: boolean 19 | sameSite?: boolean | 'lax' | 'strict' | 'none' 20 | maxAge?: number 21 | path?: string 22 | secure?: boolean 23 | } 24 | }, 25 | secret?: string, 26 | logs?: (type: 'warn' | 'error' | 'log', message: string) => void 27 | } 28 | 29 | export const FORM_OPTIONS: FormsSettings = {} as any; 30 | 31 | export function getFormOptions(Astro: AstroLinkHTTP) { 32 | return Astro.locals?.__formsInternalUtils?.FORM_OPTIONS ?? FORM_OPTIONS; 33 | } 34 | -------------------------------------------------------------------------------- /packages/forms/src/throw-action/throw-action.ts: -------------------------------------------------------------------------------- 1 | export default class ThrowAction extends Error { 2 | } -------------------------------------------------------------------------------- /packages/forms/src/throw-action/throwOverrideResponse.ts: -------------------------------------------------------------------------------- 1 | import ThrowAction from "./throw-action.js"; 2 | 3 | export default class ThrowOverrideResponse extends ThrowAction { 4 | public response?: Response | null; 5 | /** 6 | * Override the response with a new one. 7 | * 8 | * If no `Response` is provided, will be use the response stored in `locals.forms.overrideResponse`. 9 | * 10 | * If no `Response` is stored in `locals.forms.overrideResponse`, will be return the message with error code 500. 11 | * @param response - The new response to return. 12 | * @param message - The error message to show (if no response is provided / error catch). 13 | */ 14 | constructor(response?: Response | null, message = 'An error occurred, please try again later.') { 15 | super(message); 16 | this.response = response; 17 | } 18 | } -------------------------------------------------------------------------------- /packages/forms/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type {AstroGlobal} from 'astro'; 2 | import {FormsSettings} from './settings.js'; 3 | import AwaitLockDefault from 'await-lock'; 4 | import FormsReact from './form-tools/forms-react.js'; 5 | 6 | export function createLock(): InstanceType { 7 | if ('default' in AwaitLockDefault) { 8 | return new AwaitLockDefault.default(); 9 | } 10 | 11 | return new (AwaitLockDefault as any)(); 12 | } 13 | 14 | export type ExtendedRequest = AstroGlobal['request'] & { 15 | formDataLock?: ReturnType 16 | validateFormLock?: ReturnType 17 | formData: (Request['formData'] | (() => FormData | Promise)) & { 18 | requestFormValid?: boolean 19 | } 20 | } 21 | 22 | export interface AstroLinkHTTP { 23 | request: ExtendedRequest; 24 | cookies: AstroGlobal['cookies'] 25 | locals: AstroGlobal['locals']; 26 | } 27 | 28 | declare global { 29 | export namespace App { 30 | interface Locals { 31 | /** 32 | * @internal 33 | */ 34 | __formsInternalUtils: { 35 | FORM_OPTIONS: FormsSettings; 36 | bindFormCounter: number; 37 | }; 38 | forms: FormsReact; 39 | webFormOff?: boolean; 40 | session: { 41 | [key: string]: any; 42 | }; 43 | } 44 | } 45 | } 46 | 47 | export type ModifyDeep> = { 48 | [K in keyof A | keyof B]: // For all keys in A and B: 49 | K extends keyof A // ───┐ 50 | ? K extends keyof B // ───┼─ key K exists in both A and B 51 | ? A[K] extends AnyObject // │ ┴──┐ 52 | ? B[K] extends AnyObject // │ ───┼─ both A and B are objects 53 | ? ModifyDeep // │ │ └─── We need to go deeper (recursively) 54 | : B[K] // │ ├─ B is a primitive 🠆 use B as the final type (new type) 55 | : B[K] // │ └─ A is a primitive 🠆 use B as the final type (new type) 56 | : A[K] // ├─ key only exists in A 🠆 use A as the final type (original type) 57 | : B[K] // └─ key only exists in B 🠆 use B as the final type (new type) 58 | } 59 | 60 | type AnyObject = Record 61 | 62 | // This type is here only for some intellisense for the overrides object 63 | type DeepPartialAny = { 64 | /** Makes each property optional and turns each leaf property into any, allowing for type overrides by narrowing any. */ 65 | [P in keyof T]?: T[P] extends AnyObject ? DeepPartialAny : any 66 | } 67 | -------------------------------------------------------------------------------- /packages/forms/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "noImplicitAny": false, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "sourceMap": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "allowSyntheticDefaultImports": true, 12 | "outDir": "dist", 13 | "rootDir": "src", 14 | "declaration": true, 15 | "skipLibCheck": true, 16 | "stripInternal": true, 17 | "baseUrl": "." 18 | }, 19 | "include": [ 20 | "src/**/**.ts" 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------