├── .changeset ├── README.md └── config.json ├── .github ├── ISSUE_TEMPLATE │ ├── ---01-bug-report.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── biome.json ├── examples ├── nextjs-app-router │ ├── .gitignore │ ├── README.md │ ├── app │ │ ├── Signup.tsx │ │ ├── action.ts │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── utils.ts │ ├── components │ │ └── Form.tsx │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── tailwind.config.ts │ └── tsconfig.json └── playground │ ├── .gitignore │ ├── .vscode │ ├── extensions.json │ └── launch.json │ ├── CHANGELOG.md │ ├── README.md │ ├── astro.config.mjs │ ├── package.json │ ├── public │ └── favicon.svg │ ├── src │ ├── components │ │ ├── Layout.astro │ │ ├── Sanitize.tsx │ │ ├── Wait.astro │ │ ├── preact │ │ │ ├── Form.tsx │ │ │ └── Signup.tsx │ │ ├── react │ │ │ ├── Form.tsx │ │ │ └── Signup.tsx │ │ └── solid-js │ │ │ ├── Form.tsx │ │ │ └── Signup.tsx │ ├── env.d.ts │ └── pages │ │ ├── array.astro │ │ ├── index.astro │ │ └── stream.astro │ ├── tailwind.config.mjs │ └── tsconfig.json ├── package.json ├── packages ├── form │ ├── CHANGELOG.md │ ├── README.md │ ├── components │ │ ├── FormName.astro │ │ └── index.ts │ ├── package.json │ ├── src │ │ ├── cli.ts │ │ ├── index.ts │ │ ├── middleware.ts │ │ └── module.ts │ ├── templates │ │ ├── preact │ │ │ ├── Form.tsx │ │ │ ├── env.d.ts │ │ │ └── tsconfig.json │ │ ├── react-external │ │ │ ├── Form.tsx │ │ │ └── tsconfig.json │ │ ├── react │ │ │ ├── Form.tsx │ │ │ ├── env.d.ts │ │ │ └── tsconfig.json │ │ └── solid-js │ │ │ ├── Form.tsx │ │ │ ├── env.d.ts │ │ │ └── tsconfig.json │ ├── tsconfig.json │ └── types.d.ts ├── frame │ ├── CHANGELOG.md │ ├── components │ │ ├── Frame.astro │ │ ├── env.d.ts │ │ ├── index.ts │ │ └── tsconfig.json │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── index.ts │ │ └── module.ts │ └── tsconfig.json ├── query │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── ambient.d.ts │ ├── e2e │ │ ├── basic.spec.ts │ │ ├── utils.ts │ │ └── view-transitions.spec.ts │ ├── fixtures │ │ ├── basic │ │ │ ├── .gitignore │ │ │ ├── astro.config.mjs │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── components │ │ │ │ │ ├── Counter.astro │ │ │ │ │ └── WithContext.astro │ │ │ │ ├── env.d.ts │ │ │ │ └── pages │ │ │ │ │ ├── button.astro │ │ │ │ │ ├── effect.astro │ │ │ │ │ ├── index.astro │ │ │ │ │ ├── multi-counter.astro │ │ │ │ │ ├── scope-prop.astro │ │ │ │ │ └── server-data.astro │ │ │ └── tsconfig.json │ │ └── view-transitions │ │ │ ├── .astro │ │ │ └── settings.json │ │ │ ├── astro.config.mjs │ │ │ ├── package.json │ │ │ ├── src │ │ │ ├── components │ │ │ │ └── Counter.astro │ │ │ ├── env.d.ts │ │ │ ├── layouts │ │ │ │ └── Layout.astro │ │ │ └── pages │ │ │ │ ├── page-1.astro │ │ │ │ └── page-2.astro │ │ │ └── tsconfig.json │ ├── package.json │ ├── playwright.config.ts │ ├── src │ │ ├── effect.ts │ │ ├── env.d.ts │ │ ├── index.ts │ │ ├── internal.server.ts │ │ └── internal.ts │ ├── test │ │ └── data-target.test.js │ ├── tsconfig.json │ └── typescript ├── scope │ ├── CHANGELOG.md │ ├── README.md │ ├── ambient.d.ts │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json └── stream │ ├── CHANGELOG.md │ ├── README.md │ ├── components │ ├── Suspense.astro │ ├── env.d.ts │ ├── index.ts │ ├── tsconfig.json │ └── types.ts │ ├── package.json │ ├── src │ ├── env.d.ts │ ├── index.ts │ ├── middleware.ts │ └── utils.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json ├── turbo.json └── www ├── .gitignore ├── astro.config.mjs ├── package.json ├── public ├── assets │ ├── astro-partial-rendering-demo.mp4 │ └── simple-stream-intro.mov └── favicon.svg ├── src ├── content │ ├── config.ts │ └── docs │ │ ├── form │ │ ├── client.md │ │ ├── index.mdx │ │ └── parse.md │ │ ├── index.md │ │ ├── query.mdx │ │ ├── scope.md │ │ └── stream.md ├── env.d.ts └── styles │ └── custom.css └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "bholmesdev/simple-stack" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": [] 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---01-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: Report an issue or possible bug 3 | labels: [] 4 | assignees: [] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## Quick Checklist 10 | Thank you for taking the time to file a bug report! Please fill out this form as completely as possible. 11 | 12 | ✅ I am using the **latest version of Astro** and all plugins. 13 | ✅ I am using a version of Node that Astro supports (`>=18.14.1`) 14 | - type: textarea 15 | id: astro-info 16 | attributes: 17 | label: Astro Info 18 | description: Run the command `astro info` in your terminal and paste the output here. Please review the data before submitting in case there is any sensitive information you don't want to share. 19 | render: block 20 | validations: 21 | required: true 22 | - type: input 23 | id: browser 24 | attributes: 25 | label: If this issue only occurs in one browser, which browser is a problem? 26 | placeholder: Chrome, Firefox, Safari 27 | - type: textarea 28 | id: bug-description 29 | attributes: 30 | label: Describe the Bug 31 | description: A clear and concise description of what the bug is. 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: bug-expectation 36 | attributes: 37 | label: What's the expected result? 38 | description: Describe what you expect to happen. 39 | validations: 40 | required: true 41 | - type: input 42 | id: bug-reproduction 43 | attributes: 44 | label: Link to Minimal Reproducible Example 45 | description: 'Use [astro.new](https://astro.new) to create a minimal reproduction of the problem. **A minimal reproduction is required** so that others can help debug your issue. If a report is vague (e.g. just a generic error message) and has no reproduction, it may be auto-closed. Not sure how to create a minimal example? [Read our guide](https://docs.astro.build/en/guides/troubleshooting/#creating-minimal-reproductions)' 46 | placeholder: 'https://stackblitz.com/abcd1234' 47 | validations: 48 | required: true 49 | - type: checkboxes 50 | id: will-pr 51 | attributes: 52 | label: Participation 53 | options: 54 | - label: I am willing to submit a pull request for this issue. 55 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🙌 Feedback and support 4 | url: https://wtw.dev/chat 5 | about: 'This issue tracker is not for feature requests or support questions. Join us on Discord!' 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Changes 2 | 3 | - Resolves #ISSUE_NUMBER 4 | - What does this change? 5 | - Be short and concise. Bullet points can help! 6 | - Before/after screenshots can help as well. 7 | 8 | - [ ] I have read [the "Making a Pull Request" section](https://github.com/bholmesdev/simple-stack/blob/main/CONTRIBUTING.md#making-a-pull-request) before making this PR. 9 | 10 | ## Testing 11 | 12 | 13 | 14 | 15 | ## Docs 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [20] 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: 8 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'pnpm' 25 | - name: Install dependencies 26 | run: pnpm install 27 | 28 | - name: Build packages 29 | run: pnpm build 30 | 31 | - name: Lint check 32 | run: pnpm check 33 | test: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | matrix: 37 | node-version: [20] 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v2 41 | - uses: pnpm/action-setup@v2 42 | with: 43 | version: 8 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | cache: 'pnpm' 49 | - name: Install dependencies 50 | run: pnpm install 51 | 52 | - name: Test packages 53 | run: pnpm test 54 | e2e: 55 | timeout-minutes: 60 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: actions/setup-node@v4 60 | with: 61 | node-version: lts/* 62 | - name: Install dependencies 63 | run: npm install -g pnpm && pnpm install 64 | - name: Install Playwright Browsers 65 | run: pnpm exec playwright install --with-deps 66 | - name: Run Playwright tests 67 | run: pnpm e2e 68 | - uses: actions/upload-artifact@v4 69 | if: always() 70 | with: 71 | name: playwright-report 72 | path: playwright-report/ 73 | retention-days: 30 74 | 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [20] 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | - uses: pnpm/action-setup@v2 21 | with: 22 | version: 8 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'pnpm' 28 | - name: Install dependencies 29 | run: pnpm install 30 | 31 | - name: Build packages 32 | run: pnpm run build 33 | 34 | - name: Create Release Pull Request or NPM Publish 35 | id: changesets 36 | uses: changesets/action@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | with: 41 | # Note: pnpm install after versioning is necessary to refresh lockfile 42 | version: pnpm run version 43 | publish: pnpm exec changeset publish 44 | commit: "[ci] release" 45 | title: "[ci] release" 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | 4 | # npm 5 | npm-debug.log* 6 | *.tgz 7 | 8 | # typescript build 9 | *.tsbuildinfo 10 | dist/ 11 | 12 | # turborepo 13 | .turbo -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "[astro]": { 4 | "editor.defaultFormatter": "astro-build.astro-vscode" 5 | }, 6 | "[json]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hey@bholmes.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to simple stack 2 | 3 | Hey contributor 👋 Welcome to simple stack! We're a young project open to contributions of any kind, from fixes to features to documentation and tooling. 4 | 5 | ## Repository overview 6 | 7 | Simple stack is a **monorepo** containing a suite of packages built for Astro. These are the most important directories: 8 | 9 | ```bash 10 | # packages including `simple-stack-form`, `simple-stack-partial`, etc 11 | packages/* 12 | # Astro projects that use and test these packages 13 | examples/* 14 | ``` 15 | 16 | All source code is written in TypeScript, and components may use a variety of frameworks (Astro, React, Vue, Svelte, etc). 17 | 18 | ## What to ask before making a PR 19 | 20 | Before submitting a pull request, we suggest asking: 21 | 22 | 1. **Have I checked the community discord and existing issue logs first?** We use GitHub issues and [discord discussions](https://wtw.dev/chat) to collaborate on changes. By opening an issue or starting a Discord thread, you can get early feedback on your problem before diving into a solution. 23 | 24 | 2. **Have I reviewed the existing documentation?** You may find an answer to your request in the package README. In fact, you might find room to improve our docs for future users with a similar problem. 25 | 26 | If the answer is **yes** to both and you have a PR to contribute, get to it! 27 | 28 | ## Prerequisites 29 | 30 | New contributors need the following tools: 31 | 32 | - [Node 18.14+](https://nodejs.org/en/download) for building packages and example sites. 33 | - [pnpm](https://pnpm.io/) to install dependencies. We prefer pnpm since it runs quickly and offers a [robust workspace feature](https://pnpm.io/workspaces) to manage monorepo dependencies. 34 | 35 | ## Initial setup 36 | 37 | To get started, clone this repository and install dependencies from the project root: 38 | 39 | ```bash 40 | git clone https://github.com/bholmesdev/simple-stack.git 41 | cd simple-stack 42 | pnpm install 43 | ``` 44 | 45 | ### Linting and formatting 46 | 47 | This project uses [Biome](https://biomejs.dev/) to lint and format code across packages. If you use VS Code, this repository includes a `.vscode/` directory to preconfigure your default linter. [Visit the editor integration docs](https://biomejs.dev/guides/integrate-in-editor/) to enable linting in your editor of choice. 48 | 49 | To run the linter manually, you can use the following commands at the project root: 50 | 51 | ```bash 52 | # lint and format all files in packages/* 53 | pnpm check 54 | # apply lint and format fixes to all files in packages/* 55 | pnpm check:apply 56 | # run `lint` or `format` steps individually 57 | pnpm lint 58 | pnpm lint:apply 59 | pnpm format 60 | pnpm format:apply 61 | ``` 62 | 63 | ## Development 64 | 65 | You may want live compilation for your TypeScript code while working. We use [turborepo](https://turbo.build/) to build packages for development and production. For live reloading, run the following at the project root: 66 | 67 | ```bash 68 | pnpm dev 69 | ``` 70 | 71 | This will build all `packages/*` entries and listen for changes. 72 | 73 | To test your code, you can run any one of our Astro projects under `examples/*`. First open a second terminal, navigate to that example, and run the same `pnpm dev` command. You may need to kill and restart this server to see your package edits take effect. 74 | 75 | You can also run packages _and_ examples simultaneously: 76 | 77 | ```bash 78 | pnpm dev:all 79 | ``` 80 | 81 | However, we've found console logs are harder to read using this approach. Use whichever you prefer! 82 | 83 | ## Making a Pull Request 84 | 85 | When requesting a change to a simple stack package, be sure to add a [changeset](https://github.com/changesets/changesets?tab=readme-ov-file#how-do-we-do-that). This is used to specify the version bump for the package, and to update the package CHANGELOG with a description of the change. Note that changes to examples (`examples/*`) do not need changesets. 86 | 87 | To create a new changeset, run the following command: 88 | 89 | ```bash 90 | pnpm exec changeset 91 | ``` 92 | 93 | Then, provide a brief description of the change. Be sure to focus on the _user-facing change_ (the what), rather than describing _technical implementation_ (the how). 94 | 95 | ❌ Apply response headers using the `{ headers }` object in the middleware function. 96 | ✅ Fix the missing `text/html` header for Astro routes. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Benjamin Holmes @bholmesdev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | Source: -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Stack 🌱 2 | 3 | **Web apps made simple.** 4 | 5 | Simple stack is a suite of tools built for [Astro](https://astro.build) to simplify your workflow. 6 | 7 | ## Packages 8 | 9 | Simple stack offers a growing collection of packages: 10 | 11 | - **[🔎 Simple query](https://simple-stack.dev/query):** The simplest way to query elements from your Astro component. 12 | - **[🌊 Simple stream](https://simple-stack.dev/stream):** Suspend Astro components with fallback content. 13 | - **[🧘‍♂️ Simple form](https://simple-stack.dev/form):** A full stack solution to validate forms with your client framework of choice. 14 | - **[🔎 Simple scope](https://simple-stack.dev/scope):** A scoped ID generator for any file you're in. Perfect for form label IDs and query selectors. 15 | 16 | ## Get involved 17 | 18 | Simple stack is open to contributors! You can [join the discord](https://wtw.dev/chat) to share ideas, and [read the contributing guide](https://github.com/bholmesdev/simple-stack/blob/main/CONTRIBUTING.md) to make your first pull request. 19 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "linter": { 4 | "rules": { 5 | "recommended": true, 6 | "a11y": { 7 | "noSvgWithoutTitle": "warn", 8 | "useAltText": "warn", 9 | "useButtonType": "warn", 10 | "useKeyWithClickEvents": "warn" 11 | }, 12 | "correctness": { 13 | "useExhaustiveDependencies": "warn" 14 | }, 15 | "performance": { 16 | "noDelete": "warn" 17 | }, 18 | "style": { 19 | "all": false 20 | }, 21 | "suspicious": { 22 | "noArrayIndexKey": "warn", 23 | "noAssignInExpressions": "warn", 24 | "noEmptyInterface": "off", 25 | "noExplicitAny": "warn", 26 | "noConsoleLog": "warn" 27 | } 28 | } 29 | }, 30 | "organizeImports": { 31 | "enabled": true 32 | }, 33 | "vcs": { 34 | "clientKind": "git", 35 | "enabled": true, 36 | "useIgnoreFile": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project demos simple:form in a non-Astro project. 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | pnpm dev 9 | ``` 10 | 11 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 12 | 13 | ## Breakdown 14 | 15 | This repository demos how React server actions can be used with simple form. Here are the most significant files: 16 | 17 | ```bash 18 | components/ 19 | # form component scaffolded with `simple-form create`, modified to use server actions 20 | Form.tsx 21 | app/ 22 | # A signup form with live validation 23 | Signup.tsx 24 | # includes the form validator and renders a Signup form 25 | page.tsx 26 | # server action that validates form data server-side and logs the result 27 | action.ts 28 | ``` -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/Signup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signup } from "./page"; 4 | import { signupSubmit } from "./action"; 5 | import { useFormState } from "./utils"; 6 | import { Form, Input } from "../components/Form"; 7 | 8 | export default function Signup() { 9 | const [state, formAction] = useFormState(signupSubmit); 10 | 11 | return ( 12 | <> 13 | {state?.data && ( 14 |
18 | Success! 19 | 20 | You have successfully submitted the form. 21 | 22 |
23 | )} 24 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 45 |
46 | 47 | ); 48 | } 49 | 50 | function FormGroup({ children }: { children: React.ReactNode }) { 51 | return
{children}
; 52 | } 53 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { validateForm } from "simple-stack-form/module"; 4 | import { signup } from "./page"; 5 | 6 | export async function signupSubmit(formData: FormData) { 7 | const parsed = await validateForm({ formData, validator: signup.validator }); 8 | if (parsed.data) { 9 | console.info("parsed.data", parsed.data); 10 | } 11 | return parsed; 12 | } 13 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bholmesdev/simple-stack/71994d8bbbcb5951583f5db0fd8adfdd22e76991/examples/nextjs-app-router/app/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import './globals.css' 4 | 5 | const inter = Inter({ subsets: ['latin'] }) 6 | 7 | export const metadata: Metadata = { 8 | title: 'Create Next App', 9 | description: 'Generated by create next app', 10 | } 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { createForm } from "simple-stack-form/module"; 2 | import { z } from "zod"; 3 | import Signup from "./Signup"; 4 | 5 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 6 | 7 | export const signup = createForm({ 8 | username: z 9 | .string() 10 | .min(2) 11 | .refine(async (s) => { 12 | await sleep(400); 13 | return s !== "admin"; 14 | }), 15 | optIn: z.boolean().optional(), 16 | }); 17 | 18 | export default function Home() { 19 | return ( 20 |
21 |
22 |

23 | Next.js full stack forms 💪 24 |

25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/app/utils.ts: -------------------------------------------------------------------------------- 1 | import { validateForm } from "simple-stack-form/module"; 2 | import { useFormState as useForStateBase } from "react-dom"; 3 | 4 | type ValidateFormResult = Awaited> | null; 5 | export function useFormState( 6 | action: (formData: FormData) => Promise, 7 | ) { 8 | return useForStateBase( 9 | async (_: ValidateFormResult, formData: FormData) => action(formData), 10 | null, 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/components/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { 4 | type ComponentProps, 5 | createContext, 6 | useContext, 7 | useState, 8 | } from "react"; 9 | import { 10 | type FieldErrors, 11 | type FormState, 12 | type FormValidator, 13 | formNameInputProps, 14 | getInitialFormState, 15 | toSetValidationErrors, 16 | toValidateField, 17 | validateForm, 18 | } from "simple-stack-form/module"; 19 | import { twMerge } from "tailwind-merge"; 20 | 21 | export function useCreateFormContext( 22 | validator: FormValidator, 23 | fieldErrors?: FieldErrors, 24 | ) { 25 | const initial = getInitialFormState({ validator, fieldErrors }); 26 | const [formState, setFormState] = useState(initial); 27 | return { 28 | value: formState, 29 | set: setFormState, 30 | setValidationErrors: toSetValidationErrors(setFormState), 31 | validateField: toValidateField(setFormState), 32 | }; 33 | } 34 | 35 | export function useFormContext() { 36 | const formContext = useContext(FormContext); 37 | if (!formContext) { 38 | throw new Error( 39 | "Form context not found. `useFormContext()` should only be called from children of a
component.", 40 | ); 41 | } 42 | return formContext; 43 | } 44 | 45 | type FormContextType = ReturnType; 46 | 47 | const FormContext = createContext(undefined); 48 | 49 | export function Form({ 50 | children, 51 | validator, 52 | fieldErrors, 53 | name, 54 | action, 55 | ...formProps 56 | }: { 57 | validator: FormValidator; 58 | fieldErrors?: FieldErrors; 59 | action: (formData: FormData) => void; 60 | } & Omit, "method" | "onSubmit">) { 61 | const formContext = useCreateFormContext(validator, fieldErrors); 62 | 63 | return ( 64 | 65 | { 69 | e.preventDefault(); 70 | e.stopPropagation(); 71 | const formData = new FormData(e.currentTarget); 72 | formContext.set((formState) => ({ 73 | ...formState, 74 | isSubmitPending: true, 75 | submitStatus: "validating", 76 | })); 77 | const parsed = await validateForm({ formData, validator }); 78 | if (parsed.data) { 79 | formContext.set((formState) => ({ 80 | ...formState, 81 | isSubmitPending: false, 82 | submitStatus: "submitting", 83 | })); 84 | return action(formData); 85 | } 86 | 87 | formContext.setValidationErrors(parsed.fieldErrors); 88 | }} 89 | > 90 | {name ? : null} 91 | {children} 92 | 93 | 94 | ); 95 | } 96 | 97 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 98 | const formContext = useFormContext(); 99 | const fieldState = formContext.value.fields[inputProps.name]; 100 | if (!fieldState) { 101 | throw new Error( 102 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 103 | ); 104 | } 105 | 106 | const { hasErroredOnce, validationErrors, validator } = fieldState; 107 | return ( 108 | <> 109 | { 112 | const value = e.target.value; 113 | if (value === "") return; 114 | formContext.validateField(inputProps.name, value, validator); 115 | }} 116 | onChange={async (e) => { 117 | if (!hasErroredOnce) return; 118 | const value = e.target.value; 119 | formContext.validateField(inputProps.name, value, validator); 120 | }} 121 | {...inputProps} 122 | /> 123 | {validationErrors?.map((e) => ( 124 |

{e}

125 | ))} 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-app-router", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "14.0.4", 13 | "react": "^18", 14 | "react-dom": "^18", 15 | "simple-stack-form": "^0.1.7", 16 | "tailwind-merge": "^2.2.0", 17 | "zod": "^3.22.4" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^20", 21 | "@types/react": "^18", 22 | "@types/react-dom": "^18", 23 | "autoprefixer": "^10.0.1", 24 | "postcss": "^8", 25 | "tailwindcss": "^3.3.0", 26 | "typescript": "^5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /examples/nextjs-app-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /examples/playground/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /examples/playground/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /examples/playground/.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/playground/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @examples/playground 2 | 3 | ## 0.0.6 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [[`edfe2a7`](https://github.com/bholmesdev/simple-stack/commit/edfe2a761b55fab26a757e6b18e90a0bf0094e74), [`edfe2a7`](https://github.com/bholmesdev/simple-stack/commit/edfe2a761b55fab26a757e6b18e90a0bf0094e74)]: 8 | - simple-stack-stream@0.3.0 9 | 10 | ## 0.0.5 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [[`08b907c`](https://github.com/bholmesdev/simple-stack/commit/08b907c964412110f6c089e09b4cba61431aafbf)]: 15 | - simple-stack-stream@0.2.0 16 | 17 | ## 0.0.4 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [[`3e72f1c`](https://github.com/bholmesdev/simple-stack/commit/3e72f1cc2ed02b3015fd918d32e0ff9cb9bf6d1e)]: 22 | - simple-stack-stream@0.1.0 23 | 24 | ## 0.0.3 25 | 26 | ### Patch Changes 27 | 28 | - [`3405d5b`](https://github.com/bholmesdev/simple-stack/commit/3405d5baa881460aaa98e03dc096b9f720824ae9) Thanks [@dsnjunior](https://github.com/dsnjunior)! - Add Solid JS template 29 | 30 | - Updated dependencies [[`3405d5b`](https://github.com/bholmesdev/simple-stack/commit/3405d5baa881460aaa98e03dc096b9f720824ae9), [`054fe3c`](https://github.com/bholmesdev/simple-stack/commit/054fe3cfa8c5640359b6ce7e29ec11e910aa9d36)]: 31 | - simple-stack-form@0.1.7 32 | - vite-plugin-simple-scope@2.0.0 33 | 34 | ## 0.0.2 35 | 36 | ### Patch Changes 37 | 38 | - Updated dependencies [[`b645806`](https://github.com/bholmesdev/simple-stack/commit/b645806d3a8f58a8bf1cc21fad8f3295adfa07c5)]: 39 | - simple-stack-stream@0.0.3 40 | 41 | ## 0.0.1 42 | 43 | ### Patch Changes 44 | 45 | - Updated dependencies [[`a36d92d`](https://github.com/bholmesdev/simple-stack/commit/a36d92d24c36d00f6fd547930bb2483da817e2ef)]: 46 | - simple-stack-stream@0.0.2 47 | -------------------------------------------------------------------------------- /examples/playground/README.md: -------------------------------------------------------------------------------- 1 | # Astro Starter Kit: Minimal 2 | 3 | ```sh 4 | npm create astro@latest -- --template minimal 5 | ``` 6 | 7 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal) 8 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal) 9 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json) 10 | 11 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 12 | 13 | ## 🚀 Project Structure 14 | 15 | Inside of your Astro project, you'll see the following folders and files: 16 | 17 | ```text 18 | / 19 | ├── public/ 20 | ├── src/ 21 | │ └── pages/ 22 | │ └── index.astro 23 | └── package.json 24 | ``` 25 | 26 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. 27 | 28 | There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. 29 | 30 | Any static assets, like images, can be placed in the `public/` directory. 31 | 32 | ## 🧞 Commands 33 | 34 | All commands are run from the root of the project, from a terminal: 35 | 36 | | Command | Action | 37 | | :------------------------ | :----------------------------------------------- | 38 | | `npm install` | Installs dependencies | 39 | | `npm run dev` | Starts local dev server at `localhost:4321` | 40 | | `npm run build` | Build your production site to `./dist/` | 41 | | `npm run preview` | Preview your build locally, before deploying | 42 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 43 | | `npm run astro -- --help` | Get help using the Astro CLI | 44 | 45 | ## 👀 Want to learn more? 46 | 47 | Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). 48 | -------------------------------------------------------------------------------- /examples/playground/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import simpleStackForm from "simple-stack-form"; 3 | import simpleStackStream from "simple-stack-stream"; 4 | import react from "@astrojs/react"; 5 | import node from "@astrojs/node"; 6 | import preact from "@astrojs/preact"; 7 | import solidJs from "@astrojs/solid-js"; 8 | import tailwind from "@astrojs/tailwind"; 9 | import simpleScope from "vite-plugin-simple-scope"; 10 | 11 | // https://astro.build/config 12 | export default defineConfig({ 13 | output: "server", 14 | integrations: [ 15 | simpleStackForm(), 16 | simpleStackStream(), 17 | react({ include: ["**/react/*"] }), 18 | preact({ include: ["**/preact/*"] }), 19 | solidJs({ include: ["**/solid-js/*"] }), 20 | tailwind(), 21 | ], 22 | vite: { 23 | plugins: [simpleScope()], 24 | }, 25 | adapter: node({ 26 | mode: "standalone", 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /examples/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/playground", 3 | "type": "module", 4 | "version": "0.0.6", 5 | "private": true, 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro build", 10 | "preview": "astro preview", 11 | "astro": "astro" 12 | }, 13 | "dependencies": { 14 | "@astrojs/node": "^7.0.0", 15 | "@astrojs/preact": "^3.0.1", 16 | "@astrojs/react": "^3.0.7", 17 | "@astrojs/solid-js": "^3.0.3", 18 | "@astrojs/tailwind": "^5.0.3", 19 | "@types/react": "^18.0.21", 20 | "@types/react-dom": "^18.0.6", 21 | "astro": "^4.0.7", 22 | "open-props": "^1.6.13", 23 | "preact": "^10.6.5", 24 | "react": "^18.0.0", 25 | "react-dom": "^18.0.0", 26 | "sanitize-html": "^2.11.0", 27 | "simple-stack-form": "^0.1.7", 28 | "simple-stack-stream": "^0.3.0", 29 | "solid-js": "^1.8.7", 30 | "tailwindcss": "^3.0.24", 31 | "vite-plugin-simple-scope": "^2.0.0", 32 | "zod": "^3.22.4" 33 | }, 34 | "devDependencies": { 35 | "@types/sanitize-html": "^2.9.5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/playground/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /examples/playground/src/components/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Suspense 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/playground/src/components/Sanitize.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Form, Input, useFormContext } from "./react/Form"; 3 | import { type FieldErrors, createForm } from "simple:form"; 4 | import sanitizeHtml from 'sanitize-html'; 5 | 6 | export const sanitize = createForm({ 7 | unsanitized: z.string().optional(), 8 | sanitized: z.string().optional().transform((dirty) => dirty && sanitizeHtml(dirty)), 9 | }); 10 | 11 | export default function Sanitize({ 12 | serverErrors, 13 | }: { serverErrors?: FieldErrors }) { 14 | return ( 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 36 | 37 | ); 38 | } 39 | 40 | function FormGroup({ children }: { children: React.ReactNode }) { 41 | return
{children}
; 42 | } 43 | 44 | function Loading() { 45 | const { value } = useFormContext(); 46 | return value.isSubmitPending ?

{value.submitStatus}

: null; 47 | } 48 | -------------------------------------------------------------------------------- /examples/playground/src/components/Wait.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | ms: number, 4 | class?: string, 5 | } 6 | 7 | const {ms, class: cls} = Astro.props; 8 | 9 | await new Promise(resolve => setTimeout(resolve, ms)); 10 | --- 11 |
12 | 13 |

Loaded in {ms}ms

14 |
-------------------------------------------------------------------------------- /examples/playground/src/components/preact/Form.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource preact */ 2 | 3 | // Generated by simple:form 4 | 5 | import { navigate } from "astro:transitions/client"; 6 | import { type ComponentProps, createContext } from "preact"; 7 | import { useContext, useState } from "preact/hooks"; 8 | import { 9 | type FieldErrors, 10 | type FormState, 11 | type FormValidator, 12 | formNameInputProps, 13 | getInitialFormState, 14 | toSetValidationErrors, 15 | toTrackAstroSubmitStatus, 16 | toValidateField, 17 | validateForm, 18 | } from "simple:form"; 19 | 20 | export function useCreateFormContext( 21 | validator: FormValidator, 22 | fieldErrors?: FieldErrors, 23 | ) { 24 | const initial = getInitialFormState({ validator, fieldErrors }); 25 | const [formState, setFormState] = useState(initial); 26 | return { 27 | value: formState, 28 | set: setFormState, 29 | setValidationErrors: toSetValidationErrors(setFormState), 30 | validateField: toValidateField(setFormState), 31 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 32 | }; 33 | } 34 | 35 | export function useFormContext() { 36 | const formContext = useContext(FormContext); 37 | if (!formContext) { 38 | throw new Error( 39 | "Form context not found. `useFormContext()` should only be called from children of a
component.", 40 | ); 41 | } 42 | return formContext; 43 | } 44 | 45 | type FormContextType = ReturnType; 46 | 47 | const FormContext = createContext(undefined); 48 | 49 | export function Form({ 50 | children, 51 | validator, 52 | context, 53 | fieldErrors, 54 | name, 55 | ...formProps 56 | }: { 57 | validator: FormValidator; 58 | context?: FormContextType; 59 | fieldErrors?: FieldErrors; 60 | } & Omit, "method" | "onSubmit">) { 61 | const formContext = context ?? useCreateFormContext(validator, fieldErrors); 62 | 63 | return ( 64 | 65 | { 69 | e.preventDefault(); 70 | e.stopPropagation(); 71 | const formData = new FormData(e.currentTarget); 72 | formContext.set((formState) => ({ 73 | ...formState, 74 | isSubmitPending: true, 75 | submitStatus: "validating", 76 | })); 77 | const parsed = await validateForm({ formData, validator }); 78 | if (parsed.data) { 79 | const action = 80 | typeof formProps.action === "string" 81 | ? formProps.action 82 | : // Check for Preact signals 83 | formProps.action?.value ?? ""; 84 | navigate(action, { formData }); 85 | return formContext.trackAstroSubmitStatus(); 86 | } 87 | 88 | formContext.setValidationErrors(parsed.fieldErrors); 89 | }} 90 | > 91 | {name ? : null} 92 | {children} 93 | 94 | 95 | ); 96 | } 97 | 98 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 99 | const formContext = useFormContext(); 100 | const fieldState = formContext.value.fields[inputProps.name]; 101 | if (!fieldState) { 102 | throw new Error( 103 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 104 | ); 105 | } 106 | 107 | const { hasErroredOnce, validationErrors, validator } = fieldState; 108 | return ( 109 | <> 110 | { 112 | const value = e.currentTarget.value; 113 | if (value === "") return; 114 | formContext.validateField(inputProps.name, value, validator); 115 | }} 116 | onInput={async (e) => { 117 | if (!hasErroredOnce) return; 118 | const value = e.currentTarget.value; 119 | formContext.validateField(inputProps.name, value, validator); 120 | }} 121 | {...inputProps} 122 | /> 123 | {validationErrors?.map((e) => ( 124 |

125 | {e} 126 |

127 | ))} 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /examples/playground/src/components/preact/Signup.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource preact */ 2 | 3 | import type { ComponentChild } from "preact"; 4 | import { z } from "zod"; 5 | import { Form, Input, useFormContext } from "./Form"; 6 | import { type FieldErrors, createForm } from "simple:form"; 7 | import { scope } from "simple:scope"; 8 | 9 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 10 | 11 | export const signup = createForm({ 12 | username: z 13 | .string() 14 | .min(2) 15 | .refine(async (s) => { 16 | await sleep(400); 17 | return s !== "admin"; 18 | }), 19 | optIn: z.boolean().optional(), 20 | }); 21 | 22 | export default function Signup({ 23 | serverErrors, 24 | }: { serverErrors?: FieldErrors }) { 25 | return ( 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 48 | ); 49 | } 50 | 51 | function FormGroup({ children }: { children: ComponentChild }) { 52 | return
{children}
; 53 | } 54 | 55 | function Loading() { 56 | const { value } = useFormContext(); 57 | return value.isSubmitPending ?

{value.submitStatus}

: null; 58 | } 59 | -------------------------------------------------------------------------------- /examples/playground/src/components/react/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { navigate } from "astro:transitions/client"; 4 | import { 5 | type ComponentProps, 6 | createContext, 7 | useContext, 8 | useState, 9 | } from "react"; 10 | import { 11 | type FieldErrors, 12 | type FormState, 13 | type FormValidator, 14 | formNameInputProps, 15 | getInitialFormState, 16 | toSetValidationErrors, 17 | toTrackAstroSubmitStatus, 18 | toValidateField, 19 | validateForm, 20 | } from "simple:form"; 21 | 22 | export function useCreateFormContext( 23 | validator: FormValidator, 24 | fieldErrors?: FieldErrors, 25 | ) { 26 | const initial = getInitialFormState({ validator, fieldErrors }); 27 | const [formState, setFormState] = useState(initial); 28 | return { 29 | value: formState, 30 | set: setFormState, 31 | setValidationErrors: toSetValidationErrors(setFormState), 32 | validateField: toValidateField(setFormState), 33 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 34 | }; 35 | } 36 | 37 | export function useFormContext() { 38 | const formContext = useContext(FormContext); 39 | if (!formContext) { 40 | throw new Error( 41 | "Form context not found. `useFormContext()` should only be called from children of a
component.", 42 | ); 43 | } 44 | return formContext; 45 | } 46 | 47 | type FormContextType = ReturnType; 48 | 49 | const FormContext = createContext(undefined); 50 | 51 | export function Form({ 52 | children, 53 | validator, 54 | context, 55 | fieldErrors, 56 | name, 57 | ...formProps 58 | }: { 59 | validator: FormValidator; 60 | context?: FormContextType; 61 | fieldErrors?: FieldErrors; 62 | } & Omit, "method" | "onSubmit">) { 63 | const formContext = context ?? useCreateFormContext(validator, fieldErrors); 64 | 65 | return ( 66 | 67 | { 71 | e.preventDefault(); 72 | e.stopPropagation(); 73 | const formData = new FormData(e.currentTarget); 74 | formContext.set((formState) => ({ 75 | ...formState, 76 | isSubmitPending: true, 77 | submitStatus: "validating", 78 | })); 79 | const parsed = await validateForm({ formData, validator }); 80 | if (parsed.data) { 81 | navigate(formProps.action ?? "", { formData }); 82 | return formContext.trackAstroSubmitStatus(); 83 | } 84 | 85 | formContext.setValidationErrors(parsed.fieldErrors); 86 | }} 87 | > 88 | {name ? : null} 89 | {children} 90 | 91 | 92 | ); 93 | } 94 | 95 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 96 | const formContext = useFormContext(); 97 | const fieldState = formContext.value.fields[inputProps.name]; 98 | if (!fieldState) { 99 | throw new Error( 100 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 101 | ); 102 | } 103 | 104 | const { hasErroredOnce, validationErrors, validator } = fieldState; 105 | return ( 106 | <> 107 | { 109 | const value = e.target.value; 110 | if (value === "") return; 111 | formContext.validateField(inputProps.name, value, validator); 112 | }} 113 | onChange={async (e) => { 114 | if (!hasErroredOnce) return; 115 | const value = e.target.value; 116 | formContext.validateField(inputProps.name, value, validator); 117 | }} 118 | {...inputProps} 119 | /> 120 | {validationErrors?.map((e) => ( 121 |

122 | {e} 123 |

124 | ))} 125 | 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /examples/playground/src/components/react/Signup.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Form, Input, useFormContext } from "./Form"; 3 | import { type FieldErrors, createForm } from "simple:form"; 4 | import { scope } from "simple:scope"; 5 | 6 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 7 | 8 | export const signup = createForm({ 9 | username: z 10 | .string() 11 | .min(2) 12 | .refine(async (s) => { 13 | await sleep(400); 14 | return s !== "admin"; 15 | }), 16 | email: z.string().email().optional(), 17 | coffees: z.number().lt(10).min(2), 18 | optIn: z.boolean().optional(), 19 | }); 20 | 21 | export default function Signup({ 22 | serverErrors, 23 | }: { serverErrors?: FieldErrors }) { 24 | return ( 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 53 | 54 | 55 | ); 56 | } 57 | 58 | function FormGroup({ children }: { children: React.ReactNode }) { 59 | return
{children}
; 60 | } 61 | 62 | function Loading() { 63 | const { value } = useFormContext(); 64 | return value.isSubmitPending ?

{value.submitStatus}

: null; 65 | } 66 | -------------------------------------------------------------------------------- /examples/playground/src/components/solid-js/Form.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource solid-js */ 2 | 3 | // Generated by simple:form 4 | 5 | import { 6 | type ComponentProps, 7 | For, 8 | Show, 9 | createContext, 10 | createSignal, 11 | useContext, 12 | } from "solid-js"; 13 | import { 14 | type FieldErrors, 15 | type FormState, 16 | type FormValidator, 17 | formNameInputProps, 18 | getInitialFormState, 19 | toSetValidationErrors, 20 | toTrackAstroSubmitStatus, 21 | toValidateField, 22 | validateForm, 23 | } from "simple:form"; 24 | import { navigate } from "astro:transitions/client"; 25 | 26 | export function useCreateFormContext( 27 | validator: FormValidator, 28 | fieldErrors?: FieldErrors, 29 | ) { 30 | const initial = getInitialFormState({ validator, fieldErrors }); 31 | const [formState, setFormState] = createSignal(initial); 32 | return { 33 | value: formState, 34 | set: setFormState, 35 | setValidationErrors: toSetValidationErrors(setFormState), 36 | validateField: toValidateField(setFormState), 37 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 38 | }; 39 | } 40 | 41 | export function useFormContext() { 42 | const formContext = useContext(FormContext); 43 | if (!formContext) { 44 | throw new Error( 45 | "Form context not found. `useFormContext()` should only be called from children of a
component.", 46 | ); 47 | } 48 | return formContext; 49 | } 50 | 51 | type FormContextType = ReturnType; 52 | 53 | const FormContext = createContext(undefined); 54 | 55 | export function Form( 56 | props: { 57 | validator: FormValidator; 58 | context?: FormContextType; 59 | fieldErrors?: FieldErrors; 60 | } & Omit, "method" | "onSubmit">, 61 | ) { 62 | const formContext = 63 | props.context ?? useCreateFormContext(props.validator, props.fieldErrors); 64 | 65 | return ( 66 | 67 | { 71 | e.preventDefault(); 72 | e.stopPropagation(); 73 | const formData = new FormData(e.currentTarget); 74 | formContext.set((formState) => ({ 75 | ...formState, 76 | isSubmitPending: true, 77 | submitStatus: "validating", 78 | })); 79 | const parsed = await validateForm({ 80 | formData, 81 | validator: props.validator, 82 | }); 83 | if (parsed.data) { 84 | navigate(props.action?.toString() ?? "", { formData }); 85 | return formContext.trackAstroSubmitStatus(); 86 | } 87 | 88 | formContext.setValidationErrors(parsed.fieldErrors); 89 | }} 90 | > 91 | 92 | {(name) => } 93 | 94 | {props.children} 95 | 96 | 97 | ); 98 | } 99 | 100 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 101 | const formContext = useFormContext(); 102 | const fieldState = () => { 103 | const value = formContext.value().fields[inputProps.name]; 104 | if (!value) { 105 | throw new Error( 106 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 107 | ); 108 | } 109 | return value; 110 | }; 111 | return ( 112 | <> 113 | { 115 | const value = e.target.value; 116 | if (value === "") return; 117 | formContext.validateField( 118 | inputProps.name, 119 | value, 120 | fieldState().validator, 121 | ); 122 | }} 123 | onInput={(e) => { 124 | if (!fieldState().hasErroredOnce) return; 125 | const value = e.target.value; 126 | formContext.validateField( 127 | inputProps.name, 128 | value, 129 | fieldState().validator, 130 | ); 131 | }} 132 | {...inputProps} 133 | /> 134 | 135 | {(e) =>

{e}

} 136 |
137 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /examples/playground/src/components/solid-js/Signup.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource solid-js */ 2 | import { type JSX, Show } from "solid-js"; 3 | import { z } from "zod"; 4 | import { Form, Input, useFormContext } from "./Form"; 5 | import { type FieldErrors, createForm } from "simple:form"; 6 | import { scope } from "simple:scope"; 7 | 8 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 9 | 10 | export const signup = createForm({ 11 | username: z 12 | .string() 13 | .min(2) 14 | .refine(async (s) => { 15 | await sleep(400); 16 | return s !== "admin"; 17 | }), 18 | optIn: z.boolean().optional(), 19 | }); 20 | 21 | export default function Signup(props: { 22 | serverErrors?: FieldErrors; 23 | }) { 24 | return ( 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 47 | ); 48 | } 49 | 50 | function FormGroup(props: { children: JSX.Element }) { 51 | return
{props.children}
; 52 | } 53 | 54 | function Loading() { 55 | const formContext = useFormContext(); 56 | return ( 57 | 58 |

{formContext.value().submitStatus}

59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /examples/playground/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | -------------------------------------------------------------------------------- /examples/playground/src/pages/array.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { createForm } from "simple:form"; 3 | import { z } from "zod"; 4 | import { ViewTransitions } from "astro:transitions"; 5 | 6 | const fileUpload = createForm({ 7 | hexCodes: z.array(z.string().length(6)), 8 | }); 9 | 10 | const req = await Astro.locals.form.getData(fileUpload); 11 | 12 | if (req?.data) { 13 | console.log(req.data.hexCodes); 14 | } 15 | --- 16 | 17 | 18 | 19 | 20 | 21 | 22 | Color gradient builder 23 | 24 | 25 | 26 |
27 | 35 | 36 | { 37 | req?.fieldErrors?.hexCodes?.[0] && ( 38 |

{req.fieldErrors.hexCodes[0]}

39 | ) 40 | } 41 | 42 |
43 | 50 | 51 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/playground/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import SignupReact, { signup as signupReact } from "../components/react/Signup"; 3 | import SignupPreact, { 4 | signup as signupPreact, 5 | } from "../components/preact/Signup"; 6 | import SignupSolid, { 7 | signup as signupSolid, 8 | } from "../components/solid-js/Signup"; 9 | import Sanitize, { sanitize } from "../components/Sanitize"; 10 | import { ViewTransitions } from "astro:transitions"; 11 | 12 | const { form } = Astro.locals; 13 | 14 | const formResultReact = await form.getDataByName("signupReact", signupReact); 15 | const formResultPreact = await form.getDataByName("signupPreact", signupPreact); 16 | const formResultSolid = await form.getDataByName("signupSolid", signupSolid); 17 | const sanitizeFormResult = await form.getDataByName("sanitize", sanitize); 18 | 19 | [ 20 | formResultReact, 21 | formResultPreact, 22 | formResultSolid, 23 | sanitizeFormResult 24 | ].forEach((formResult) => { 25 | if (formResult?.data) { 26 | console.log(formResult.data); 27 | } 28 | }); 29 | 30 | await new Promise((resolve) => setTimeout(resolve, 400)); 31 | --- 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Astro 41 | 42 | 43 |
44 |

Simple form

45 |

React

46 | { 47 | formResultReact?.data && ( 48 | 57 | ) 58 | } 59 |
60 | 61 |
62 | 63 |
64 | 65 |

Preact

66 | { 67 | formResultPreact?.data && ( 68 | 77 | ) 78 | } 79 |
80 | 84 |
85 | 86 |
87 | 88 |

Solid

89 | { 90 | formResultSolid?.data && ( 91 | 100 | ) 101 | } 102 |
103 | 107 |
108 | 109 |
110 | 111 |

Sanitize

112 |
113 | Try pasting this code snippet into each field once and submit 114 | 115 | {``} 116 | 117 |
118 |
119 | 120 |
121 | { 122 | sanitizeFormResult?.data && ( 123 | <> 124 |
125 | Sanitized result:{" "} 126 | 127 |
128 |
129 | Unsanitized result:{" "} 130 | 131 |
132 | 133 | ) 134 | } 135 | 139 |
140 | 141 | 142 | -------------------------------------------------------------------------------- /examples/playground/src/pages/stream.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../components/Layout.astro"; 3 | import Wait from "../components/Wait.astro"; 4 | import { Suspense, } from 'simple-stack-stream/components' 5 | --- 6 | 7 | 8 |
9 |

Out of order streaming

10 | 12 | 13 | 14 |

Slow content

15 |
16 | 17 | 18 |

Fast child content

19 |
20 |

21 | Loading fast child... (shouldn't appear) 22 |

23 |
24 | 25 | 26 |

Slow child content

27 |
28 |

29 | Loading slower child... 30 |

31 |
32 |

Loading...

33 |
34 | 35 | 36 | 37 |
38 |

Follow us

39 |

Join the newsletter

40 |
41 |
42 | 43 | 44 |

Loading... (shouldn't appear)

45 |
46 |

Synchronous content wrapped in a Suspense

47 |

(it shouldn't show a fallback)

48 |
49 |
50 | 51 | 52 |

Loading... (also shouldn't appear)

53 | 54 |

More content

55 |

It's only delayed by only 1ms, so it shouldn't show a fallback either

56 |
57 |
58 |
59 |
-------------------------------------------------------------------------------- /examples/playground/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /examples/playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | } 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-stack", 3 | "version": "0.0.0", 4 | "description": "Astro for apps", 5 | "scripts": { 6 | "dev": "turbo dev --filter='./packages/*'", 7 | "dev:all": "turbo dev", 8 | "build": "turbo build --filter='./packages/*'", 9 | "build:all": "turbo build", 10 | "test": "turbo test --filter='./packages/*'", 11 | "e2e": "turbo e2e", 12 | "check": "biome check packages/", 13 | "check:apply": "biome check packages/ --apply", 14 | "lint": "biome lint packages/", 15 | "lint:apply": "biome lint packages/ --apply", 16 | "format": "biome format", 17 | "format:write": "biome format --write", 18 | "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run check:apply" 19 | }, 20 | "keywords": [ 21 | "withastro" 22 | ], 23 | "author": "bholmesdev", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@biomejs/biome": "^1.8.3", 27 | "@changesets/changelog-github": "^0.5.0", 28 | "@changesets/cli": "^2.27.1", 29 | "@playwright/test": "^1.45.3", 30 | "@types/node": "^20.14.11", 31 | "turbo": "^1.11.2", 32 | "typescript": "^5.5.3" 33 | }, 34 | "packageManager": "pnpm@8.8.0" 35 | } 36 | -------------------------------------------------------------------------------- /packages/form/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # simple-stack-form 2 | 3 | ## 0.1.12 4 | 5 | ### Patch Changes 6 | 7 | - [#40](https://github.com/bholmesdev/simple-stack/pull/40) [`23cb93c`](https://github.com/bholmesdev/simple-stack/commit/23cb93cf35f4e8400c22289a655f4c4d2bb3bb08) Thanks [@dsnjunior](https://github.com/dsnjunior)! - Add `min` and `max` input props to number input if present on schema 8 | 9 | ## 0.1.11 10 | 11 | ### Patch Changes 12 | 13 | - [#38](https://github.com/bholmesdev/simple-stack/pull/38) [`43eb52c`](https://github.com/bholmesdev/simple-stack/commit/43eb52cea8af0c2c5e62bff6dc2e6a2e957dda90) Thanks [@dsnjunior](https://github.com/dsnjunior)! - Set input type as email for inputs validating email values 14 | 15 | ## 0.1.10 16 | 17 | ### Patch Changes 18 | 19 | - [#34](https://github.com/bholmesdev/simple-stack/pull/34) [`574fee1`](https://github.com/bholmesdev/simple-stack/commit/574fee1bf5cd3a78d36d412ecee4f87c75cc6999) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add a generic React template to use simple-form in non-Astro projects. 20 | 21 | To try it, install `simple-stack-form` as a dependency in your React-based project: 22 | 23 | ```bash 24 | # pnpm 25 | pnpm i simple-stack-form 26 | # npm 27 | npm i simple-stack-form 28 | ``` 29 | 30 | And run the `simple-form create` command. This will create a base template for validation, and leave `onSubmit` handling to you. 31 | 32 | ```bash 33 | # pnpm 34 | pnpx run simple-form create 35 | # npm 36 | npx simple-form create 37 | ``` 38 | 39 | ## 0.1.9 40 | 41 | ### Patch Changes 42 | 43 | - [#31](https://github.com/bholmesdev/simple-stack/pull/31) [`0d489e5`](https://github.com/bholmesdev/simple-stack/commit/0d489e5f356e607a97a06766f9549666c599dae0) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Async validation would cause the form to submit even when form errors are present. This fix updates all form templates to call Astro's submit method manually. 44 | 45 | ## 0.1.8 46 | 47 | ### Patch Changes 48 | 49 | - [#29](https://github.com/bholmesdev/simple-stack/pull/29) [`79d5cc5`](https://github.com/bholmesdev/simple-stack/commit/79d5cc53fe1f6bb108e5ecb13b089d730b6c73c1) Thanks [@dsnjunior](https://github.com/dsnjunior)! - Make `inputProps` keys from created forms typed. This way will be easier to identify what inputs are available for the each form. 50 | 51 | ## 0.1.7 52 | 53 | ### Patch Changes 54 | 55 | - [`3405d5b`](https://github.com/bholmesdev/simple-stack/commit/3405d5baa881460aaa98e03dc096b9f720824ae9) Thanks [@dsnjunior](https://github.com/dsnjunior)! - Add Solid JS template 56 | 57 | ## 0.1.6 58 | 59 | ### Patch Changes 60 | 61 | - [#18](https://github.com/bholmesdev/simple-stack/pull/18) [`86034e4`](https://github.com/bholmesdev/simple-stack/commit/86034e4f0880f254fa033a09a66bd8c59b85e4a7) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix accidental form submission on client validation errors 62 | -------------------------------------------------------------------------------- /packages/form/README.md: -------------------------------------------------------------------------------- 1 | # Simple form 🧘‍♂️ 2 | 3 | > The simple way to handle forms in your Astro project 4 | 5 | ```astro 6 | --- 7 | import { z } from "zod"; 8 | import { createForm } from "simple:form"; 9 | 10 | const checkout = createForm({ 11 | quantity: z.number(), 12 | email: z.string().email(), 13 | allowAlerts: z.boolean(), 14 | }); 15 | 16 | const result = await Astro.locals.form.getData(checkout); 17 | 18 | if (result?.data) { 19 | await myDb.insert(result.data); 20 | // proceed to checkout 21 | } 22 | --- 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | ``` 35 | 36 | 📚 Visit [the docs](https://simple-stack.dev/form) for more information and usage examples. 37 | -------------------------------------------------------------------------------- /packages/form/components/FormName.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | value: string; 4 | }; 5 | 6 | const { value } = Astro.props; 7 | --- 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/form/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FormName } from "./FormName.astro"; 2 | -------------------------------------------------------------------------------- /packages/form/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-stack-form", 3 | "version": "0.1.12", 4 | "description": "A simple form library for Astro projects", 5 | "type": "module", 6 | "bin": { 7 | "simple-form": "./dist/cli.js" 8 | }, 9 | "scripts": { 10 | "build": "tsc", 11 | "dev": "tsc --watch" 12 | }, 13 | "exports": { 14 | ".": "./dist/index.js", 15 | "./module": "./dist/module.js", 16 | "./middleware": "./dist/middleware.js", 17 | "./components": "./components/index.ts", 18 | "./types": "./types.d.ts" 19 | }, 20 | "keywords": ["withastro", "astro-integration"], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/bholmesdev/simple-stack.git", 24 | "directory": "packages/form" 25 | }, 26 | "author": "bholmesdev", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@types/fs-extra": "^11.0.4", 30 | "@types/node": "^20.10.4", 31 | "@types/react": "^18.0.21", 32 | "astro": "^4.0.7", 33 | "preact": "^10.19.3", 34 | "react": "^18.0.0", 35 | "solid-js": "^1.8.7", 36 | "typescript": "^5.5.3", 37 | "zod": "^3.22.4" 38 | }, 39 | "peerDependencies": { 40 | "astro": "^3.6.0 || ^4.0.0", 41 | "zod": "^3.22.4" 42 | }, 43 | "dependencies": { 44 | "@clack/prompts": "^0.7.0", 45 | "fs-extra": "^11.2.0", 46 | "just-map-values": "^3.2.0", 47 | "kleur": "^4.1.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/form/src/cli.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs"; 2 | import { mkdir, readFile, readdir } from "node:fs/promises"; 3 | import { resolve } from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import { 6 | cancel, 7 | confirm, 8 | intro, 9 | isCancel, 10 | log, 11 | outro, 12 | select, 13 | text, 14 | } from "@clack/prompts"; 15 | import { copy } from "fs-extra/esm"; 16 | import { bgGreen, bgWhite, black, bold, dim, green } from "kleur/colors"; 17 | 18 | const frameworks = [ 19 | { 20 | value: "react", 21 | label: "React", 22 | templateDir: "react", 23 | }, 24 | { 25 | value: "preact", 26 | label: "Preact", 27 | templateDir: "preact", 28 | }, 29 | { 30 | value: "solid-js", 31 | label: "SolidJS", 32 | templateDir: "solid-js", 33 | }, 34 | ] as const; 35 | 36 | type Framework = (typeof frameworks)[number]; 37 | 38 | const internalFiles = ["env.d.ts", "tsconfig.json"]; 39 | 40 | const cmd = process.argv[2]; 41 | 42 | switch (cmd) { 43 | case "create": 44 | await create(); 45 | break; 46 | default: 47 | help(); 48 | break; 49 | } 50 | 51 | async function create() { 52 | intro(`Create a new form component`); 53 | 54 | let isUsingAstro: boolean = false; 55 | let foundFramework: Framework | null = null; 56 | const packageJsonPath = resolve(process.cwd(), "package.json"); 57 | if (existsSync(packageJsonPath)) { 58 | const { dependencies = {}, devDependencies = {} } = JSON.parse( 59 | await readFile(packageJsonPath, { encoding: "utf-8" }), 60 | ); 61 | 62 | isUsingAstro = 63 | Object.keys(dependencies).includes("astro") || 64 | Object.keys(devDependencies).includes("astro"); 65 | 66 | for (const framework of frameworks) { 67 | if ( 68 | Object.keys(dependencies).includes(framework.value) || 69 | Object.keys(devDependencies).includes(framework.value) 70 | ) { 71 | foundFramework = framework; 72 | break; 73 | } 74 | } 75 | } 76 | 77 | if (!isUsingAstro) { 78 | if (foundFramework?.value === "react") { 79 | log.warn( 80 | `${bold( 81 | `Looks like you're using simple:form outside of Astro.`, 82 | )} Using our generic React template.`, 83 | ); 84 | 85 | await handleFormTemplate({ 86 | framework: foundFramework, 87 | useExternalTemplate: true, 88 | }); 89 | return outro(`Form created. You're all set!`); 90 | } 91 | return log.error( 92 | "Sorry, simple:form is only supported in Astro or other React-based frameworks.", 93 | ); 94 | } 95 | 96 | const useFoundFramework = 97 | !!foundFramework && 98 | handleCancel( 99 | await confirm({ 100 | message: `Do you want to use ${foundFramework.label}?`, 101 | initialValue: true, 102 | }), 103 | ); 104 | 105 | const framework = await (async () => { 106 | if (useFoundFramework) { 107 | return foundFramework!; 108 | } 109 | 110 | const selected = (await select({ 111 | message: "What framework should we use?", 112 | options: frameworks.map(({ value, label }) => ({ 113 | value: value, 114 | label: label, 115 | })), 116 | })) as Framework["value"]; 117 | 118 | return frameworks.find((framework) => framework.value === selected)!; 119 | })(); 120 | 121 | await handleFormTemplate({ framework }); 122 | 123 | outro(`Form created. You're all set!`); 124 | } 125 | 126 | async function handleFormTemplate({ 127 | framework, 128 | useExternalTemplate, 129 | }: { framework: Framework; useExternalTemplate?: boolean }) { 130 | const templateFileUrl = new URL( 131 | `../templates/${ 132 | // TODO: make external templates generic for all frameworks. 133 | // still testing non-Astro support. 134 | useExternalTemplate ? "react-external" : framework.templateDir 135 | }`, 136 | import.meta.url, 137 | ); 138 | 139 | const fileNamesToCreate = (await readdir(templateFileUrl)).filter( 140 | (fileName) => !internalFiles.includes(fileName), 141 | ); 142 | 143 | const relativeOutputDir = handleCancel( 144 | await text({ 145 | message: "What directory should we use?", 146 | initialValue: existsSync("src") ? "src/components" : "components", 147 | validate: (value) => { 148 | if (!value) { 149 | return "Please enter a path."; 150 | } 151 | const conflict = fileNamesToCreate.find((fileName) => 152 | existsSync(resolve(process.cwd(), value, fileName)), 153 | ); 154 | if (conflict) { 155 | return `${bold( 156 | conflict, 157 | )} already exists here. Choose a different directory.`; 158 | } 159 | }, 160 | }), 161 | ); 162 | 163 | if (!existsSync(resolve(process.cwd(), relativeOutputDir))) { 164 | await mkdir(resolve(process.cwd(), relativeOutputDir), { 165 | recursive: true, 166 | }); 167 | } 168 | 169 | const outputPath = resolve(process.cwd(), relativeOutputDir); 170 | 171 | await copy(fileURLToPath(templateFileUrl), outputPath, { 172 | filter: (src) => { 173 | const fileName = src.split("/").at(-1); 174 | return !!fileName && !internalFiles.includes(fileName); 175 | }, 176 | }); 177 | } 178 | 179 | function help() { 180 | printHelp({ 181 | commandName: "simple-form", 182 | usage: "[command] [...flags]", 183 | headline: "Handle forms in Astro projects, simply.", 184 | tables: { 185 | Commands: [["create", "Create a new form component."]], 186 | "Global Flags": [["--help", "Show this help message."]], 187 | }, 188 | }); 189 | } 190 | 191 | function handleCancel(result: T): Exclude { 192 | if (isCancel(result)) { 193 | cancel(`Cancelled.`); 194 | process.exit(0); 195 | } 196 | // TODO: Figure out type narrowing 197 | return result as unknown as Exclude; 198 | } 199 | 200 | /** 201 | * Format help two-column grid. 202 | * @see https://github.com/withastro/astro/blob/main/packages/astro/src/core/messages.ts#L298 203 | */ 204 | function printHelp({ 205 | commandName, 206 | headline, 207 | usage, 208 | tables, 209 | description, 210 | }: { 211 | commandName: string; 212 | headline?: string; 213 | usage?: string; 214 | tables?: Record; 215 | description?: string; 216 | }) { 217 | const linebreak = () => ""; 218 | const title = (label: string) => ` ${bgWhite(black(` ${label} `))}`; 219 | const table = ( 220 | rows: [string, string][], 221 | { padding }: { padding: number }, 222 | ) => { 223 | const split = process.stdout.columns < 60; 224 | let raw = ""; 225 | 226 | for (const row of rows) { 227 | if (split) { 228 | raw += ` ${row[0]}\n `; 229 | } else { 230 | raw += `${`${row[0]}`.padStart(padding)}`; 231 | } 232 | raw += " " + dim(row[1]) + "\n"; 233 | } 234 | 235 | return raw.slice(0, -1); // remove latest \n 236 | }; 237 | 238 | let message = []; 239 | 240 | if (headline) { 241 | message.push( 242 | linebreak(), 243 | ` ${bgGreen(black(` ${commandName} `))} ${green( 244 | `v${process.env.PACKAGE_VERSION ?? ""}`, 245 | )} ${headline}`, 246 | ); 247 | } 248 | 249 | if (usage) { 250 | message.push(linebreak(), ` ${green(commandName)} ${bold(usage)}`); 251 | } 252 | 253 | if (tables) { 254 | function calculateTablePadding(rows: [string, string][]) { 255 | return rows.reduce((val, [first]) => Math.max(val, first.length), 0) + 2; 256 | } 257 | const tableEntries = Object.entries(tables); 258 | const padding = Math.max( 259 | ...tableEntries.map(([, rows]) => calculateTablePadding(rows)), 260 | ); 261 | for (const [tableTitle, tableRows] of tableEntries) { 262 | message.push( 263 | linebreak(), 264 | title(tableTitle), 265 | table(tableRows, { padding }), 266 | ); 267 | } 268 | } 269 | 270 | if (description) { 271 | message.push(linebreak(), `${description}`); 272 | } 273 | 274 | // eslint-disable-next-line no-console 275 | console.info(message.join("\n") + "\n"); 276 | } 277 | -------------------------------------------------------------------------------- /packages/form/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { AstroIntegration } from "astro"; 2 | 3 | const VIRTUAL_MOD_ID = "simple:form"; 4 | const RESOLVED_VIRTUAL_MOD_ID = "\0" + VIRTUAL_MOD_ID; 5 | 6 | export default function integration(): AstroIntegration { 7 | return { 8 | name: "simple-form", 9 | hooks: { 10 | "astro:config:setup"({ addMiddleware, updateConfig }) { 11 | addMiddleware({ 12 | entrypoint: "simple-stack-form/middleware", 13 | order: "pre", 14 | }); 15 | 16 | updateConfig({ 17 | vite: { 18 | plugins: [ 19 | { 20 | name: "simple:form", 21 | resolveId(id) { 22 | if (id === VIRTUAL_MOD_ID) { 23 | return RESOLVED_VIRTUAL_MOD_ID; 24 | } 25 | }, 26 | load(id) { 27 | if (id === RESOLVED_VIRTUAL_MOD_ID) { 28 | return `export * from 'simple-stack-form/module';`; 29 | } 30 | }, 31 | }, 32 | ], 33 | }, 34 | }); 35 | }, 36 | }, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /packages/form/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { ValidRedirectStatus } from "astro"; 2 | import { defineMiddleware } from "astro/middleware"; 3 | import type { ZodRawShape } from "zod"; 4 | import { validateForm } from "./module.js"; 5 | 6 | const formContentTypes = [ 7 | "application/x-www-form-urlencoded", 8 | "multipart/form-data", 9 | ]; 10 | 11 | function isFormRequest(request: Request) { 12 | return ( 13 | request.method === "POST" && 14 | formContentTypes.some((t) => 15 | request.headers.get("content-type")?.startsWith(t), 16 | ) 17 | ); 18 | } 19 | 20 | export type SearchParams = Record< 21 | string, 22 | string | number | boolean | undefined 23 | >; 24 | 25 | export type PartialRedirectPayload = { 26 | status: ValidRedirectStatus; 27 | location: string; 28 | }; 29 | 30 | export const onRequest = defineMiddleware(({ request, locals }, next) => { 31 | locals.form = { 32 | async getData(form: { validator: ZodRawShape }) { 33 | if (!isFormRequest(request)) return undefined; 34 | 35 | // TODO: hoist exceptions as `formErrors` 36 | const formData = await request.clone().formData(); 37 | 38 | return validateForm({ formData, validator: form.validator }); 39 | }, 40 | async getDataByName(name: string, form: { validator: ZodRawShape }) { 41 | if (!isFormRequest(request)) return undefined; 42 | 43 | // TODO: hoist exceptions as `formErrors` 44 | const formData = await request.clone().formData(); 45 | 46 | if (formData.get("_formName") === name) { 47 | formData.delete("_formName"); 48 | return validateForm({ formData, validator: form.validator }); 49 | } 50 | 51 | return undefined; 52 | }, 53 | }; 54 | 55 | return next(); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/form/src/module.ts: -------------------------------------------------------------------------------- 1 | import mapValues from "just-map-values"; 2 | import { 3 | ZodArray, 4 | ZodBoolean, 5 | type ZodError, 6 | ZodNullable, 7 | ZodNumber, 8 | ZodObject, 9 | ZodOptional, 10 | type ZodRawShape, 11 | ZodString, 12 | type ZodType, 13 | z, 14 | } from "zod"; 15 | 16 | export type FormValidator = ZodRawShape; 17 | export type FieldErrors< 18 | T extends { validator: FormValidator } = { validator: FormValidator }, 19 | > = ZodError>>["formErrors"]["fieldErrors"]; 20 | 21 | export type InputProp = { 22 | "aria-required": boolean; 23 | name: string; 24 | } & ( 25 | | { type: "text" | "checkbox" | "email" } 26 | | { type: "number"; min?: number; max?: number } 27 | ); 28 | 29 | export const formNameInputProps = { 30 | type: "hidden", 31 | name: "_formName", 32 | } as const; 33 | 34 | export type FieldState = { 35 | hasErroredOnce: boolean; 36 | isValidating: boolean; 37 | validator: ZodType; 38 | validationErrors: string[] | undefined; 39 | }; 40 | 41 | export type FormState = { 42 | isSubmitPending: boolean; 43 | submitStatus: "idle" | "validating" | "submitting"; 44 | hasFieldErrors: boolean; 45 | fields: Record; 46 | }; 47 | 48 | export function createForm(validator: T) { 49 | return { 50 | inputProps: mapValues(validator, getInputProp) as Record< 51 | keyof T, 52 | InputProp 53 | >, 54 | validator: preprocessValidators(validator), 55 | }; 56 | } 57 | 58 | export function getInitialFormState({ 59 | validator, 60 | fieldErrors, 61 | }: { 62 | validator: FormValidator; 63 | fieldErrors: FieldErrors | undefined; 64 | }) { 65 | return { 66 | hasFieldErrors: false, 67 | submitStatus: "idle", 68 | isSubmitPending: false, 69 | fields: mapValues(validator, (validator, name) => { 70 | const fieldError = fieldErrors?.[name]; 71 | return { 72 | hasErroredOnce: !!fieldError?.length, 73 | validationErrors: fieldError, 74 | isValidating: false, 75 | validator, 76 | }; 77 | }), 78 | } satisfies FormState; 79 | } 80 | 81 | function preprocessValidators(formValidator: T) { 82 | return Object.fromEntries( 83 | Object.entries(formValidator).map(([key, validator]) => { 84 | const inputType = getInputInfo(validator); 85 | 86 | let value = validator; 87 | 88 | switch (inputType.type) { 89 | case "checkbox": 90 | value = z.preprocess((value) => value === "on", validator); 91 | break; 92 | case "number": 93 | value = z.preprocess(Number, validator); 94 | break; 95 | case "text": 96 | case "email": 97 | value = z.preprocess( 98 | // Consider empty input as "required" 99 | (value) => (value === "" ? undefined : value), 100 | validator, 101 | ); 102 | break; 103 | } 104 | 105 | if (inputType.isArray) { 106 | value = z.preprocess((v) => { 107 | // Support validating a single input against an array validator 108 | // Use case: input validation on blur 109 | return Array.isArray(v) ? v : [v]; 110 | }, validator); 111 | } 112 | 113 | return [key, value]; 114 | }), 115 | ) as T; 116 | } 117 | 118 | type Setter = (callback: (previous: T) => T) => void; 119 | 120 | function toSetFieldState(setFormState: Setter) { 121 | return (key: string, getValue: (previous: FieldState) => FieldState) => { 122 | setFormState((formState) => { 123 | const fieldState = formState.fields[key]; 124 | if (!fieldState) return formState; 125 | 126 | const fields = { ...formState.fields, [key]: getValue(fieldState) }; 127 | const hasFieldErrors = Object.values(fields).some( 128 | (f) => f.validationErrors?.length, 129 | ); 130 | return { ...formState, hasFieldErrors, fields }; 131 | }); 132 | }; 133 | } 134 | 135 | export function toTrackAstroSubmitStatus( 136 | setFormState: Setter, 137 | ) { 138 | return () => { 139 | setFormState((value) => ({ 140 | ...value, 141 | isSubmitPending: true, 142 | submitStatus: "submitting", 143 | })); 144 | document.addEventListener( 145 | "astro:after-preparation", 146 | () => 147 | setFormState((value) => ({ 148 | ...value, 149 | isSubmitPending: false, 150 | submitStatus: "idle", 151 | })), 152 | { 153 | once: true, 154 | }, 155 | ); 156 | }; 157 | } 158 | 159 | export function toValidateField(setFormState: Setter) { 160 | const setFieldState = toSetFieldState(setFormState); 161 | 162 | return async (fieldName: string, inputValue: unknown, validator: ZodType) => { 163 | setFieldState(fieldName, (fieldState) => ({ 164 | ...fieldState, 165 | isValidating: true, 166 | })); 167 | const parsed = await validator.safeParseAsync(inputValue); 168 | if (parsed.success === false) { 169 | return setFieldState(fieldName, (fieldState) => ({ 170 | ...fieldState, 171 | hasErroredOnce: true, 172 | isValidating: false, 173 | validationErrors: parsed.error.errors.map((e) => e.message), 174 | })); 175 | } 176 | setFieldState(fieldName, (fieldState) => ({ 177 | ...fieldState, 178 | isValidating: false, 179 | validationErrors: undefined, 180 | })); 181 | }; 182 | } 183 | 184 | export function toSetValidationErrors( 185 | setFormState: Setter, 186 | ) { 187 | const setFieldState = toSetFieldState(setFormState); 188 | return ( 189 | fieldErrors: ZodError>["formErrors"]["fieldErrors"], 190 | ) => { 191 | setFormState((formState) => ({ 192 | ...formState, 193 | hasFieldErrors: false, 194 | submitStatus: "idle", 195 | })); 196 | for (const [key, validationErrors] of Object.entries(fieldErrors)) { 197 | setFieldState(key, (fieldState) => ({ 198 | ...fieldState, 199 | hasErroredOnce: true, 200 | validationErrors, 201 | })); 202 | } 203 | }; 204 | } 205 | 206 | function getInputProp( 207 | fieldValidator: T, 208 | name: string | number | symbol, 209 | ) { 210 | const inputInfo = getInputInfo(fieldValidator); 211 | 212 | const inputProp: InputProp = { 213 | name: String(name), 214 | "aria-required": 215 | !fieldValidator.isOptional() && !fieldValidator.isNullable(), 216 | type: inputInfo.type, 217 | min: inputInfo.min, 218 | max: inputInfo.max, 219 | }; 220 | 221 | return inputProp; 222 | } 223 | 224 | function getInputType(fieldValidator: T): InputProp["type"] { 225 | if (fieldValidator instanceof ZodBoolean) { 226 | return "checkbox"; 227 | } 228 | 229 | if (fieldValidator instanceof ZodNumber) { 230 | return "number"; 231 | } 232 | 233 | if ( 234 | fieldValidator instanceof ZodString && 235 | fieldValidator._def.checks.some((check) => check.kind === "email") 236 | ) { 237 | return "email"; 238 | } 239 | 240 | return "text"; 241 | } 242 | 243 | function getInputInfo( 244 | fieldValidator: T, 245 | ): { 246 | type: InputProp["type"]; 247 | isArray: boolean; 248 | isOptional: boolean; 249 | min?: number; 250 | max?: number; 251 | } { 252 | let resolvedType = fieldValidator; 253 | let isArray = false; 254 | let isOptional = false; 255 | if ( 256 | fieldValidator instanceof ZodOptional || 257 | fieldValidator instanceof ZodNullable 258 | ) { 259 | resolvedType = fieldValidator._def.innerType; 260 | isOptional = true; 261 | } 262 | 263 | if (fieldValidator instanceof ZodArray) { 264 | resolvedType = fieldValidator._def.type; 265 | isArray = true; 266 | } 267 | 268 | // TODO: respect preprocess() wrappers 269 | 270 | const type = getInputType(resolvedType); 271 | 272 | const result: ReturnType = { type, isArray, isOptional }; 273 | 274 | if (type === "number" && resolvedType instanceof ZodNumber) { 275 | for (const check of resolvedType._def.checks) { 276 | if (check.kind === "min") { 277 | result.min = check.value + (check.inclusive ? 0 : 1); 278 | } 279 | if (check.kind === "max") { 280 | result.max = check.value - (check.inclusive ? 0 : 1); 281 | } 282 | } 283 | } 284 | 285 | return result; 286 | } 287 | 288 | export async function validateForm({ 289 | formData, 290 | validator, 291 | }: { 292 | formData: FormData; 293 | validator: T; 294 | }) { 295 | const result = await z 296 | .preprocess((formData) => { 297 | if (!(formData instanceof FormData)) return formData; 298 | 299 | return mapValues(Object.fromEntries(formData), (value, key) => { 300 | const all = formData.getAll(String(key)); 301 | return all.length > 1 ? all : value; 302 | }); 303 | }, z.object(validator)) 304 | .safeParseAsync(formData); 305 | 306 | if (result.success) { 307 | return { data: result.data, fieldErrors: undefined }; 308 | } 309 | return { 310 | data: undefined, 311 | fieldErrors: result.error.formErrors.fieldErrors, 312 | }; 313 | } 314 | -------------------------------------------------------------------------------- /packages/form/templates/preact/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { navigate } from "astro:transitions/client"; 4 | import { 5 | type FieldErrors, 6 | type FormState, 7 | type FormValidator, 8 | formNameInputProps, 9 | getInitialFormState, 10 | toSetValidationErrors, 11 | toTrackAstroSubmitStatus, 12 | toValidateField, 13 | validateForm, 14 | } from "simple:form"; 15 | import { type ComponentProps, createContext } from "preact"; 16 | import { useContext, useState } from "preact/hooks"; 17 | 18 | export function useCreateFormContext( 19 | validator: FormValidator, 20 | fieldErrors?: FieldErrors, 21 | ) { 22 | const initial = getInitialFormState({ validator, fieldErrors }); 23 | const [formState, setFormState] = useState(initial); 24 | return { 25 | value: formState, 26 | set: setFormState, 27 | setValidationErrors: toSetValidationErrors(setFormState), 28 | validateField: toValidateField(setFormState), 29 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 30 | }; 31 | } 32 | 33 | export function useFormContext() { 34 | const formContext = useContext(FormContext); 35 | if (!formContext) { 36 | throw new Error( 37 | "Form context not found. `useFormContext()` should only be called from children of a
component.", 38 | ); 39 | } 40 | return formContext; 41 | } 42 | 43 | type FormContextType = ReturnType; 44 | 45 | const FormContext = createContext(undefined); 46 | 47 | export function Form({ 48 | children, 49 | validator, 50 | context, 51 | fieldErrors, 52 | name, 53 | ...formProps 54 | }: { 55 | validator: FormValidator; 56 | context?: FormContextType; 57 | fieldErrors?: FieldErrors; 58 | } & Omit, "method" | "onSubmit">) { 59 | const formContext = context ?? useCreateFormContext(validator, fieldErrors); 60 | 61 | return ( 62 | 63 | { 67 | e.preventDefault(); 68 | e.stopPropagation(); 69 | const formData = new FormData(e.currentTarget); 70 | formContext.set((formState) => ({ 71 | ...formState, 72 | isSubmitPending: true, 73 | submitStatus: "validating", 74 | })); 75 | const parsed = await validateForm({ formData, validator }); 76 | if (parsed.data) { 77 | const action = 78 | typeof formProps.action === "string" 79 | ? formProps.action 80 | : // Check for Preact signals 81 | formProps.action?.value ?? ""; 82 | navigate(action, { formData }); 83 | return formContext.trackAstroSubmitStatus(); 84 | } 85 | 86 | formContext.setValidationErrors(parsed.fieldErrors); 87 | }} 88 | > 89 | {name ? : null} 90 | {children} 91 | 92 | 93 | ); 94 | } 95 | 96 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 97 | const formContext = useFormContext(); 98 | const fieldState = formContext.value.fields[inputProps.name]; 99 | if (!fieldState) { 100 | throw new Error( 101 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 102 | ); 103 | } 104 | 105 | const { hasErroredOnce, validationErrors, validator } = fieldState; 106 | return ( 107 | <> 108 | { 110 | const value = e.currentTarget.value; 111 | if (value === "") return; 112 | formContext.validateField(inputProps.name, value, validator); 113 | }} 114 | onInput={async (e) => { 115 | if (!hasErroredOnce) return; 116 | const value = e.currentTarget.value; 117 | formContext.validateField(inputProps.name, value, validator); 118 | }} 119 | {...inputProps} 120 | /> 121 | {validationErrors?.map((e) => ( 122 |

{e}

123 | ))} 124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /packages/form/templates/preact/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/form/templates/preact/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "preact" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/form/templates/react-external/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { 4 | type ComponentProps, 5 | createContext, 6 | useContext, 7 | useState, 8 | } from "react"; 9 | import { 10 | type FieldErrors, 11 | type FormState, 12 | type FormValidator, 13 | formNameInputProps, 14 | getInitialFormState, 15 | toSetValidationErrors, 16 | toValidateField, 17 | validateForm, 18 | } from "simple-stack-form/module"; 19 | 20 | export function useCreateFormContext( 21 | validator: FormValidator, 22 | fieldErrors?: FieldErrors, 23 | ) { 24 | const initial = getInitialFormState({ validator, fieldErrors }); 25 | const [formState, setFormState] = useState(initial); 26 | return { 27 | value: formState, 28 | set: setFormState, 29 | setValidationErrors: toSetValidationErrors(setFormState), 30 | validateField: toValidateField(setFormState), 31 | }; 32 | } 33 | 34 | export function useFormContext() { 35 | const formContext = useContext(FormContext); 36 | if (!formContext) { 37 | throw new Error( 38 | "Form context not found. `useFormContext()` should only be called from children of a component.", 39 | ); 40 | } 41 | return formContext; 42 | } 43 | 44 | type FormContextType = ReturnType; 45 | 46 | const FormContext = createContext(undefined); 47 | 48 | export function Form({ 49 | children, 50 | validator, 51 | fieldErrors, 52 | name, 53 | ...formProps 54 | }: { 55 | validator: FormValidator; 56 | fieldErrors?: FieldErrors; 57 | } & Omit, "method" | "onSubmit">) { 58 | const formContext = useCreateFormContext(validator, fieldErrors); 59 | 60 | return ( 61 | 62 | { 66 | e.preventDefault(); 67 | e.stopPropagation(); 68 | const formData = new FormData(e.currentTarget); 69 | formContext.set((formState) => ({ 70 | ...formState, 71 | isSubmitPending: true, 72 | submitStatus: "validating", 73 | })); 74 | const parsed = await validateForm({ formData, validator }); 75 | if (parsed.data) { 76 | /* 77 | * Handle form submission. 78 | * See our documentation for examples: 79 | * https://simple-stack.dev/packages/form 80 | */ 81 | console.info("TODO: add form submit handler"); 82 | return; 83 | } 84 | 85 | formContext.setValidationErrors(parsed.fieldErrors); 86 | }} 87 | > 88 | {name ? : null} 89 | {children} 90 | 91 | 92 | ); 93 | } 94 | 95 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 96 | const formContext = useFormContext(); 97 | const fieldState = formContext.value.fields[inputProps.name]; 98 | if (!fieldState) { 99 | throw new Error( 100 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 101 | ); 102 | } 103 | 104 | const { hasErroredOnce, validationErrors, validator } = fieldState; 105 | return ( 106 | <> 107 | { 109 | const value = e.target.value; 110 | if (value === "") return; 111 | formContext.validateField(inputProps.name, value, validator); 112 | }} 113 | onChange={async (e) => { 114 | if (!hasErroredOnce) return; 115 | const value = e.target.value; 116 | formContext.validateField(inputProps.name, value, validator); 117 | }} 118 | {...inputProps} 119 | /> 120 | {validationErrors?.map((e) => ( 121 |

{e}

122 | ))} 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /packages/form/templates/react-external/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/form/templates/react/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { navigate } from "astro:transitions/client"; 4 | import { 5 | type FieldErrors, 6 | type FormState, 7 | type FormValidator, 8 | formNameInputProps, 9 | getInitialFormState, 10 | toSetValidationErrors, 11 | toTrackAstroSubmitStatus, 12 | toValidateField, 13 | validateForm, 14 | } from "simple:form"; 15 | import { 16 | type ComponentProps, 17 | createContext, 18 | useContext, 19 | useState, 20 | } from "react"; 21 | 22 | export function useCreateFormContext( 23 | validator: FormValidator, 24 | fieldErrors?: FieldErrors, 25 | ) { 26 | const initial = getInitialFormState({ validator, fieldErrors }); 27 | const [formState, setFormState] = useState(initial); 28 | return { 29 | value: formState, 30 | set: setFormState, 31 | setValidationErrors: toSetValidationErrors(setFormState), 32 | validateField: toValidateField(setFormState), 33 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 34 | }; 35 | } 36 | 37 | export function useFormContext() { 38 | const formContext = useContext(FormContext); 39 | if (!formContext) { 40 | throw new Error( 41 | "Form context not found. `useFormContext()` should only be called from children of a component.", 42 | ); 43 | } 44 | return formContext; 45 | } 46 | 47 | type FormContextType = ReturnType; 48 | 49 | const FormContext = createContext(undefined); 50 | 51 | export function Form({ 52 | children, 53 | validator, 54 | context, 55 | fieldErrors, 56 | name, 57 | ...formProps 58 | }: { 59 | validator: FormValidator; 60 | context?: FormContextType; 61 | fieldErrors?: FieldErrors; 62 | } & Omit, "method" | "onSubmit">) { 63 | const formContext = context ?? useCreateFormContext(validator, fieldErrors); 64 | 65 | return ( 66 | 67 | { 71 | e.preventDefault(); 72 | e.stopPropagation(); 73 | const formData = new FormData(e.currentTarget); 74 | formContext.set((formState) => ({ 75 | ...formState, 76 | isSubmitPending: true, 77 | submitStatus: "validating", 78 | })); 79 | const parsed = await validateForm({ formData, validator }); 80 | if (parsed.data) { 81 | navigate(formProps.action ?? "", { formData }); 82 | return formContext.trackAstroSubmitStatus(); 83 | } 84 | 85 | formContext.setValidationErrors(parsed.fieldErrors); 86 | }} 87 | > 88 | {name ? : null} 89 | {children} 90 | 91 | 92 | ); 93 | } 94 | 95 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 96 | const formContext = useFormContext(); 97 | const fieldState = formContext.value.fields[inputProps.name]; 98 | if (!fieldState) { 99 | throw new Error( 100 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 101 | ); 102 | } 103 | 104 | const { hasErroredOnce, validationErrors, validator } = fieldState; 105 | return ( 106 | <> 107 | { 109 | const value = e.target.value; 110 | if (value === "") return; 111 | formContext.validateField(inputProps.name, value, validator); 112 | }} 113 | onChange={async (e) => { 114 | if (!hasErroredOnce) return; 115 | const value = e.target.value; 116 | formContext.validateField(inputProps.name, value, validator); 117 | }} 118 | {...inputProps} 119 | /> 120 | {validationErrors?.map((e) => ( 121 |

{e}

122 | ))} 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /packages/form/templates/react/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/form/templates/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/form/templates/solid-js/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { navigate } from "astro:transitions/client"; 4 | import { 5 | type FieldErrors, 6 | type FormState, 7 | type FormValidator, 8 | formNameInputProps, 9 | getInitialFormState, 10 | toSetValidationErrors, 11 | toTrackAstroSubmitStatus, 12 | toValidateField, 13 | validateForm, 14 | } from "simple:form"; 15 | import { 16 | type ComponentProps, 17 | For, 18 | Show, 19 | createContext, 20 | createSignal, 21 | useContext, 22 | } from "solid-js"; 23 | 24 | export function useCreateFormContext( 25 | validator: FormValidator, 26 | fieldErrors?: FieldErrors, 27 | ) { 28 | const initial = getInitialFormState({ validator, fieldErrors }); 29 | const [formState, setFormState] = createSignal(initial); 30 | return { 31 | value: formState, 32 | set: setFormState, 33 | setValidationErrors: toSetValidationErrors(setFormState), 34 | validateField: toValidateField(setFormState), 35 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 36 | }; 37 | } 38 | 39 | export function useFormContext() { 40 | const formContext = useContext(FormContext); 41 | if (!formContext) { 42 | throw new Error( 43 | "Form context not found. `useFormContext()` should only be called from children of a component.", 44 | ); 45 | } 46 | return formContext; 47 | } 48 | 49 | type FormContextType = ReturnType; 50 | 51 | const FormContext = createContext(undefined); 52 | 53 | export function Form( 54 | props: { 55 | validator: FormValidator; 56 | context?: FormContextType; 57 | fieldErrors?: FieldErrors; 58 | } & Omit, "method" | "onSubmit">, 59 | ) { 60 | const formContext = 61 | props.context ?? useCreateFormContext(props.validator, props.fieldErrors); 62 | 63 | return ( 64 | 65 | { 69 | e.preventDefault(); 70 | e.stopPropagation(); 71 | const formData = new FormData(e.currentTarget); 72 | formContext.set((formState) => ({ 73 | ...formState, 74 | isSubmitPending: true, 75 | submitStatus: "validating", 76 | })); 77 | const parsed = await validateForm({ 78 | formData, 79 | validator: props.validator, 80 | }); 81 | if (parsed.data) { 82 | navigate(props.action?.toString() ?? "", { formData }); 83 | return formContext.trackAstroSubmitStatus(); 84 | } 85 | 86 | formContext.setValidationErrors(parsed.fieldErrors); 87 | }} 88 | > 89 | 90 | {(name) => } 91 | 92 | {props.children} 93 | 94 | 95 | ); 96 | } 97 | 98 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 99 | const formContext = useFormContext(); 100 | const fieldState = () => { 101 | const value = formContext.value().fields[inputProps.name]; 102 | if (!value) { 103 | throw new Error( 104 | `Input "${inputProps.name}" not found in form. Did you use the
component?`, 105 | ); 106 | } 107 | return value; 108 | }; 109 | return ( 110 | <> 111 | { 113 | const value = e.target.value; 114 | if (value === "") return; 115 | formContext.validateField( 116 | inputProps.name, 117 | value, 118 | fieldState().validator, 119 | ); 120 | }} 121 | onInput={(e) => { 122 | if (!fieldState().hasErroredOnce) return; 123 | const value = e.target.value; 124 | formContext.validateField( 125 | inputProps.name, 126 | value, 127 | fieldState().validator, 128 | ); 129 | }} 130 | {...inputProps} 131 | /> 132 | {(e) =>

{e}

}
133 | 134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /packages/form/templates/solid-js/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/form/templates/solid-js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/form/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "rootDir": "./src" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/form/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "simple:form" { 2 | export * from "simple-stack-form/module"; 3 | } 4 | 5 | declare namespace App { 6 | type FormValidator = import("simple-stack-form/module").FormValidator; 7 | type GetDataResult = 8 | | { 9 | data: import("zod").output>; 10 | fieldErrors: undefined; 11 | } 12 | | { 13 | data: undefined; 14 | fieldErrors: import("zod").ZodError< 15 | import("zod").output> 16 | >["formErrors"]["fieldErrors"]; 17 | }; 18 | 19 | interface Locals { 20 | form: { 21 | getData( 22 | form: T, 23 | ): Promise | undefined>; 24 | getDataByName( 25 | name: string, 26 | form: T, 27 | ): Promise | undefined>; 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/frame/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # simple-stack-frame 2 | 3 | ## 0.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [`abbd3b3`](https://github.com/bholmesdev/simple-stack/commit/abbd3b3279f13df168bad1d787f67a7cac43ba41) - Add GET request handling 8 | 9 | ## 0.0.2 10 | 11 | ### Patch Changes 12 | 13 | - [#59](https://github.com/bholmesdev/simple-stack/pull/59) [`53acd1f`](https://github.com/bholmesdev/simple-stack/commit/53acd1ffce21a956db9cba1c184f1c4464b2f78b) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Introduce simple-stack-frame package. The goal: rerender **just the parts that change** on any Astro route. Use the `` component to declare a target on the page, and use any form to rerender that target. 14 | -------------------------------------------------------------------------------- /packages/frame/components/Frame.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { HTMLAttributes } from "astro/types"; 3 | 4 | type Props> = { 5 | component(_props: T): any; 6 | props?: T; 7 | } & HTMLAttributes<"div">; 8 | 9 | const { component: Component, props, ...attrs } = Astro.props; 10 | 11 | function safeParseUrl(url: string) { 12 | try { 13 | return new URL(url); 14 | } catch { 15 | return null; 16 | } 17 | } 18 | 19 | const componentFileUrl = Component.moduleId 20 | ? safeParseUrl("file://" + Component.moduleId) 21 | : null; 22 | const pagesDirUrl = safeParseUrl(import.meta.env.PAGES_DIR); 23 | if (!(pagesDirUrl instanceof URL)) { 24 | throw new Error( 25 | "[simple:frame] Could not scan pages directory. Did you apply the `simple:frame` integration?", 26 | ); 27 | } 28 | if (!(componentFileUrl instanceof URL)) { 29 | throw new Error( 30 | "[simple:frame] Could not resolve component file path. Did you pass an Astro component to ?", 31 | ); 32 | } 33 | if (!componentFileUrl.href.startsWith(pagesDirUrl.href)) { 34 | throw new Error( 35 | `[simple:frame] Frame component ${Component.moduleId} is not in the pages directory. Please move to pages/ to expose as a route.`, 36 | ); 37 | } 38 | 39 | const pagePath = componentFileUrl.href 40 | .slice(pagesDirUrl.href.length) 41 | .replace(/^\/+/, "") 42 | .replace(/\.astro$/, ""); 43 | --- 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /packages/frame/components/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly PAGES_DIR: string; 5 | } 6 | -------------------------------------------------------------------------------- /packages/frame/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Frame } from "./Frame.astro"; 2 | -------------------------------------------------------------------------------- /packages/frame/components/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest" 3 | } 4 | -------------------------------------------------------------------------------- /packages/frame/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-stack-frame", 3 | "version": "0.0.3", 4 | "description": "Rerender just the parts that change", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev": "tsc --watch" 9 | }, 10 | "exports": { 11 | ".": "./src/index.ts", 12 | "./module": "./dist/module.js", 13 | "./client": "./dist/client.js", 14 | "./components": "./components/index.ts" 15 | }, 16 | "keywords": ["withastro", "astro-integration"], 17 | "author": "bholmesdev", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/bholmesdev/simple-stack.git", 22 | "directory": "packages/frame" 23 | }, 24 | "dependencies": { 25 | "astro-integration-kit": "^0.15.0" 26 | }, 27 | "devDependencies": { 28 | "astro": "^4.5.5", 29 | "typescript": "^5.5.3", 30 | "vite": "^5.0.10" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/frame/src/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | supportsViewTransitions, 3 | transitionEnabledOnThisPage, 4 | } from "astro/virtual-modules/transitions-router.js"; 5 | 6 | function setupForms(forms: NodeListOf) { 7 | for (const form of forms) { 8 | let controller: AbortController; 9 | form.addEventListener("submit", async (e) => { 10 | e.preventDefault(); 11 | e.stopPropagation(); 12 | if (controller) controller.abort(); 13 | controller = new AbortController(); 14 | 15 | let frameUrl = form.getAttribute("data-frame"); 16 | if (frameUrl === "") { 17 | frameUrl = form.closest("simple-frame")?.getAttribute("src") ?? null; 18 | } 19 | if (typeof frameUrl !== "string") { 20 | throw missingUrlError; 21 | } 22 | const frame = document.querySelector(`simple-frame[src="${frameUrl}"]`); 23 | if (!frame) { 24 | console.warn(`Frame with URL ${frameUrl} not found.`); 25 | // TODO: decide error handling strategy 26 | return; 27 | } 28 | const headers = new Headers(); 29 | headers.set("Accept", "text/html"); 30 | const stringifiedProps = frame.getAttribute("data-props"); 31 | if (stringifiedProps) headers.set("x-frame-props", stringifiedProps); 32 | frame.toggleAttribute("data-loading", true); 33 | form.toggleAttribute("data-loading", true); 34 | frame.dispatchEvent(new CustomEvent("frame-submit")); 35 | form.dispatchEvent(new CustomEvent("frame-submit")); 36 | try { 37 | let res: Response; 38 | if (form.method === "POST") { 39 | res = await fetch(frameUrl, { 40 | method: "POST", 41 | body: new FormData(form), 42 | signal: controller.signal, 43 | headers, 44 | }); 45 | } else { 46 | const searchParams = new URLSearchParams(new FormData(form) as any); 47 | const shouldMirrorQuery = form.getAttribute("data-mirror-query"); 48 | if (shouldMirrorQuery !== "false") { 49 | window.history.replaceState( 50 | {}, 51 | "", 52 | `${window.location.pathname}?${searchParams}`, 53 | ); 54 | } 55 | 56 | res = await fetch(`${frameUrl}?${searchParams}`, { 57 | method: "GET", 58 | signal: controller.signal, 59 | headers, 60 | }); 61 | } 62 | if (!res.ok) { 63 | frame.dispatchEvent(new CustomEvent("frame-error", { detail: res })); 64 | form.dispatchEvent(new CustomEvent("frame-error", { detail: res })); 65 | return; 66 | } 67 | // TODO: handle redirects 68 | const htmlString = await res.text(); 69 | const incomingContents = new DOMParser().parseFromString( 70 | htmlString, 71 | "text/html", 72 | ); 73 | const render = () => { 74 | frame.innerHTML = incomingContents.body.innerHTML; 75 | setupForms(frame.querySelectorAll("form[data-frame]")); 76 | }; 77 | 78 | if (transitionEnabledOnThisPage() && supportsViewTransitions) { 79 | // @ts-expect-error 80 | document.startViewTransition(() => render()); 81 | } else { 82 | render(); 83 | } 84 | frame.toggleAttribute("data-loading", false); 85 | form.toggleAttribute("data-loading", false); 86 | frame.dispatchEvent(new CustomEvent("frame-load")); 87 | form.dispatchEvent(new CustomEvent("frame-load")); 88 | } catch (e) { 89 | if (e instanceof DOMException && e.name === "AbortError") { 90 | return; 91 | } 92 | // TODO: generic error handling 93 | frame.toggleAttribute("data-loading", false); 94 | form.toggleAttribute("data-loading", false); 95 | frame.dispatchEvent(new CustomEvent("frame-load")); 96 | form.dispatchEvent(new CustomEvent("frame-load")); 97 | throw e; 98 | } 99 | }); 100 | } 101 | } 102 | 103 | if (transitionEnabledOnThisPage()) { 104 | document.addEventListener("astro:page-load", () => { 105 | setupForms( 106 | document.querySelectorAll( 107 | "form[data-frame]", 108 | ) as NodeListOf, 109 | ); 110 | }); 111 | } else { 112 | setupForms( 113 | document.querySelectorAll( 114 | "form[data-frame]", 115 | ) as NodeListOf, 116 | ); 117 | } 118 | 119 | const missingUrlError = new Error( 120 | "[simple:frame] Unexpected missing frame URL. The simple-frame web component must be used with the Astro component.", 121 | ); 122 | -------------------------------------------------------------------------------- /packages/frame/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { AstroIntegration } from "astro"; 2 | import { addDts, addVirtualImports } from "astro-integration-kit"; 3 | 4 | const name = "simple:frame"; 5 | 6 | export default function simpleFrame(): AstroIntegration { 7 | return { 8 | name, 9 | hooks: { 10 | "astro:config:setup": (params) => { 11 | addVirtualImports(params, { 12 | name, 13 | imports: { 14 | "simple:frame": `export * from 'simple-stack-frame/components'; 15 | export * from 'simple-stack-frame/module';`, 16 | }, 17 | }); 18 | addDts(params, { 19 | name, 20 | content: `declare module "simple:frame" { 21 | export * from "simple-stack-frame/components"; 22 | export * from "simple-stack-frame/module"; 23 | }`, 24 | }); 25 | params.injectScript("page", 'import "simple-stack-frame/client";'); 26 | params.updateConfig({ 27 | vite: { 28 | define: { 29 | "import.meta.env.PAGES_DIR": JSON.stringify( 30 | new URL("src/pages", params.config.root).href, 31 | ), 32 | }, 33 | }, 34 | }); 35 | }, 36 | }, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /packages/frame/src/module.ts: -------------------------------------------------------------------------------- 1 | export function resolveProps>({ 2 | request, 3 | props, 4 | }: { 5 | request: Request; 6 | props: T; 7 | }): T { 8 | return { 9 | ...props, 10 | ...JSON.parse(request.headers.get("x-frame-props") ?? "{}"), 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /packages/frame/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "rootDir": "./src" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/query/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /blob-report/ 5 | /playwright/.cache/ 6 | -------------------------------------------------------------------------------- /packages/query/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # simple-stack-query 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - [#77](https://github.com/bholmesdev/simple-stack/pull/77) [`f1431d5`](https://github.com/bholmesdev/simple-stack/commit/f1431d56e6a25b8854b749614e5d8af865e33c82) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Revamps APIs to fix bugs and unlock a new suite of features. 8 | 9 | ```astro 10 | 11 | 12 | 13 | 14 | 21 | ``` 22 | 23 | - Support multiple instances of the same component. Before, only the first instance would become interactive. 24 | - Enable data passing from the server to your client script using the `data` property. 25 | - Add an `effect()` utility to interact with the [Signal polyfill](https://github.com/proposal-signals/signal-polyfill?tab=readme-ov-file#creating-a-simple-effect) for state management. 26 | 27 | [Visit revamped documentation page](https://simple-stack.dev/query) to learn how to use the new features. 28 | 29 | ## Migration for v0.1 30 | 31 | If you were an early adopter of v0.1, thank you! You'll a few small updates to use the new APIs: 32 | 33 | - Wrap any HTML you want to target with the global `RootElement` component. 34 | - Remove the `# simple-stack-query from your `data-target` selector (`data-target={$('btn')}`->`data-target="btn"`). Scoping is now handled automatically. 35 | - Change `$.ready()` to `RootElement.ready()`, and retrieve the `# simple-stack-query selector from the first function argument. The `# simple-stack-query selector is no longer a global. 36 | 37 | ```diff 38 | + 39 | - 43 | + 44 | 45 | 53 | ``` 54 | 55 | Since the syntax for `data-target` is now simpler, we have also **removed the VS Code snippets prompt.** We recommend deleting the snippets file created by v0.1: `.vscode/simple-query.code-snippets`. 56 | 57 | ## 0.1.1 58 | 59 | ### Patch Changes 60 | 61 | - [#75](https://github.com/bholmesdev/simple-stack/pull/75) [`56a4000`](https://github.com/bholmesdev/simple-stack/commit/56a4000810aed4ddb07f5d8fccd3b7e1c7c8bbd4) Thanks [@bholmesdev](https://github.com/bholmesdev)! - fixes issue where `$.ready` does not fire in Safari or Firefox when using Astro view transitions with `fallback="none"` 62 | 63 | ## 0.1.0 64 | 65 | ### Minor Changes 66 | 67 | - [#72](https://github.com/bholmesdev/simple-stack/pull/72) [`72e2630`](https://github.com/bholmesdev/simple-stack/commit/72e26309278afc4312fc1b477536c8999dba8e8a) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Change from a global `ready()` block from client scripts to a namespaced `$.ready()` block. Should be safer to avoid collisions with any local variables called `ready`. 68 | 69 | ### Patch Changes 70 | 71 | - [#74](https://github.com/bholmesdev/simple-stack/pull/74) [`20f1ab9`](https://github.com/bholmesdev/simple-stack/commit/20f1ab937f2d4210f62e0b386297690719f3517b) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add CLI prompt to add VS Code snippets in development. 72 | 73 | ## 0.0.3 74 | 75 | ### Patch Changes 76 | 77 | - Updated dependencies [[`7ff6c6d`](https://github.com/bholmesdev/simple-stack/commit/7ff6c6dc2f1aae9b26f574ec93aef1cc8014495b)]: 78 | - vite-plugin-simple-scope@2.0.2 79 | 80 | ## 0.0.2 81 | 82 | ### Patch Changes 83 | 84 | - [#68](https://github.com/bholmesdev/simple-stack/pull/68) [`017e3ea`](https://github.com/bholmesdev/simple-stack/commit/017e3ea9de946148b7c02ae1b63e360ef45e9a99) Thanks [@bholmesdev](https://github.com/bholmesdev)! - introduces the new `simple-stack-query` package, a simple library to query the DOM from your Astro components. 85 | 86 | ```astro 87 | 88 | 89 | 96 | ``` 97 | 98 | Visit the package README for more information. 99 | 100 | - Updated dependencies [[`017e3ea`](https://github.com/bholmesdev/simple-stack/commit/017e3ea9de946148b7c02ae1b63e360ef45e9a99)]: 101 | - vite-plugin-simple-scope@2.0.1 102 | -------------------------------------------------------------------------------- /packages/query/README.md: -------------------------------------------------------------------------------- 1 | # Simple stack query 2 | 3 | A simple library to query the DOM from your Astro components. 4 | 5 | ```astro 6 | 7 | 8 | 9 | 10 | 17 | ``` 18 | 19 | 📚 Visit [the docs](https://simple-stack.dev/query) for more information and usage examples. -------------------------------------------------------------------------------- /packages/query/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace RootElement { 2 | function ready>( 3 | callback: ( 4 | $: { 5 | (selector: string): T; 6 | self: HTMLElement; 7 | all(selector: string): Array; 8 | optional( 9 | selector: string, 10 | ): T | undefined; 11 | }, 12 | context: { 13 | effect: (callback: () => void | Promise) => void; 14 | data: T; 15 | abortSignal: AbortSignal; 16 | }, 17 | ) => void, 18 | ); 19 | } 20 | 21 | declare function RootElement>( 22 | props: import("astro/types").HTMLAttributes<"div"> & { data?: T }, 23 | ): any | Promise; 24 | -------------------------------------------------------------------------------- /packages/query/e2e/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { type PreviewServer, preview } from "astro"; 3 | import { generatePort, getPath } from "./utils"; 4 | 5 | const fixtureRoot = new URL("../fixtures/basic", import.meta.url).pathname; 6 | let previewServer: PreviewServer; 7 | 8 | test.beforeAll(async () => { 9 | previewServer = await preview({ 10 | root: fixtureRoot, 11 | server: { port: await generatePort() }, 12 | }); 13 | }); 14 | 15 | test.afterAll(async () => { 16 | await previewServer.stop(); 17 | }); 18 | 19 | test("loads client JS for heading", async ({ page }) => { 20 | await page.goto(getPath("", previewServer)); 21 | 22 | const h1 = page.getByTestId("heading"); 23 | await expect(h1).toContainText("Heading JS loaded"); 24 | }); 25 | 26 | test("reacts to button click", async ({ page }) => { 27 | await page.goto(getPath("button", previewServer)); 28 | 29 | const btn = page.getByRole("button"); 30 | 31 | await expect(btn).toHaveAttribute("data-ready"); 32 | await btn.click(); 33 | await expect(btn).toContainText("1"); 34 | }); 35 | 36 | test("reacts to button effect", async ({ page }) => { 37 | await page.goto(getPath("effect", previewServer)); 38 | 39 | const btn = page.getByRole("button"); 40 | 41 | await expect(btn).toHaveAttribute("data-ready"); 42 | await btn.click(); 43 | await expect(btn).toContainText("1"); 44 | const p = page.getByRole("paragraph"); 45 | await expect(p).toContainText("1"); 46 | }); 47 | 48 | test("respects server data", async ({ page }) => { 49 | await page.goto(getPath("server-data", previewServer)); 50 | 51 | const h1 = page.getByTestId("heading"); 52 | await expect(h1).toContainText("Server data"); 53 | }); 54 | 55 | test("reacts to multiple instances of button counter", async ({ page }) => { 56 | await page.goto(getPath("multi-counter", previewServer)); 57 | 58 | for (const testId of ["counter-1", "counter-2"]) { 59 | const counter = page.getByTestId(testId); 60 | const btn = counter.getByRole("button"); 61 | 62 | await expect(btn).toHaveAttribute("data-ready"); 63 | await expect(btn).toContainText("0"); 64 | await btn.click(); 65 | await expect(btn).toContainText("1"); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /packages/query/e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import net from "node:net"; 2 | import { type PreviewServer } from "astro"; 3 | 4 | export function isPortAvailable(port) { 5 | return new Promise((resolve) => { 6 | const server = net.createServer(); 7 | 8 | server.once("error", (err) => { 9 | if ("code" in err && err.code === "EADDRINUSE") { 10 | resolve(false); 11 | } 12 | }); 13 | 14 | server.once("listening", () => { 15 | server.close(); 16 | resolve(true); 17 | }); 18 | 19 | server.listen(port); 20 | }); 21 | } 22 | 23 | export async function generatePort() { 24 | const port = Math.floor(Math.random() * 1000) + 9000; 25 | if (await isPortAvailable(port)) return port; 26 | 27 | return generatePort(); 28 | } 29 | 30 | export function getPath(path: string, previewServer: PreviewServer) { 31 | return new URL(path, `http://localhost:${previewServer.port}/`).href; 32 | } 33 | -------------------------------------------------------------------------------- /packages/query/e2e/view-transitions.spec.ts: -------------------------------------------------------------------------------- 1 | import { Page, expect, test } from "@playwright/test"; 2 | import { type PreviewServer, preview } from "astro"; 3 | import { generatePort, getPath } from "./utils"; 4 | 5 | const fixtureRoot = new URL("../fixtures/view-transitions", import.meta.url) 6 | .pathname; 7 | let previewServer: PreviewServer; 8 | 9 | test.beforeAll(async () => { 10 | previewServer = await preview({ 11 | root: fixtureRoot, 12 | server: { port: await generatePort() }, 13 | }); 14 | }); 15 | 16 | test.afterAll(async () => { 17 | await previewServer.stop(); 18 | }); 19 | 20 | function viewTransitionTimeout(page: Page) { 21 | // Safari has an artificial delay for its view transition fallback 22 | return page.waitForTimeout(300); 23 | } 24 | 25 | test("is interactive on initial load", async ({ page }) => { 26 | await page.goto(getPath("page-1", previewServer)); 27 | 28 | const btn = page.getByRole("button"); 29 | 30 | await expect(btn).toHaveAttribute("data-ready"); 31 | await btn.click(); 32 | await expect(btn).toContainText("1"); 33 | }); 34 | 35 | test("is interactive on navigation", async ({ page }) => { 36 | await page.goto(getPath("page-1", previewServer)); 37 | 38 | const btn1 = page.getByRole("button"); 39 | await expect(btn1).toHaveAttribute("data-ready"); 40 | 41 | const a = page.getByRole("link"); 42 | await a.click(); 43 | await page.waitForURL("**/page-2"); 44 | 45 | const btn2 = page.getByRole("button"); 46 | await viewTransitionTimeout(page); 47 | await btn2.click(); 48 | await expect(btn2).toContainText("2"); 49 | }); 50 | 51 | test("is interactive navigating back to previous page", async ({ page }) => { 52 | await page.goto(getPath("page-1", previewServer)); 53 | 54 | const btnBefore = page.getByRole("button"); 55 | await expect(btnBefore).toHaveAttribute("data-ready"); 56 | await btnBefore.click(); 57 | await expect(btnBefore).toContainText("1"); 58 | 59 | const a = page.getByRole("link"); 60 | await a.click(); 61 | await page.waitForURL("**/page-2"); 62 | await viewTransitionTimeout(page); 63 | 64 | const aBack = page.getByRole("link"); 65 | await aBack.click(); 66 | await page.waitForURL("**/page-1"); 67 | 68 | const btnAfter = page.getByRole("button"); 69 | await viewTransitionTimeout(page); 70 | await btnAfter.click(); 71 | await expect(btnAfter).toContainText("1"); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/.gitignore: -------------------------------------------------------------------------------- 1 | .astro/ -------------------------------------------------------------------------------- /packages/query/fixtures/basic/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import simpleStackQuery from "simple-stack-query"; 3 | 4 | export default defineConfig({ 5 | integrations: [simpleStackQuery()], 6 | }); 7 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@test/query-basic", 3 | "private": true, 4 | "scripts": { 5 | "dev": "astro dev", 6 | "build": "astro build", 7 | "preview": "astro preview", 8 | "astro": "astro" 9 | }, 10 | "dependencies": { 11 | "astro": "^4.12.0", 12 | "signal-polyfill": "^0.1.2", 13 | "simple-stack-query": "workspace:*", 14 | "typescript": "^5.5.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/components/Counter.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/components/WithContext.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | scope: typeof import("simple:scope").scope; 4 | }; 5 | 6 | const { scope } = Astro.props; 7 | --- 8 | 9 |

With context

-------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/pages/button.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Counter from "../components/Counter.astro"; 3 | --- 4 | 5 |

Counter

6 | 7 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/pages/effect.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 |

The count is 0

4 |
5 | 6 | 22 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | 2 |

Heading JS loading

3 |
4 | 5 | 10 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/pages/multi-counter.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Counter from "../components/Counter.astro"; 3 | --- 4 | 5 |
6 | 7 |
8 | 9 |
10 | 11 |
-------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/pages/scope-prop.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { scope } from "simple:scope"; 3 | import WithContext from "../components/WithContext.astro"; 4 | --- 5 | 6 |

Scope prop

7 | 8 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/src/pages/server-data.astro: -------------------------------------------------------------------------------- 1 | 2 |

Awaiting message

3 |
4 | 5 | 10 | -------------------------------------------------------------------------------- /packages/query/fixtures/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/.astro/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "_variables": { 3 | "lastUpdateCheck": 1722171998489 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import simpleStackQuery from "simple-stack-query"; 3 | 4 | export default defineConfig({ 5 | integrations: [simpleStackQuery()], 6 | }); 7 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@test/query-view-transitions", 3 | "private": true, 4 | "scripts": { 5 | "dev": "astro dev", 6 | "build": "astro build", 7 | "preview": "astro preview", 8 | "astro": "astro" 9 | }, 10 | "dependencies": { 11 | "astro": "^4.12.0", 12 | "signal-polyfill": "^0.1.2", 13 | "simple-stack-query": "workspace:*", 14 | "typescript": "^5.5.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/src/components/Counter.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | initialCount?: number; 4 | }; 5 | 6 | const { initialCount = 0 } = Astro.props; 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { ViewTransitions } from "astro:transitions"; 3 | 4 | type Props = { 5 | title: string; 6 | }; 7 | 8 | const { title } = Astro.props; 9 | --- 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {title} 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/src/pages/page-1.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Counter from "../components/Counter.astro"; 3 | import Layout from "../layouts/Layout.astro"; 4 | --- 5 | 6 | 7 | Go to page 2 8 | 9 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/src/pages/page-2.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Counter from "../components/Counter.astro"; 3 | import Layout from "../layouts/Layout.astro"; 4 | --- 5 | 6 | 7 | Go to page 1 8 | 9 | -------------------------------------------------------------------------------- /packages/query/fixtures/view-transitions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /packages/query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-stack-query", 3 | "version": "0.2.0", 4 | "description": "Select elements in Astro without the hassle", 5 | "type": "module", 6 | "scripts": { 7 | "test": "node --test test", 8 | "e2e": "pnpm -r --filter=\"./fixtures/**\" build && pnpm exec playwright test", 9 | "test:watch": "node --test --watch test" 10 | }, 11 | "exports": { 12 | ".": "./src/index.ts", 13 | "./internal": "./src/internal.ts", 14 | "./effect": "./src/effect.ts", 15 | "./internal.server": "./src/internal.server.ts" 16 | }, 17 | "keywords": ["withastro", "astro-integration"], 18 | "files": ["src", "ambient.d.ts"], 19 | "author": "bholmesdev", 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/bholmesdev/simple-stack.git", 24 | "directory": "packages/query" 25 | }, 26 | "dependencies": { 27 | "@clack/prompts": "^0.7.0", 28 | "kleur": "^4.1.5", 29 | "vite-plugin-simple-scope": "workspace:*" 30 | }, 31 | "engines": { 32 | "node": "^18.17.1 || ^20.3.0 || >=21.0.0" 33 | }, 34 | "devDependencies": { 35 | "@playwright/test": "^1.45.3", 36 | "@types/node": "^20.14.12", 37 | "astro": "^4.12.0", 38 | "linkedom": "^0.18.4", 39 | "signal-polyfill": "^0.1.2", 40 | "typescript": "^5.5.3", 41 | "vite": "^5.0.10" 42 | }, 43 | "peerDependencies": { 44 | "signal-polyfill": "^0.1.2" 45 | }, 46 | "peerDependenciesMeta": { 47 | "signal-polyfill": { 48 | "optional": true 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/query/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | export default defineConfig({ 14 | testDir: "./e2e", 15 | /* Run tests in files in parallel */ 16 | fullyParallel: false, 17 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 18 | forbidOnly: !!process.env.CI, 19 | /* Retry on CI only */ 20 | retries: process.env.CI ? 2 : 0, 21 | /* Opt out of parallel tests on CI. */ 22 | workers: process.env.CI ? 1 : undefined, 23 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 24 | reporter: "html", 25 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 26 | use: { 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | // baseURL: 'http://127.0.0.1:3000', 29 | 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: "on-first-retry", 32 | }, 33 | 34 | /* Configure projects for major browsers */ 35 | projects: [ 36 | { 37 | name: "chromium", 38 | use: { ...devices["Desktop Chrome"] }, 39 | }, 40 | 41 | { 42 | name: "firefox", 43 | use: { ...devices["Desktop Firefox"] }, 44 | }, 45 | 46 | { 47 | name: "webkit", 48 | use: { ...devices["Desktop Safari"] }, 49 | }, 50 | 51 | /* Test against mobile viewports. */ 52 | // { 53 | // name: 'Mobile Chrome', 54 | // use: { ...devices['Pixel 5'] }, 55 | // }, 56 | // { 57 | // name: 'Mobile Safari', 58 | // use: { ...devices['iPhone 12'] }, 59 | // }, 60 | 61 | /* Test against branded browsers. */ 62 | // { 63 | // name: 'Microsoft Edge', 64 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 65 | // }, 66 | // { 67 | // name: 'Google Chrome', 68 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 69 | // }, 70 | ], 71 | 72 | /* Run your local dev server before starting the tests */ 73 | // webServer: { 74 | // command: 'npm run start', 75 | // url: 'http://127.0.0.1:3000', 76 | // reuseExistingServer: !process.env.CI, 77 | // }, 78 | }); 79 | -------------------------------------------------------------------------------- /packages/query/src/effect.ts: -------------------------------------------------------------------------------- 1 | // Sample effect implementation from signal-polyfill: 2 | // https://github.com/proposal-signals/signal-polyfill?tab=readme-ov-file#creating-a-simple-effect 3 | 4 | import { Signal } from "signal-polyfill"; 5 | 6 | let needsEnqueue = true; 7 | 8 | const w = new Signal.subtle.Watcher(() => { 9 | if (needsEnqueue) { 10 | needsEnqueue = false; 11 | queueMicrotask(processPending); 12 | } 13 | }); 14 | 15 | function processPending() { 16 | needsEnqueue = true; 17 | 18 | for (const s of w.getPending()) { 19 | s.get(); 20 | } 21 | 22 | w.watch(); 23 | } 24 | 25 | export type MaybePromise = T | Promise; 26 | export type CleanupCallback = () => MaybePromise; 27 | 28 | export function effect( 29 | this: { signal?: AbortSignal }, 30 | callback: () => MaybePromise, 31 | ) { 32 | let cleanup: undefined | CleanupCallback; 33 | 34 | const computed = new Signal.Computed(async () => { 35 | typeof cleanup === "function" && (await cleanup()); 36 | cleanup = await callback(); 37 | }); 38 | 39 | w.watch(computed); 40 | computed.get(); 41 | 42 | this.signal?.addEventListener( 43 | "abort", 44 | () => { 45 | w.unwatch(computed); 46 | typeof cleanup === "function" && cleanup(); 47 | cleanup = undefined; 48 | }, 49 | { once: true }, 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/query/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/query/src/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { createRequire } from "node:module"; 4 | import type { AstroConfig, AstroIntegration } from "astro"; 5 | import vitePluginSimpleScope from "vite-plugin-simple-scope"; 6 | 7 | type VitePlugin = Required["plugins"][number]; 8 | 9 | export default function simpleStackQueryIntegration(): AstroIntegration { 10 | return { 11 | name: "simple-stack-query", 12 | hooks: { 13 | async "astro:config:setup"(params) { 14 | params.updateConfig({ 15 | vite: { 16 | plugins: [ 17 | vitePlugin({ root: params.config.root }), 18 | vitePluginSimpleScope(), 19 | ], 20 | }, 21 | }); 22 | }, 23 | }, 24 | }; 25 | } 26 | 27 | const dataTargetRegex = /data-target="(.*?)"/g; 28 | 29 | function vitePlugin({ root }: { root: URL }): VitePlugin { 30 | return { 31 | name: "simple-stack-query", 32 | transform(code, id) { 33 | const [baseId, search] = id.split("?"); 34 | if (!baseId?.endsWith(".astro")) return; 35 | 36 | const isAstroFrontmatter = !search; 37 | 38 | if (isAstroFrontmatter) { 39 | const codeWithTargetsReplaced = code.replace( 40 | dataTargetRegex, 41 | (_, target) => { 42 | return `data-target=\${__scope(${JSON.stringify(target)})}`; 43 | }, 44 | ); 45 | return ` 46 | import { scope as __scope } from 'simple:scope'; 47 | import * as __internals from 'simple-stack-query/internal.server'; 48 | 49 | const RootElement = __internals.createRootElement(__scope);\n${codeWithTargetsReplaced}`; 50 | } 51 | 52 | const searchParams = new URLSearchParams(search); 53 | if (!searchParams.has("lang.ts")) return; 54 | 55 | return ` 56 | import { scope as __scope } from 'simple:scope'; 57 | import * as __internals from "simple-stack-query/internal"; 58 | ${hasSignalPolyfill(root) ? `import { effect as __effect } from "simple-stack-query/effect";` : "const __effect = undefined;"} 59 | 60 | const RootElement = __internals.createRootElement(__scope, __effect);\n${code}`; 61 | }, 62 | }; 63 | } 64 | 65 | function hasSignalPolyfill(root: URL) { 66 | const require = createRequire(root); 67 | try { 68 | require.resolve("signal-polyfill"); 69 | return true; 70 | } catch { 71 | return false; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/query/src/internal.server.ts: -------------------------------------------------------------------------------- 1 | import { type scope as scopeFn } from "simple:scope"; 2 | import { 3 | createComponent, 4 | render, 5 | renderComponent, 6 | } from "astro/runtime/server/index.js"; 7 | 8 | export const createRootElement = (scope: typeof scopeFn) => 9 | createComponent({ 10 | factory(result, { data, ...props }, slots) { 11 | return render`${renderComponent( 12 | result, 13 | "RootElement", 14 | `simple-query-root-${scope()}`, 15 | { 16 | style: props.class ? "" : "display: contents", 17 | ...props, 18 | "data-stringified": JSON.stringify(data), 19 | }, 20 | slots, 21 | )}`; 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/query/src/internal.ts: -------------------------------------------------------------------------------- 1 | import type { scope as scopeFn } from "simple:scope"; 2 | import type { 3 | CleanupCallback, 4 | MaybePromise, 5 | effect as effectFn, 6 | } from "./effect.js"; 7 | 8 | type ReadyCallback = ( 9 | $: any, 10 | context: { 11 | effect: typeof effectFn; 12 | data: any; 13 | abortSignal: AbortSignal; 14 | }, 15 | ) => MaybePromise; 16 | 17 | export function createRootElement( 18 | scope: typeof scopeFn, 19 | effect: typeof effectFn = () => { 20 | throw new Error( 21 | "Unable to call `effect()`. To use this function, install the `signal-polyfill` package.", 22 | ); 23 | }, 24 | ) { 25 | return { 26 | ready(callback: ReadyCallback) { 27 | window.customElements.define( 28 | `simple-query-root-${scope()}`, 29 | createRootElementClass(scope, effect, callback), 30 | ); 31 | }, 32 | }; 33 | } 34 | 35 | export function createRootElementClass( 36 | scope: typeof scopeFn, 37 | effect: typeof effectFn, 38 | readyCallback: ReadyCallback, 39 | ) { 40 | return class extends HTMLElement { 41 | #cleanupCallback?: CleanupCallback; 42 | 43 | #abortController = new AbortController(); 44 | abortSignal = this.#abortController.signal; 45 | 46 | async connectedCallback() { 47 | const stringifiedData = this.getAttribute("data-stringified")!; 48 | const $ = create$(this, scope); 49 | const data = JSON.parse(stringifiedData); 50 | 51 | this.#cleanupCallback = await readyCallback($, { 52 | effect: effect.bind({ signal: this.abortSignal }), 53 | data, 54 | abortSignal: this.abortSignal, 55 | }); 56 | } 57 | 58 | disconnectedCallback() { 59 | this.#cleanupCallback?.(); 60 | this.#abortController.abort(); 61 | } 62 | }; 63 | } 64 | 65 | function create$(self: HTMLElement, scope: typeof scopeFn) { 66 | function getSelector(scopeId: string) { 67 | return `[data-target=${JSON.stringify(scope(scopeId))}]`; 68 | } 69 | function $(scopeId: string) { 70 | const selector = getSelector(scopeId); 71 | const element = self.querySelector(selector); 72 | if (!element) throw new Error(`Element not found: ${selector}`); 73 | return element; 74 | } 75 | Object.assign($, { 76 | self, 77 | optional(scopeId: string) { 78 | const selector = getSelector(scopeId); 79 | return self.querySelector(selector) ?? undefined; 80 | }, 81 | all(scopeId: string) { 82 | const selector = getSelector(scopeId); 83 | return [...self.querySelectorAll(selector)]; 84 | }, 85 | }); 86 | return $; 87 | } 88 | -------------------------------------------------------------------------------- /packages/query/test/data-target.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { after, before, describe, it } from "node:test"; 3 | import { dev } from "astro"; 4 | import { DOMParser } from "linkedom"; 5 | 6 | const parser = new DOMParser(); 7 | 8 | describe("data-target", () => { 9 | describe("dev", () => { 10 | /** @type {Awaited>} */ 11 | let server; 12 | 13 | before(async () => { 14 | server = await dev({ 15 | root: new URL("../fixtures/basic", import.meta.url), 16 | }); 17 | }); 18 | 19 | after(() => { 20 | server.stop(); 21 | }); 22 | 23 | function getPath(path = "") { 24 | return new URL(path, `http://localhost:${server.address.port}/`).href; 25 | } 26 | 27 | it("should generate scoped id", async () => { 28 | const res = await fetch(getPath()); 29 | const html = await res.text(); 30 | 31 | assert.ok(res.ok); 32 | const h1 = parser.parseFromString(html, "text/html").querySelector("h1"); 33 | assert.match(h1?.getAttribute("data-target"), /^heading-\w+/); 34 | }); 35 | 36 | it("should generate a different scoped id for nested components", async () => { 37 | const res = await fetch(getPath("button")); 38 | const html = await res.text(); 39 | 40 | assert.ok(res.ok); 41 | const h1 = parser.parseFromString(html, "text/html").querySelector("h1"); 42 | const scopeHash = h1?.getAttribute("data-target").split("-")[1]; 43 | 44 | const nestedButton = parser 45 | .parseFromString(html, "text/html") 46 | .querySelector("button"); 47 | const nestedScopeHash = nestedButton 48 | ?.getAttribute("data-target") 49 | .split("-")[1]; 50 | 51 | assert.notEqual(scopeHash, nestedScopeHash); 52 | }); 53 | 54 | it("allows passing scope as a prop", async () => { 55 | const res = await fetch(getPath("scope-prop")); 56 | const html = await res.text(); 57 | 58 | assert.ok(res.ok); 59 | const h1 = parser.parseFromString(html, "text/html").querySelector("h1"); 60 | const scopeHash = h1?.getAttribute("data-target").split("-")[1]; 61 | 62 | const h3 = parser.parseFromString(html, "text/html").querySelector("h3"); 63 | const contextScopeHash = h3?.getAttribute("data-target").split("-")[1]; 64 | 65 | assert.equal(scopeHash, contextScopeHash); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/query/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/scope/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # vite-plugin-simple-scope 2 | 3 | ## 2.0.2 4 | 5 | ### Patch Changes 6 | 7 | - [#70](https://github.com/bholmesdev/simple-stack/pull/70) [`7ff6c6d`](https://github.com/bholmesdev/simple-stack/commit/7ff6c6dc2f1aae9b26f574ec93aef1cc8014495b) Thanks [@bholmesdev](https://github.com/bholmesdev)! - fixes "failed to resolve entry" error on dev server start. 8 | 9 | ## 2.0.1 10 | 11 | ### Patch Changes 12 | 13 | - [#68](https://github.com/bholmesdev/simple-stack/pull/68) [`017e3ea`](https://github.com/bholmesdev/simple-stack/commit/017e3ea9de946148b7c02ae1b63e360ef45e9a99) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix type error applying the Simple scope plugin to a `.ts` config file. 14 | 15 | ## 2.0.0 16 | 17 | ### Major Changes 18 | 19 | - [#25](https://github.com/bholmesdev/simple-stack/pull/25) [`054fe3c`](https://github.com/bholmesdev/simple-stack/commit/054fe3cfa8c5640359b6ce7e29ec11e910aa9d36) Thanks [@dsnjunior](https://github.com/dsnjunior)! - Generate Scope IDs deterministically. This means IDs will match between development and production builds. 20 | 21 | ## 1.0.4 22 | 23 | ### Patch Changes 24 | 25 | - [#16](https://github.com/bholmesdev/simple-stack/pull/16) [`bfd7834`](https://github.com/bholmesdev/simple-stack/commit/bfd783467eb3e9f39d014a59316e8715d290e76d) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Update package.json to use new github URL 26 | -------------------------------------------------------------------------------- /packages/scope/README.md: -------------------------------------------------------------------------------- 1 | # simple scope 🔎 2 | 3 | > Get a scoped ID for whatever file you're in. Resolved at build-time with zero client JS. 4 | 5 | ```jsx 6 | import { scope } from 'simple:scope'; 7 | 8 | function Form() { 9 | return ( 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | /* 18 | Output: 19 | 20 |
21 | 22 | 23 |
24 | */ 25 | ``` 26 | 27 | 📚 Visit [the docs](https://simple-stack.dev/scope) for more information and usage examples. 28 | -------------------------------------------------------------------------------- /packages/scope/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare module "simple:scope" { 2 | export function scope(prefix?: string): string; 3 | } 4 | -------------------------------------------------------------------------------- /packages/scope/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-simple-scope", 3 | "version": "2.0.2", 4 | "description": "Get a scoped ID for whatever file you're in. Zero client-side JS.", 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts", 8 | "./types": "./ambient.d.ts" 9 | }, 10 | "files": ["src", "ambient.d.ts"], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/bholmesdev/simple-stack.git", 14 | "directory": "packages/scope" 15 | }, 16 | "dependencies": { 17 | "typescript": "^5.5.3", 18 | "vite": "^5.0.10" 19 | }, 20 | "author": "bholmesdev", 21 | "license": "MIT", 22 | "engines": { 23 | "node": ">=18.14.1" 24 | }, 25 | "devDependencies": { 26 | "astro": "^4.12.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/scope/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "node:crypto"; 2 | import type { AstroConfig } from "astro"; 3 | import { normalizePath } from "vite"; 4 | import "../ambient.d.ts"; 5 | 6 | type VitePlugin = Required["plugins"][number]; 7 | 8 | const virtualMod = "simple:scope"; 9 | 10 | export default function vitePluginSimpleScope(): VitePlugin { 11 | const scopeIdByImporter: Record = {}; 12 | 13 | return { 14 | name: "vite-plugin-simple-scope", 15 | resolveId(id, rawImporter) { 16 | if (id !== virtualMod || !rawImporter) return; 17 | 18 | const importer = getBaseFilePath(rawImporter); 19 | if (!scopeIdByImporter[importer]) { 20 | scopeIdByImporter[importer] = createScopeHash(importer); 21 | } 22 | return `${virtualMod}/${scopeIdByImporter[importer]}`; 23 | }, 24 | async load(id) { 25 | const [maybeVirtualMod, scopeId] = id.split("/"); 26 | if (maybeVirtualMod !== virtualMod || !scopeId) return; 27 | 28 | return `const scopeId = ${JSON.stringify(scopeId)}; 29 | export function scope(id) { 30 | if (!id) return scopeId; 31 | 32 | return id + '-' + scopeId; 33 | }`; 34 | }, 35 | }; 36 | } 37 | 38 | function createScopeHash(filename: string) { 39 | return createHash("shake256", { outputLength: 4 }) 40 | .update(normalizeFilename(filename)) 41 | .digest("hex"); 42 | } 43 | 44 | function normalizeFilename(filename: string) { 45 | const normalizedFilename = normalizePath(filename); 46 | const normalizedRoot = normalizePath(process.cwd()); 47 | if (normalizedFilename.startsWith(normalizedRoot)) { 48 | return normalizedFilename.slice(normalizedRoot.length - 1); 49 | } 50 | 51 | return normalizedFilename; 52 | } 53 | 54 | /** 55 | * Vite supports file search params with `?`. 56 | * Trim these off to get the base file path. 57 | */ 58 | function getBaseFilePath(filePath: string) { 59 | return filePath.replace(/\?.*$/, ""); 60 | } 61 | -------------------------------------------------------------------------------- /packages/scope/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "rootDir": "./src" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/stream/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # simple-stack-stream 2 | 3 | ## 0.3.4 4 | 5 | ### Patch Changes 6 | 7 | - [#64](https://github.com/bholmesdev/simple-stack/pull/64) [`51c5ae0`](https://github.com/bholmesdev/simple-stack/commit/51c5ae024867777407d0fcc5359d35fc2de7d65d) Thanks [@Mupu](https://github.com/Mupu)! - Fix unhandled crash when a connection is closed before Suspense components are streamed. 8 | 9 | ## 0.3.3 10 | 11 | ### Patch Changes 12 | 13 | - [#50](https://github.com/bholmesdev/simple-stack/pull/50) [`25bf781`](https://github.com/bholmesdev/simple-stack/commit/25bf7816a81457a755a06afb5ad96b8e711db24b) Thanks [@jonathantneal](https://github.com/jonathantneal)! - Change Suspense wrapper from a `
` to a ``. This ensures inline HTML content is correctly parsed. 14 | 15 | ## 0.3.2 16 | 17 | ### Patch Changes 18 | 19 | - [#55](https://github.com/bholmesdev/simple-stack/pull/55) [`44460a5`](https://github.com/bholmesdev/simple-stack/commit/44460a55aa99c40d23c15473b523bd4e64497d37) Thanks [@parrad0](https://github.com/parrad0)! - Fix missing `text/html` header on Astro pages when using the simple-stack-stream middleware. 20 | 21 | ## 0.3.1 22 | 23 | ### Patch Changes 24 | 25 | - [#52](https://github.com/bholmesdev/simple-stack/pull/52) [`314eac6`](https://github.com/bholmesdev/simple-stack/commit/314eac6ee074f07d6abd34427de209e9bd5e80fd) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Handle cases where child Suspense boundaries resolve faster than their parent. 26 | 27 | ## 0.3.0 28 | 29 | ### Minor Changes 30 | 31 | - [#46](https://github.com/bholmesdev/simple-stack/pull/46) [`edfe2a7`](https://github.com/bholmesdev/simple-stack/commit/edfe2a761b55fab26a757e6b18e90a0bf0094e74) Thanks [@lubieowoce](https://github.com/lubieowoce)! - Don't show a fallback if the content renders quickly enough (current timeout: 5ms) 32 | 33 | ### Patch Changes 34 | 35 | - [#46](https://github.com/bholmesdev/simple-stack/pull/46) [`edfe2a7`](https://github.com/bholmesdev/simple-stack/commit/edfe2a761b55fab26a757e6b18e90a0bf0094e74) Thanks [@lubieowoce](https://github.com/lubieowoce)! - Reduced the size of the ``; 152 | 153 | for await (const { chunk, id } of stream) { 154 | yield `` + 155 | ``; 156 | 157 | pendingChunks.delete(id); 158 | if (!pendingChunks.size) return streamController.close(); 159 | } 160 | } 161 | 162 | // @ts-expect-error generator not assignable to ReadableStream 163 | return new Response(render(), { headers: response.headers }); 164 | }); 165 | -------------------------------------------------------------------------------- /packages/stream/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | 3 | export const sleep = (ms: number) => 4 | new Promise((resolve) => setTimeout(resolve, ms)); 5 | 6 | export const SuspenseStorage = new AsyncLocalStorage<{ 7 | id: number; 8 | }>(); 9 | 10 | export function fallbackMarkerStart(id: number) { 11 | return ``; 12 | } 13 | 14 | export function fallbackMarkerEnd(id: number) { 15 | return ``; 16 | } 17 | -------------------------------------------------------------------------------- /packages/stream/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "rootDir": "./src" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/**/* 3 | - examples/* 4 | - www/ -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "isolatedModules": true, 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "noFallthroughCasesInSwitch": true, 10 | "noUncheckedIndexedAccess": true, 11 | "preserveWatchOutput": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "target": "ESNext", 15 | "declaration": true, 16 | "declarationMap": true, 17 | "sourceMap": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "dev": { 9 | "cache": false, 10 | "persistent": true 11 | }, 12 | "test": { 13 | "dependsOn": ["^build"] 14 | }, 15 | "e2e": { 16 | "dependsOn": ["^build"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /www/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import starlight from "@astrojs/starlight"; 3 | 4 | export default defineConfig({ 5 | integrations: [ 6 | starlight({ 7 | title: "Simple Stack 🌱", 8 | social: { 9 | github: "https://github.com/bholmesdev/simple-stack", 10 | discord: "https://wtw.dev/chat", 11 | }, 12 | sidebar: [ 13 | { 14 | label: "🔎 Query", 15 | link: "/query", 16 | }, 17 | { 18 | label: "🌊 Stream", 19 | link: "/stream", 20 | }, 21 | { 22 | label: "🧘‍♂️ Form", 23 | autogenerate: { directory: "form" }, 24 | }, 25 | { 26 | label: "🔎 Scope", 27 | link: "/scope", 28 | }, 29 | ], 30 | customCss: [ 31 | "@fontsource/atkinson-hyperlegible/400.css", 32 | "@fontsource/atkinson-hyperlegible/700.css", 33 | "./src/styles/custom.css", 34 | ], 35 | }), 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 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 | "@astrojs/check": "^0.5.9", 15 | "@astrojs/starlight": "^0.21.1", 16 | "@fontsource/atkinson-hyperlegible": "^5.0.18", 17 | "astro": "^4.5.5", 18 | "sharp": "^0.32.5", 19 | "typescript": "^5.3.3" 20 | }, 21 | "packageManager": "pnpm@8.8.0" 22 | } 23 | -------------------------------------------------------------------------------- /www/public/assets/astro-partial-rendering-demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bholmesdev/simple-stack/71994d8bbbcb5951583f5db0fd8adfdd22e76991/www/public/assets/astro-partial-rendering-demo.mp4 -------------------------------------------------------------------------------- /www/public/assets/simple-stream-intro.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bholmesdev/simple-stack/71994d8bbbcb5951583f5db0fd8adfdd22e76991/www/public/assets/simple-stream-intro.mov -------------------------------------------------------------------------------- /www/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 🌱 -------------------------------------------------------------------------------- /www/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | i18n: defineCollection({ type: 'data', schema: i18nSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /www/src/content/docs/form/client.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Add client validation 3 | description: Add client validation to your forms 4 | sidebar: 5 | order: 3 6 | --- 7 | 8 | simple form helps generate a client-validated form in your framework of choice. 9 | 10 | > ⚠️ When using Astro, client validation relies on Astro view transitions. Ensure [view transitions are enabled](https://docs.astro.build/en/guides/view-transitions/#adding-view-transitions-to-a-page) on your page. 11 | 12 | ## Create a form with the `simple-form` CLI 13 | 14 | You can generate a client form component with the `simple-form create` command: 15 | 16 | ```bash 17 | # npm 18 | npx simple-form create 19 | 20 | # pnpm 21 | pnpm dlx simple-form create 22 | ``` 23 | 24 | This will output form and input components in your directory of choice. 25 | 26 |
27 | 🙋‍♀️ Why code generation? 28 | 29 | We know form libraries have [come](https://react-hook-form.com/) and [gone](https://formik.org/) over the years. We think the reason is _ahem_ simple: **forms are just hard.** There's countless pieces to tweak, from debounced inputs to live vs. delayed validation to styling your components. 30 | 31 | So, we decided to take a hint from the popular [shadcn/ui](https://ui.shadcn.com/docs/theming) library and pass the code off to you. 32 | 33 | We expose internal functions to manage your form state and handle both synchronous and asynchronous validation. Then, we generate components with accessible defaults based on [the "Reward now, punish late" pattern.](https://www.smashingmagazine.com/2022/09/inline-validation-web-forms-ux/#4-reward-early-punish-late) We invite you to tweak and override the code from here! 34 | 35 |
36 | 37 | ## Reward early, punish late pattern 38 | 39 | Simple form uses the "reward early, punish late" pattern for input validation. 40 | 41 | - **Punish late:** Only show error messaging when the user has moved on to the next input. This uses the [input `blur` event.](https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event) 42 | - **Reward early:** Remove error messaging the moment the user corrects an error. This uses the [input `change` event.](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event) 43 | 44 | This is simple form's implementation of the pattern using React: 45 | 46 | ```tsx 47 | { 49 | const value = e.target.value; 50 | // Check if the input has errored before. 51 | // If so, enter live validation mode (reward early) 52 | if (!hasErroredOnce) return; 53 | formContext.validateField(value); 54 | }} 55 | // Validate once the user blurs the input (punish late) 56 | onBlur={async (e) => { 57 | const value = e.target.value; 58 | // Exception: Avoid validating empty inputs on blur. 59 | // It's best to hide "required" errors 60 | // until the user submits the form. 61 | if (value === "") return; 62 | formContext.validateField(value); 63 | }} 64 | /> 65 | ``` 66 | 67 | You will likely find exceptions to this pattern, like live password rules or postal code suggestions. This is why simple form uses code generation! You are free to copy generated code and massage to your intended use case. 68 | 69 | **Sources** 70 | 71 | - [📚 Vitaly Friedman on form validation UX](https://www.smashingmagazine.com/2022/09/inline-validation-web-forms-ux/#4-reward-early-punish-late) 72 | 73 | ## Online playground 74 | 75 | We recommend our online playgrounds to try client validation in your framework of choice: 76 | 77 | - [Astro playground](https://stackblitz.com/github/bholmesdev/simple-stack/tree/main/examples/playground) 78 | - [Next.js playground](https://stackblitz.com/github/bholmesdev/simple-stack/tree/main/examples/nextjs-app-router) 79 | -------------------------------------------------------------------------------- /www/src/content/docs/form/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 🧘‍♂️ Simple form 3 | description: The simple way to validate forms in your fullstack app. 4 | sidebar: 5 | label: Get started 6 | order: 1 7 | --- 8 | 9 | import { Tabs, TabItem } from '@astrojs/starlight/components'; 10 | 11 | The simple way to validate forms in your fullstack app. 12 | 13 | ```astro 14 | --- 15 | // src/pages/index.astro 16 | 17 | import { z } from "zod"; 18 | import { createForm } from "simple:form"; 19 | 20 | const checkout = createForm({ 21 | quantity: z.number(), 22 | email: z.string().email(), 23 | allowAlerts: z.boolean(), 24 | }); 25 | 26 | const result = await Astro.locals.form.getData(checkout); 27 | 28 | if (result?.data) { 29 | await myDb.insert(result.data); 30 | // proceed to checkout 31 | } 32 | --- 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | ``` 45 | 46 | ## Installation 47 | 48 | 49 | 50 | 51 | 52 | Simple form is an Astro integration. You can install and configure this via the Astro CLI using `astro add`: 53 | 54 | ```bash 55 | npm run astro add simple-stack-form 56 | ``` 57 | 58 | After installing, you'll need to add a type definition to your environment for editor hints. Add this reference to a new or existing `src/env.d.ts` file: 59 | 60 | ```ts 61 | // env.d.ts 62 | /// 63 | ``` 64 | 65 | 66 | 67 | 68 | Simple form can be used with any framework. You can install it via npm: 69 | 70 | ```bash 71 | # npm 72 | npm i simple-stack-form 73 | 74 | # pnpm 75 | pnpm i simple-stack-form 76 | ``` 77 | 78 | 79 | 80 | 81 | ## Create a validated form 82 | 83 | **Type:** `createForm(ZodRawShape): { inputProps: Record, validator: ZodRawShape }` 84 | 85 | You can create a simple form with the `createForm()` function. This lets you specify a validation schema using [Zod](https://zod.dev/), where each input corresponds to an object key. Simple form supports string, number, or boolean (checkbox) fields. 86 | 87 | ```ts 88 | import { createForm } from "simple:form"; 89 | import z from "zod"; 90 | 91 | const signupForm = createForm({ 92 | name: z.string(), 93 | age: z.number().min(18).optional(), 94 | newsletterOptIn: z.boolean(), 95 | }); 96 | ``` 97 | 98 | `createForm()` returns both a validator and the `inputProps` object. `inputProps` converts each key of your validator to matching HTML props / attributes. The following props are generated today: 99 | 100 | - `name` - the object key. 101 | - `type` - `checkbox` for booleans, `number` for numbers, and `text` for strings. 102 | - `aria-required` - `true` by default, `false` when `.optional()` is used. Note `aria-required` is used to add semantic meaning for screenreaders, but leave room to add a custom error banner. 103 | 104 | Our `signupForm` example generates the following `inputProps` object: 105 | 106 | ```ts 107 | const signupForm = createForm({ 108 | name: z.string(), 109 | age: z.number().min(18).optional(), 110 | newsletterOptIn: z.boolean(), 111 | }); 112 | 113 | signupForm.inputProps; 114 | /* 115 | name: { name: 'name', type: 'text', 'aria-required': true } 116 | age: { name: 'age', type: 'number', 'aria-required': false } 117 | newsletterOptIn: { name: 'newsletterOptIn', type: 'checkbox', 'aria-required': true } 118 | */ 119 | ``` 120 | 121 | ### Handle array values 122 | 123 | You may want to submit multiple form values under the same name. This is common for multi-select file inputs, or generated inputs like "add a second contact." 124 | 125 | You can aggregate values under the same name using `z.array()` in your validator: 126 | 127 | ```ts 128 | import { createForm } from "simple:form"; 129 | import z from "zod"; 130 | 131 | const contact = createForm({ 132 | contactNames: z.array(z.string()), 133 | }); 134 | ``` 135 | 136 | Now, all inputs with the name `contactNames` will be aggregated. This [uses `FormData.getAll()`](https://developer.mozilla.org/en-US/docs/Web/API/FormData/getAll) behind the scenes: 137 | 138 | ```astro 139 | --- 140 | import { createForm } from "simple:form"; 141 | import z from "zod"; 142 | 143 | const contact = createForm({ 144 | contactNames: z.array(z.string()), 145 | }); 146 | 147 | const res = await Astro.locals.form.getData(contact); 148 | console.log(res?.data); 149 | // contactNames: ["Ben", "George"] 150 | --- 151 | 152 |
153 | 154 | 155 | {res.fieldErrors?.contactNames?.[0]} 156 | 157 | 158 | {res.fieldErrors?.contactNames?.[1]} 159 |
160 | ``` 161 | 162 | Note that `fieldErrors` can be retrieved by index. For example, to get parse errors for the second input, use `fieldErrors.contactNames[1]`. 163 | 164 | ### Sanitize User Input 165 | 166 | You may need to sanitize user input with rich text content. This is important for any text rendered as HTML to prevent Cross-Site Scripting (XSS) attacks. You can use the [sanitize-html](https://www.npmjs.com/package/sanitize-html) library for this: 167 | 168 | ```bash 169 | npm install --save sanitize-html 170 | npm install --save-dev @types/sanitize-html 171 | ``` 172 | 173 | Next, call `sanitize-html` from your text validator [with a Zod `transform()`](https://zod.dev/?id=transform): 174 | 175 | ```diff 176 | + import sanitizeHtml from "sanitize-html"; 177 | 178 | const signupForm = createForm({ 179 | - name: z.string(), 180 | + name: z.string().transform((dirty) => sanitizeHtml(dirty)), 181 | age: z.number().min(18).optional(), 182 | newsletterOptIn: z.boolean(), 183 | }); 184 | ``` 185 | 186 | You can find a sanitization example in our [Astro playground](https://github.com/bholmesdev/simple-stack/tree/main/examples/playground/src/components/Sanitize.tsx) 187 | -------------------------------------------------------------------------------- /www/src/content/docs/form/parse.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Parse form requests 3 | description: Validate forms server-side 4 | sidebar: 5 | order: 2 6 | --- 7 | 8 | Simple form exposes helpers to parse and validate form requests with generic APIs and the [`Astro.locals.form`](#astrolocalsform) object when using Astro frontmatter. 9 | 10 | ## `validateForm()` 11 | 12 | **Type:** `validateForm(params: { formData: FormData, validator: T): Promise>` 13 | 14 | The `validateForm()` function can be used to validate any form request using a simple form validator. This returns data parsed by your validator or a `fieldErrors` object: 15 | 16 | ```ts 17 | import { validateForm, createForm } from 'simple:form'; 18 | 19 | const signup = createForm(...); 20 | 21 | const parsed = await validateForm({ formData, validator: signup.validator }); 22 | if (parsed.data) { 23 | console.info(parsed.data); 24 | } 25 | ``` 26 | 27 | ## `Astro.locals.form` 28 | 29 | Simple form exposes helpers to parse form requests from your Astro frontmatter. 30 | 31 | ### `getData()` 32 | 33 | **Type:** `getData(form: T): Promise | undefined>` 34 | 35 | `Astro.locals.form.getData()` parses any incoming form request with the method POST. This will return `undefined` if no form request was sent, or return form data parsed by your [Zod validator](https://github.com/colinhacks/zod#safeparse). 36 | 37 | If successful, `result.data` will contain the parsed result. Otherwise, `result.fieldErrors` will contain validation error messages by field name: 38 | 39 | ```astro 40 | --- 41 | import { z } from 'zod'; 42 | import { createForm } from 'simple:form'; 43 | 44 | const checkout = createForm({ 45 | quantity: z.number(), 46 | }); 47 | 48 | const result = await Astro.locals.form.getData(checkout); 49 | 50 | if (result?.data) { 51 | console.log(result.data); 52 | // { quantity: number } 53 | } 54 | --- 55 | 56 |
57 | 58 | 59 | { 60 | result?.fieldErrors?.quantity?.map(error => ( 61 |

{error}

62 | )) 63 | } 64 | ... 65 |
66 | ``` 67 | 68 | ### `getDataByName()` 69 | 70 | **Type:** `getDataByName(name: string, form: T): Promise | undefined>` 71 | 72 | You may have multiple forms on the page you want to parse separately. You can define a unique form name in this case, and pass the name as a hidden input within the form using ``: 73 | 74 | ```astro 75 | --- 76 | import { z } from 'zod'; 77 | import { createForm } from 'simple:form'; 78 | 79 | const checkout = createForm({ 80 | quantity: z.number(), 81 | }); 82 | 83 | const result = await Astro.locals.form.getDataByName( 84 | 'checkout', 85 | checkout, 86 | ); 87 | 88 | if (result?.data) { 89 | console.log(result.data); 90 | // { quantity: number } 91 | } 92 | --- 93 | 94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | ``` -------------------------------------------------------------------------------- /www/src/content/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple stack 🌱 3 | description: A suite of tools built for Astro to simplify your workflow. 4 | tableOfContents: false 5 | head: 6 | - tag: title 7 | content: Simple stack 🌱 8 | --- 9 | 10 | **Web apps made simple.** 11 | 12 | Simple stack is a suite of tools built for [Astro](https://astro.build) to simplify your workflow. 13 | 14 | ## Packages 15 | 16 | Simple stack offers a growing collection of packages: 17 | 18 | - **[🔎 Simple query](/query):** The simplest way to query elements from your Astro component. 19 | - **[🌊 Simple stream](/stream):** Suspend Astro components with fallback content. 20 | - **[🧘‍♂️ Simple form](/form):** A full stack solution to validate forms with your client framework of choice. 21 | - **[🔎 Simple scope](/scope):** A scoped ID generator for any file you're in. Perfect for form label IDs and query selectors. 22 | 23 | ## Get involved 24 | 25 | Simple stack is open to contributors! You can [join the discord](https://wtw.dev/chat) to share ideas, and [read the contributing guide](https://github.com/bholmesdev/simple-stack/blob/main/CONTRIBUTING.md) to make your first pull request. 26 | -------------------------------------------------------------------------------- /www/src/content/docs/scope.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple scope 🔎 3 | description: Get a scoped ID for whatever file you're in. Resolved at build-time with zero client JS. 4 | --- 5 | 6 | Get a scoped ID for whatever file you're in. Resolved at build-time with zero client JS. 7 | 8 | ```jsx 9 | import { scope } from 'simple:scope'; 10 | 11 | function Form() { 12 | return ( 13 |
14 | 15 | 16 |
17 | ); 18 | } 19 | 20 | /* 21 | Output: 22 | 23 |
24 | 25 | 26 |
27 | */ 28 | ``` 29 | 30 | ## Installation 31 | 32 | ### With the Simple Query Astro integration 33 | 34 | Simple Scope is included with the [Simple Query](/query#installation) Astro integration. You can apply this integration using the `astro add` CLI: 35 | 36 | ```bash 37 | astro add simple-stack-query 38 | ``` 39 | 40 | If you do not want to use Simple Query, install as a vite plugin with the following instructions. 41 | 42 | ### As a vite plugin 43 | 44 | Simple scope is a vite plugin compatible with any vite-based framework (Astro, Nuxt, SvelteKit, etc). First install the dependency from npm: 45 | 46 | ```bash 47 | npm i vite-plugin-simple-scope 48 | ``` 49 | 50 | Then, apply as a vite plugin in your framework of choice: 51 | 52 | ```js 53 | import simpleScope from 'vite-plugin-simple-scope'; 54 | 55 | // apply `simpleScope()` to your vite plugin config 56 | ``` 57 | 58 | - [Astro vite plugin configuration](https://docs.astro.build/en/recipes/add-yaml-support/) 59 | - [Nuxt vite plugin configuration](https://nuxt.com/docs/getting-started/configuration#external-configuration-files) 60 | - [SvelteKit vite plugin configuration](https://kit.svelte.dev/docs/project-structure#project-files-vite-config-js) 61 | 62 | ## Usage 63 | 64 | You can import the `scope()` utility from `simple:scope` in any JavaScript-based file. This function accepts an optional prefix string for naming different scoped identifiers. 65 | 66 | Since `scope()` uses the file path to generate IDs, multiple calls to `scope()` will append the same value: 67 | 68 | ```js 69 | // example.js 70 | 71 | scope(); // JYZeLezU 72 | scope('first'); // first-JYZeLezU 73 | scope('second'); // second-JYZeLezU 74 | ``` 75 | 76 | Simple scope will also generate the same ID when called server-side or client-side. This prevents hydration mismatches when using component frameworks like React or Vue, and is helpful when querying scoped element `id`s from the DOM. 77 | 78 | This example uses [Astro](https://astro.build) to add a scoped `id` to a `` element, and queries that `id` from a client-side ` 94 | ``` 95 | -------------------------------------------------------------------------------- /www/src/content/docs/stream.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple stream 🌊 3 | description: Suspend Astro components with fallback content. Like React Server Components, but Just HTML ™️ 4 | --- 5 | 6 | Suspend Astro components with fallback content. Like React Server Components, but Just HTML ™️ 7 | 8 | 9 | 10 | ```astro 11 | --- 12 | import { Suspense } from 'simple-stack-stream/components'; 13 | --- 14 | 15 |

Simple stream

16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |