├── .cursorrules ├── .env.example ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── astro.config.mjs ├── db ├── config.ts └── seed.ts ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.svg ├── og-image.png └── robots.txt ├── scripts ├── check-env.js ├── create-freedom-stack.js └── setup-turso.js ├── src ├── actions │ ├── auth.ts │ ├── index.ts │ └── posts.ts ├── assets │ ├── deploy.png │ ├── full-stack.png │ └── sections.png ├── components │ ├── IconBlock.astro │ ├── RenderMarkdown.astro │ ├── ShowIfAuthenticated.astro │ ├── TrixEditor.astro │ ├── sections │ │ ├── Container.astro │ │ ├── FAQ.astro │ │ ├── Features │ │ │ ├── Card.astro │ │ │ └── index.astro │ │ ├── Footer.astro │ │ ├── HalfTextHalfImage.astro │ │ ├── Hero.astro │ │ ├── Navbar.astro │ │ └── Quote.astro │ └── server │ │ └── GithubButton.astro ├── entrypoint.ts ├── env.d.ts ├── global.css ├── icons │ └── astro.svg ├── layouts │ └── Layout.astro ├── lib │ ├── auth-client.ts │ ├── auth.ts │ └── email.ts ├── middleware.ts └── pages │ ├── api │ ├── auth │ │ └── [...all].ts │ ├── create-post.ts │ └── htmx-partials │ │ ├── github-stars.astro │ │ ├── rick-roll.astro │ │ └── uneed-launch.astro │ ├── dashboard │ ├── index.astro │ └── posts │ │ ├── edit │ │ └── [slug].astro │ │ └── new.astro │ ├── forgot-password.astro │ ├── index.astro │ ├── posts │ ├── [...slug].astro │ └── index.astro │ ├── sign-in.astro │ ├── sign-out.astro │ ├── sign-up.astro │ ├── sitemap.xml.ts │ └── verify-email.astro └── tsconfig.json /.cursorrules: -------------------------------------------------------------------------------- 1 | You are an expert in JavaScript, TypeScript, and Astro framework for scalable web development. 2 | 3 | Technology Stack: 4 | 5 | UI Layer: 6 | 7 | - Framework: Astro 8 | - Styling: TailwindCSS, Preline UI, DaisyUI 9 | - Icons: Lucide Icons 10 | - File Pattern: \*.astro 11 | - Rich Text Editor: Trix 12 | 13 | Interactivity Layer: 14 | 15 | - Language: TypeScript 16 | - Frameworks: Alpine.js, HTMX 17 | - Alpine Plugins: Intersect, Persist, Collapse, Mask 18 | - File Pattern: _.ts, _.tsx 19 | 20 | Backend Layer: 21 | 22 | - ORM: Drizzle via Astro DB 23 | - Database: Astro DB (with libSQL/Turso) 24 | - Authentication: Better Auth 25 | - Cache: Netlify 26 | - File Pattern: db/\*.ts 27 | 28 | Development Guidelines: 29 | 30 | - Enforce strict TypeScript settings for type safety 31 | - Use DaisyUI and TailwindCSS with utility-first approach (never use @apply) 32 | - Create modular, reusable Astro components 33 | - Maintain clear separation of concerns 34 | - Implement proper cache control headers 35 | - Sanitize HTML content using DOMPurify 36 | - Use Markdown for content formatting (marked) 37 | - Leverage Astro's partial hydration and multi-framework support 38 | - Prioritize static generation and minimal JavaScript 39 | - Use descriptive variable names and follow Astro's conventions 40 | 41 | Project Structure: 42 | 43 | - src/ 44 | - components/ 45 | - layouts/ 46 | - pages/ 47 | - styles/ 48 | - public/ 49 | - astro.config.mjs 50 | 51 | Component Development: 52 | 53 | - Create .astro files for Astro components 54 | - Use framework-specific components when necessary 55 | - Implement proper component composition 56 | - Use Astro's component props for data passing 57 | - Leverage built-in components like 58 | 59 | Routing and Pages: 60 | 61 | - Utilize file-based routing in src/pages/ 62 | - Implement dynamic routes using [...slug].astro 63 | - Use getStaticPaths() for static page generation 64 | - Implement proper 404 handling 65 | 66 | Content Management: 67 | 68 | - Use Markdown (.md) or MDX (.mdx) for content-heavy pages 69 | - Leverage frontmatter in Markdown files 70 | - Implement content collections 71 | 72 | Performance Optimization: 73 | 74 | - Minimize client-side JavaScript 75 | - Use client:\* directives judiciously: 76 | - client:load for immediate needs 77 | - client:idle for non-critical 78 | - client:visible for viewport-based 79 | - Implement proper lazy loading 80 | - Utilize built-in asset optimization 81 | 82 | Code Style Requirements: 83 | 84 | - Indentation: 2 spaces (tabWidth: 2, useTabs: false) 85 | - Enable format on save 86 | - No trailing comma (trailingComma: "none") 87 | - Line length: 120 characters (printWidth: 120) 88 | - Trim trailing whitespace 89 | - Ensure final newline 90 | - Include path/filename as first comment 91 | - Write purpose-focused comments 92 | - Follow DRY principles 93 | - Prioritize modularity and performance 94 | - Use Astro-specific parser for .astro files 95 | - Use prettier-plugin-astro for Astro file formatting 96 | 97 | Commit Message Standards: 98 | 99 | - Use conventional commits with lowercase type and optional scope 100 | - Keep messages concise (max 60 characters) 101 | - Format: type(scope): description 102 | - Include full commit command in suggestions 103 | - Messages should be terminal-ready 104 | 105 | Environment Variables: 106 | 107 | - ASTRO_DB_REMOTE_URL: libSQL connection URL 108 | - ASTRO_DB_APP_TOKEN: libSQL auth token 109 | - BETTER_AUTH_SECRET: Better Auth secret 110 | - BETTER_AUTH_URL: Better Auth URL 111 | 112 | Testing and Accessibility: 113 | 114 | - Implement unit tests for utility functions 115 | - Use end-to-end testing with Cypress 116 | - Ensure proper semantic HTML structure 117 | - Implement ARIA attributes where necessary 118 | - Ensure keyboard navigation support 119 | 120 | Documentation Resources: 121 | 122 | - DaisyUI: https://daisyui.com/ 123 | - TailwindCSS: https://tailwindcss.com/ 124 | - Preline: https://preline.co/ 125 | - Astro DB: https://docs.astro.build/en/guides/astro-db 126 | - HTMX: https://htmx.org/ 127 | - Better Auth: https://better-auth.com/ 128 | - Alpine.js: https://alpinejs.dev/ 129 | - Turso: https://docs.turso.tech/ 130 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Astro DB - LibSQL (required) - Your database - https://docs.astro.build/en/guides/astro-db/#connect-a-libsql-database-for-production 2 | ASTRO_DB_REMOTE_URL="" 3 | ASTRO_DB_APP_TOKEN="" 4 | 5 | # Better Auth (required) - https://www.better-auth.com/docs/installation#set-environment-variables 6 | BETTER_AUTH_SECRET="" 7 | BETTER_AUTH_URL="http://localhost:4321" 8 | BETTER_AUTH_EMAIL_VERIFICATION="false" 9 | 10 | # Mail server configuration 11 | MAIL_HOST=smtp.resend.com 12 | MAIL_PORT=465 13 | MAIL_SECURE=true 14 | MAIL_AUTH_USER=resend 15 | MAIL_AUTH_PASS=your_resend_api_key 16 | MAIL_FROM=your_verified_sender@yourdomain.com 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Package managers file 17 | pnpm-lock.yaml 18 | 19 | # environment variables 20 | .env 21 | .env.production 22 | 23 | # macOS-specific files 24 | .DS_Store 25 | 26 | # Netlify build 27 | .netlify/ 28 | 29 | # TinyMCE self-hosted gets copied on build, so no need to commit. 30 | public/tinymce/ 31 | 32 | # Ignore all versions of tinymce in the specified path 33 | node_modules/.pnpm/tinymce@*/node_modules/tinymce 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .DS_Store 4 | node_modules 5 | dist 6 | .env 7 | .env.* 8 | !.env.example 9 | .vscode 10 | .astro 11 | .netlify 12 | *.log 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.10.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "trailingComma": "none", 4 | "printWidth": 120, 5 | "tabWidth": 2, 6 | "plugins": ["prettier-plugin-astro"], 7 | "overrides": [ 8 | { 9 | "files": "*.astro", 10 | "options": { 11 | "parser": "astro" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "astro-build.astro-vscode", 4 | "RisingStack.astro-alpinejs-syntax-highlight", 5 | "CraigRBroughton.htmx-attributes", 6 | "esbenp.prettier-vscode" 7 | ], 8 | "unwantedRecommendations": [] 9 | } 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.tabSize": 2, 5 | "editor.insertSpaces": true, 6 | "files.trimTrailingWhitespace": true, 7 | "files.insertFinalNewline": true, 8 | "prettier.requireConfig": true, 9 | "[astro]": { 10 | "editor.defaultFormatter": "astro-build.astro-vscode" 11 | }, 12 | "[xml]": { 13 | "editor.defaultFormatter": "redhat.vscode-xml" 14 | }, 15 | "typescript.tsdk": "node_modules/typescript/lib", 16 | "typescript.enablePromptUseWorkspaceTsdk": true, 17 | "editor.rulers": [120], 18 | "files.associations": { 19 | "*.mdx": "markdown" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | Freedom Stack is committed to being a welcoming community that empowers developers to create with freedom and joy. We pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Values 8 | 9 | - **Approachability**: We believe in making web development accessible to everyone, regardless of their experience level. We welcome questions, encourage learning, and support those new to our stack. 10 | 11 | - **Flow-ability**: We value simplicity and focus. Our community interactions should reflect this by being clear, helpful, and free from unnecessary complexity or gatekeeping. 12 | 13 | - **Pocket-friendly**: We work hard to make full-stack web development financially accessible to everyone. 14 | 15 | - **Generosity**: We believe that it is better to give than to receive, a lesson taught by Jesus in the Bible. While this project is created by a Christian, anyone can contribute to this project. 16 | 17 | ## Expected Behavior 18 | 19 | - Use welcoming and kind language 20 | - Be respectful to one another 21 | - Gracefully accept constructive criticism 22 | - Focus on what is best for the community 23 | - Show empathy towards other community members 24 | - Help others learn and grow 25 | 26 | ## Unacceptable Behavior 27 | 28 | The following behaviors are considered harassment and are unacceptable: 29 | 30 | - The use of sexualized language or imagery 31 | - Personal attacks or derogatory comments 32 | - Public or private harassment 33 | - Publishing others' private information without explicit permission 34 | - Other conduct which could reasonably be considered inappropriate in a professional setting 35 | - Trolling, insulting/derogatory comments, and personal or political attacks 36 | - Promoting discrimination of any kind 37 | 38 | ## Enforcement Responsibilities 39 | 40 | Project maintainers are responsible for clarifying and enforcing standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 41 | 42 | ## Reporting Process 43 | 44 | If you experience or witness unacceptable behavior, please report it by: 45 | 46 | 1. Opening an issue in the repository 47 | 2. Contacting the project maintainers directly 48 | 3. Emailing [Cameron Pak](https://letterbird.co/cameronandrewpak) 49 | 50 | All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. 51 | 52 | ## Enforcement 53 | 54 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct. Project maintainers who do not follow or enforce the Code of Conduct may be temporarily or permanently removed from the project team. 55 | 56 | ## Attribution 57 | 58 | This Code of Conduct is adapted from the [Project Include](https://projectinclude.org/writing_cocs) guidelines and inspired by the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.0. 59 | 60 | ## Questions? 61 | 62 | If you have questions about this Code of Conduct, please open an issue in the repository or contact the project maintainers. 63 | 64 | ## Project Maintenance 65 | 66 | This project is maintained by Cameron Pak as the sole maintainer, operating under FAITH TOOLS SOFTWARE SOLUTIONS, LLC. While community contributions are welcome, final decisions and project direction are managed through this structure. 67 | 68 | --- 69 | 70 | Freedom Stack is made with 🕊️ by [Cameron Pak](https://cameronpak.com), brought to you by [faith.tools](https://faith.tools). 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Cameron Pak, FAITH TOOLS SOFTWARE SOLUTIONS, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Freedom Stack 2 | 3 | > [!WARNING] 4 | > Freedom Stack is no longer in active development and needs maintainers/contributors! If you want to be part of this, [email me](mailto:cameronandrewpak@gmail.com?subject=I%20want%20to%20contribute%20to%20Freedom%20Stack) 5 | 6 | > [!IMPORTANT] 7 | > I'd recommend checking out [Better T Stack](https://better-t-stack.amanv.dev/new) to piece together your preferred app stack. 8 | 9 | A modern, type-safe web development stack using Astro, TypeScript, HTMX, Alpine.js, and more. 10 | 11 | > [!TIP] 12 | > Turso has a generous free tier for database hosting and management. And, when it's time to scale, use the code `FREEDOMSTACK` for a discount on paid plans. 13 | > [Check out Turso](https://tur.so/freedomstack) 14 | 15 | ## Get Started 🚀 16 | 17 | ### 1. Create Your Project 18 | 19 | You can create a new Freedom Stack project using npm: 20 | 21 | ```bash 22 | # Create a new project 23 | npx create-freedom-stack my-app 24 | 25 | # Navigate to the project directory 26 | cd my-app 27 | 28 | # Set up your database 29 | npm run db:setup 30 | 31 | # Start the development server 32 | npm run dev 33 | ``` 34 | 35 | Your development server will be running on [`localhost:4321`](http://localhost:4321). 36 | 37 | ### 2. Environment Variables 38 | 39 | The project will automatically create a `.env` file with a generated `BETTER_AUTH_SECRET`. You'll need to set these additional variables: 40 | 41 | ```env 42 | # Astro DB - LibSQL (required) - Your database 43 | ASTRO_DB_REMOTE_URL="" # Added by npm run db:setup 44 | ASTRO_DB_APP_TOKEN="" # Added by npm run db:setup 45 | 46 | # Better Auth (required) 47 | BETTER_AUTH_SECRET="" # Auto-generated during setup 48 | BETTER_AUTH_URL="http://localhost:4321" 49 | 50 | # Email Configuration (optional) - For sending emails 51 | MAIL_HOST="" # SMTP host (e.g., smtp.resend.com) 52 | MAIL_PORT="" # SMTP port (e.g., 465) 53 | MAIL_SECURE="" # Use TLS/SSL (true/false) 54 | MAIL_AUTH_USER="" # SMTP username 55 | MAIL_AUTH_PASS="" # SMTP password or API key 56 | MAIL_FROM="" # Sender email address 57 | ``` 58 | 59 | ### 3. Have fun! 60 | 61 | Create because you love creating. Make development fun again! 62 | 63 | ## What's Included 64 | 65 | - 🚀 [Astro](https://astro.build) - The web framework for content-driven websites 66 | - 🎨 [TailwindCSS](https://tailwindcss.com) + [DaisyUI](https://daisyui.com) - Utility-first CSS 67 | - ⚡ [HTMX](https://htmx.org) - High power tools for HTML 68 | - 🗄️ [Astro DB](https://docs.astro.build/en/guides/astro-db) - Built-in database with type safety 69 | - 🔒 [Better Auth](https://better-auth.com) - Simple, secure authentication 70 | - 🏃‍♂️ [Alpine.js](https://alpinejs.dev) - Lightweight JavaScript framework 71 | 72 | # Freedom Stack • Full-Stack Starter Kit 73 | 74 | [![Netlify Status](https://api.netlify.com/api/v1/badges/78803fc4-5d36-4efb-82cd-2daeb5684fb6/deploy-status)](https://app.netlify.com/sites/freedom-stack/deploys) [![Github Stars](https://img.shields.io/github/stars/cameronapak/freedom-stack?style=flat-square)](https://github.com/cameronapak/freedom-stack/stargazers) 75 | 76 | An Astro-based full-stack starter kit that feels freeing, and is free. Make development fun again. [See the demo site](https://freedom.faith.tools). 77 | 78 | I wanted to provide a stack that's powerful like Ruby on Rails _("The One Person Framework")_, but with the ease and "vanilla" web dev feel of Astro. 79 | 80 | Deploy to Netlify 81 | 82 | ![freedom stack](public/og-image.png) 83 | 84 | ## Learning Resources 📚 85 | 86 | ### The Frontend Layer 87 | 88 | If you want to learn more about the frontend layer, I recommend the [Astro Web Framework Crash Course by freeCodeCamp](https://www.youtube.com/watch?v=e-hTm5VmofI). 89 | 90 | ### The Interactivity Layer 91 | 92 | If you want to learn more about Alpine.js, I recommend [Learn Alpine.js on codecourse](https://codecourse.com/courses/learn-alpine-js). 93 | 94 | ### The Database Layer 95 | 96 | If you want to learn more about the database layer, I recommend learning from [High Performance SQLite course](https://highperformancesqlite.com/), sponsored by [Turso](https://tur.so/freedomstack/). 97 | 98 | ### The Philosophy Layer 99 | 100 | A starter kit like this can save hours, days, or even weeks of development time. However, it's not enough just to have the baseline. You will need to have a philosophy around building a site or web app, so that you can make the most of the tooling and minimize wasting time. I recommend reading Getting Real by 37signals. [It's free to read online](https://books.37signals.com/8/getting-real). _(While the book says a few choice words, it's a great, practical resource for building great software.)_ 101 | 102 | ## Here's What's Included 🔋🔋🔋 103 | 104 | Ogres have layers. Onions have layers. Parfaits have layers. And, Freedom Stack has layers! 105 | 106 | ### UI Layer 107 | 108 | - [Astro](https://astro.build/) - A simple web metaframework. 109 | - [Tailwind CSS](https://tailwindcss.com/) - For styling. 110 | - [Preline UI](https://preline.co/) - Tailwind-based HTML components. 111 | - [Daisy UI](https://daisyui.com/) - For a Bootstrap-like UI CSS component 112 | library, built upon Tailwind. 113 | - [Lucide Icons](https://lucide.dev/) - For a beautiful icon library. 114 | 115 | ### Interactivity Layer 116 | 117 | - [TypeScript](https://www.typescriptlang.org/) - For type safety. 118 | - [AlpineJS](https://alpinejs.dev/) - For state management and interactivity. 119 | - [HTMX](https://htmx.org/) - For sending HTML partials/snippets over the wire. 120 | 121 | ### Backend Data Layer 122 | 123 | - [Astro DB](https://astro.build/db) - Astro DB is a fully managed SQL database 124 | that is fast, lightweight, and ridiculously easy-to-use. 125 | - [Drizzle ORM](https://orm.drizzle.team/) - Use your database without having to know or worry about SQL syntax. 126 | - [Better Auth](https://better-auth.com/) - For authentication. 127 | 128 | ### Bonus Layer 129 | 130 | - A well-prompted `.cursorrules` file for [Cursor's AI IDE](https://cursor.com/) to be a friendly guide helping you using this stack easier. 131 | 132 | --- 133 | 134 | ## Host Your Project ☁️ 135 | 136 | Host your site with [Netlify](https://netlify.com) in under a minute. 137 | 138 | First, you must login to Netlify: 139 | 140 | ```bash 141 | npm run host:login 142 | ``` 143 | 144 | Then, you can deploy your site with: 145 | 146 | ```bash 147 | npm run host:deploy 148 | ``` 149 | 150 | > [!IMPORTANT] 151 | > Remember to set the environment variables in Netlify so that it builds successfully. 152 | 153 | [Learn more about hosting Astro sites on Netlify](https://docs.astro.build/en/guides/deploy/netlify/). 154 | 155 | --- 156 | 157 | ## Send Emails 158 | 159 | ## Email Configuration 📧 160 | 161 | Freedom Stack includes a pre-configured email service using Nodemailer. This allows you to: 162 | 163 | - Send transactional emails 164 | - Use any SMTP provider 165 | - Handle email templates 166 | - Maintain type safety 167 | 168 | ### Setting up Email 169 | 170 | 1. Configure your environment variables as shown above 171 | 172 | Send emails programmatically: 173 | 174 | ```typescript 175 | import { sendEmail } from "@/lib/email"; 176 | 177 | await sendEmail({ 178 | to: "recipient@example.com", 179 | subject: "Hello!", 180 | html: "

Welcome!

" 181 | }); 182 | ``` 183 | 184 | ### Email Providers 185 | 186 | While you can use any SMTP provider, we recommend [Resend](https://resend.com) - Modern email API with generous free tier. 187 | 188 | > [!TIP] 189 | > Resend offers 100 emails/day free and has excellent developer experience. 190 | 191 | --- 192 | 193 | ## Vision ❤️ 194 | 195 | I dream of a lightweight, simple web development stack that invokes a fun web 196 | experience at the cheapest possible maintainance, backend, and server cost. As 197 | close to free as possible. 198 | 199 | ### Core Principles 200 | 201 | - **Approachable** — I want those new to web development to feel comfortable 202 | using this stack. Things like database management should feel intuitive. 203 | Remove barriers of traditional JavaScript frameworks, such as excessive 204 | boilerplate code or intense state management. Go back to the basics of web 205 | development. (_While this is not vanilla, the dev experience will feel very 206 | natural._) 207 | - **Flow-able** — Use an HTML-first approach, where almost all of the work is 208 | done on the DOM layer: styling, structuring, and interactivity. An opinionated 209 | stack helps you avoid analysis paralysis of trying to decide what tooling to 210 | pick or how to put things together. Instead, spend your thinking time 211 | building. This simple stack helps you focus and get in the flow of code 212 | faster. Fast setup. Fast building. Fast shipping. 213 | - **Pocket-friendly** — Using this stack will be financially maintainable to 214 | anyone, especially indie hackers and those creating startup sites / web apps. 215 | 216 | ## Showcase 🏆 217 | 218 | - [faith.tools](https://faith.tools) 219 | - [freedom](https://freedom.melos.church) 220 | - [Be Still](https://ft-be-still.netlify.app) 221 | - [kit](https://kit.faith.tools) 222 | 223 | Have a project that uses Freedom Stack? [Open a PR](https://github.com/cameronapak/freedom-stack) to add it to the list! 224 | 225 | ## Available Scripts ⚡ 226 | 227 | | Command | Description | 228 | | --------------------------- | ------------------------------------------------ | 229 | | `npm run dev` | Start the development server | 230 | | `npm run dev:host` | Start development server accessible from network | 231 | | `npm run build` | Build the production site with remote database | 232 | | `npm run preview` | Preview the built site locally | 233 | | `npm run format` | Format all files using Prettier | 234 | | `npm run packages:update` | Update all packages to their latest versions | 235 | | `npm run db:update-schemas` | Push database schema changes to remote database | 236 | 237 | ## Contributions 🤝 238 | 239 | Contributions welcomed. Please 240 | [open an issue](https://github.com/cameronapak/astwoah-stack/issues) if you'd 241 | like to contribute. 242 | 243 | 244 | 245 | 246 | 247 | Made with [contrib.rocks](https://contrib.rocks). 248 | 249 | --- 250 | 251 | ## License 📜 252 | 253 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 254 | 255 | ## Code of Conduct 📜 256 | 257 | See the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) file for details. 258 | 259 | --- 260 | 261 | Freedom Stack is made with 🕊️ by [Cameron Pak](https://cameronpak.com), brought to you by [faith.tools](https://faith.tools). 262 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import alpinejs from "@astrojs/alpinejs"; 4 | import netlify from "@astrojs/netlify"; 5 | import db from "@astrojs/db"; 6 | 7 | // https://astro.build/config 8 | export default defineConfig({ 9 | integrations: [ 10 | db(), 11 | alpinejs({ 12 | entrypoint: "/src/entrypoint" 13 | }) 14 | ], 15 | output: "server", 16 | adapter: netlify(), 17 | vite: { 18 | plugins: [tailwindcss()] 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /db/config.ts: -------------------------------------------------------------------------------- 1 | import { defineDb, defineTable, column } from "astro:db"; 2 | 3 | const Posts = defineTable({ 4 | columns: { 5 | id: column.number({ primaryKey: true }), 6 | title: column.text(), 7 | pubDate: column.date(), 8 | description: column.text(), 9 | author: column.text(), 10 | imageUrl: column.text({ optional: true }), 11 | imageAlt: column.text({ optional: true }), 12 | tags: column.json({ optional: true }), 13 | slug: column.text({ unique: true }), 14 | content: column.text() 15 | } 16 | }); 17 | 18 | const User = defineTable({ 19 | columns: { 20 | id: column.text({ primaryKey: true }), 21 | email: column.text({ unique: true }), 22 | name: column.text(), 23 | emailVerified: column.boolean({ default: false }), 24 | image: column.text({ optional: true }), 25 | createdAt: column.date(), 26 | updatedAt: column.date() 27 | } 28 | }); 29 | 30 | const Session = defineTable({ 31 | columns: { 32 | token: column.text(), 33 | id: column.text({ primaryKey: true }), 34 | userId: column.text(), 35 | expiresAt: column.date(), 36 | ipAddress: column.text({ optional: true }), 37 | userAgent: column.text({ optional: true }), 38 | createdAt: column.date(), 39 | updatedAt: column.date() 40 | } 41 | }); 42 | 43 | const Account = defineTable({ 44 | columns: { 45 | id: column.text({ primaryKey: true }), 46 | userId: column.text(), 47 | accountId: column.text({ optional: true }), 48 | providerId: column.text({ optional: true }), 49 | accessToken: column.text({ optional: true }), 50 | refreshToken: column.text({ optional: true }), 51 | idToken: column.text({ optional: true }), 52 | expiresAt: column.date({ optional: true }), 53 | password: column.text({ optional: true }), 54 | createdAt: column.date(), 55 | updatedAt: column.date() 56 | } 57 | }); 58 | 59 | const Verification = defineTable({ 60 | columns: { 61 | id: column.text({ primaryKey: true }), 62 | identifier: column.text(), 63 | value: column.text(), 64 | expiresAt: column.date(), 65 | createdAt: column.date(), 66 | updatedAt: column.date({ optional: true }) 67 | } 68 | }); 69 | 70 | export default defineDb({ 71 | tables: { 72 | Posts, 73 | User, 74 | Session, 75 | Account, 76 | Verification 77 | } 78 | }); 79 | -------------------------------------------------------------------------------- /db/seed.ts: -------------------------------------------------------------------------------- 1 | import { db, Posts } from "astro:db"; 2 | 3 | const content = ` 4 | ## This is the first post of my new Astro blog. 5 | 6 | Never gonna give you up, never gonna let you down. 7 | Never gonna run around and desert you. 8 | Never gonna make you cry, never gonna say goodbye. 9 | Never gonna tell a lie and hurt you. 10 | Never gonna hold you back, never gonna lose your grip. 11 | Never gonna give you up, never gonna let you down. 12 | Never gonna run around and desert you. 13 | Never gonna make you cry, never gonna say goodbye. 14 | Never gonna tell a lie and hurt you. 15 | `.trim(); 16 | 17 | const shrekContent = ` 18 |

A Story of Layers

19 | 20 |

21 | Just like onions, this blog post has layers. Shrek taught us that true beauty lies within, 22 | and that the best stories often come from the most unexpected places - like a swamp. 23 |

24 |

25 |

26 | Some people judge a book by its cover, but as our favorite ogre would say, "Better out than in!" 27 | This post celebrates the wisdom, humor, and heart that made Shrek a beloved character for generations. 28 |

29 |

30 |

31 | Remember: Ogres are like onions. They have layers. Onions have layers. 32 |

33 | `.trim(); 34 | 35 | // https://astro.build/db/seed 36 | export default async function seed() { 37 | await db.insert(Posts).values([ 38 | { 39 | id: 1, 40 | title: "My First Blog Post", 41 | pubDate: new Date("2022-07-01"), 42 | description: "This is the first post of my new Astro blog.", 43 | author: "email@example.com", 44 | imageUrl: "https://astro.build/assets/blog/astro-1-release-update/cover.jpeg", 45 | imageAlt: "The Astro logo with the word One.", 46 | tags: JSON.stringify(["astro", "blogging", "learning in public"]), 47 | slug: "my-first-blog-post", 48 | content 49 | }, 50 | { 51 | id: 2, 52 | title: "Shrek: Lessons from an Ogre", 53 | pubDate: new Date("2022-07-15"), 54 | description: "Exploring the wisdom and layers of everyone's favorite ogre.", 55 | author: "cameronandrewpak@gmail.com", 56 | imageUrl: "https://example.com/shrek-image.jpg", // Replace with actual Shrek image URL 57 | imageAlt: "Shrek standing proudly in his swamp", 58 | tags: JSON.stringify(["shrek", "movies", "life lessons", "animation"]), 59 | slug: "shrek-lessons-from-an-ogre", 60 | content: shrekContent 61 | } 62 | ]); 63 | } 64 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "dist" 4 | environment = { NODE_VERSION = "20" } 5 | 6 | [template] 7 | [template.environment] 8 | ASTRO_DB_REMOTE_URL = "Your Turso database URL" 9 | ASTRO_DB_APP_TOKEN = "Your Turso database app token" 10 | BETTER_AUTH_SECRET = "Your Better Auth secret (You get to choose this)" 11 | BETTER_AUTH_URL = "Your Better Auth URL (Your Netlify site URL)" 12 | 13 | # Force HTTPS 14 | [[redirects]] 15 | from = "http://*" 16 | to = "https://:splat" 17 | status = 301 18 | force = true 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-freedom-stack", 3 | "type": "module", 4 | "version": "1.0.11", 5 | "description": "Create a new Freedom Stack project - A modern, type-safe web development stack using Astro, TypeScript, HTMX, Alpine.js, and more", 6 | "author": "Cameron Pak", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/cameronapak/freedom-stack.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/cameronapak/freedom-stack/issues" 14 | }, 15 | "homepage": "https://freedom.faith.tools", 16 | "keywords": [ 17 | "create-freedom-stack", 18 | "freedom-stack", 19 | "astro", 20 | "typescript", 21 | "htmx", 22 | "alpinejs", 23 | "tailwindcss", 24 | "daisyui", 25 | "starter", 26 | "template" 27 | ], 28 | "bin": { 29 | "create-freedom-stack": "./scripts/create-freedom-stack.js" 30 | }, 31 | "files": [ 32 | "src", 33 | "public", 34 | "scripts", 35 | "db", 36 | ".env.example", 37 | "astro.config.mjs", 38 | "tailwind.config.mjs", 39 | "tsconfig.json", 40 | ".prettierrc", 41 | ".nvmrc" 42 | ], 43 | "scripts": { 44 | "check:env": "node scripts/check-env.js", 45 | "dev": "npm run check:env && astro dev", 46 | "dev:host": "npm run check:env && astro dev --host", 47 | "start": "npm run check:env && astro dev", 48 | "build": "astro check && astro build --remote", 49 | "preview": "astro preview", 50 | "format": "prettier -w .", 51 | "packages:update": "npx npm-check-updates -u", 52 | "db:setup": "node scripts/setup-turso.js", 53 | "db:update-schemas": "astro db push --remote", 54 | "host:deploy": "npx netlify deploy", 55 | "host:login": "npx netlify login" 56 | }, 57 | "dependencies": { 58 | "@alpinejs/collapse": "^3.14.8", 59 | "@alpinejs/intersect": "^3.14.8", 60 | "@alpinejs/mask": "^3.14.8", 61 | "@alpinejs/persist": "^3.14.8", 62 | "@astrojs/alpinejs": "^0.4.8", 63 | "@astrojs/check": "^0.9.4", 64 | "@astrojs/db": "^0.14.14", 65 | "@astrojs/netlify": "^6.3.4", 66 | "@iconify-json/lucide": "^1.2.27", 67 | "@iconify-json/lucide-lab": "^1.2.3", 68 | "alpinejs": "^3.14.8", 69 | "astro": "^5.8.0", 70 | "astro-iconify": "^1.2.0", 71 | "astro-seo": "^0.8.4", 72 | "better-auth": "1.2", 73 | "better-sqlite3": "^11.8.1", 74 | "cleye": "^1.3.4", 75 | "drizzle-orm": "^0.40.0", 76 | "htmx.org": "2.0.1", 77 | "isomorphic-dompurify": "^2.22.0", 78 | "marked": "^15.0.7", 79 | "nodemailer": "^6.10.0", 80 | "ora": "^8.2.0", 81 | "trix": "^2.1.12" 82 | }, 83 | "devDependencies": { 84 | "@tailwindcss/typography": "^0.5.16", 85 | "@tailwindcss/vite": "^4.0.9", 86 | "@types/alpinejs": "^3.13.11", 87 | "@types/better-sqlite3": "^7.6.12", 88 | "@types/nodemailer": "^6.4.17", 89 | "daisyui": "^5.0.0-beta.9", 90 | "netlify-cli": "^19.0.0", 91 | "prettier": "^3.5.2", 92 | "prettier-plugin-astro": "^0.14.1", 93 | "tailwindcss": "^4.0.9", 94 | "typescript": "^5.8.2" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # Redirect default Netlify subdomain to primary domain 2 | https://freedom-stack.netlify.app/* https://freedom.faith.tools/:splat 301! -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameronapak/freedom-stack/2d5ac9ca7f50c8d4781edd41f4e70532303e985f/public/og-image.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://freedom.faith.tools/sitemap.xml 5 | -------------------------------------------------------------------------------- /scripts/check-env.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import { fileURLToPath } from "url"; 3 | import { dirname, join } from "path"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | // Read .env.example to get required variables 9 | const envExample = readFileSync(join(__dirname, "../.env.example"), "utf8"); 10 | const requiredVars = envExample 11 | .split("\n") 12 | .filter((line) => line && !line.startsWith("#")) 13 | .map((line) => line.split("=")[0]); 14 | 15 | // Read .env file 16 | let envVars = {}; 17 | try { 18 | const envFile = readFileSync(join(__dirname, "../.env"), "utf8"); 19 | envVars = Object.fromEntries( 20 | envFile 21 | .split("\n") 22 | .filter((line) => line && !line.startsWith("#")) 23 | .map((line) => line.split("=").map((part) => part.trim())) 24 | ); 25 | } catch (error) { 26 | console.error("\x1b[33m%s\x1b[0m", "No .env file found. Creating one from .env.example..."); 27 | try { 28 | const { execSync } = require("child_process"); 29 | execSync("cp .env.example .env"); 30 | console.log("\x1b[32m%s\x1b[0m", "Created .env file from .env.example"); 31 | const exampleEnv = readFileSync(join(__dirname, "../.env.example"), "utf8"); 32 | envVars = Object.fromEntries( 33 | exampleEnv 34 | .split("\n") 35 | .filter((line) => line && !line.startsWith("#")) 36 | .map((line) => line.split("=").map((part) => part.trim())) 37 | ); 38 | } catch (error) { 39 | console.error("\x1b[31m%s\x1b[0m", "Error: Failed to create .env file!"); 40 | process.exit(1); 41 | } 42 | process.exit(1); 43 | } 44 | 45 | // Check if all required variables are set 46 | const missingVars = requiredVars.filter((varName) => !envVars[varName]); 47 | 48 | if (missingVars.length > 0) { 49 | console.error("\x1b[31m%s\x1b[0m", "Error: You have some missing required environment variables:"); 50 | 51 | // Read .env.example again to get comments 52 | const envExampleLines = envExample.split("\n"); 53 | const varComments = new Map(); 54 | 55 | let currentComment = ""; 56 | envExampleLines.forEach((line) => { 57 | if (line.startsWith("#")) { 58 | currentComment = line.substring(1).trim(); 59 | } else if (line && !line.startsWith("#")) { 60 | const varName = line.split("=")[0]; 61 | varComments.set(varName, currentComment); 62 | } 63 | }); 64 | 65 | missingVars.forEach((varName) => { 66 | console.error("\x1b[33m%s\x1b[0m", `- ${varName}`); 67 | const comment = varComments.get(varName); 68 | if (comment) { 69 | console.error("\x1b[36m%s\x1b[0m", ` → ${comment}`); 70 | } 71 | }); 72 | 73 | console.error("\n\x1b[37m%s\x1b[0m", "Please set these variables in your .env file before running the dev server."); 74 | process.exit(1); 75 | } 76 | -------------------------------------------------------------------------------- /scripts/create-freedom-stack.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { cli } from "cleye"; 4 | import { execSync } from "child_process"; 5 | import fs from "fs"; 6 | import path from "path"; 7 | import { fileURLToPath } from "url"; 8 | import { randomUUID } from "crypto"; 9 | import ora from "ora"; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | 14 | function createProject(projectName) { 15 | const currentDir = process.cwd(); 16 | const projectDir = path.join(currentDir, projectName); 17 | 18 | // Create project directory 19 | fs.mkdirSync(projectDir, { recursive: true }); 20 | 21 | // Copy template files 22 | const templateDir = path.join(__dirname, ".."); 23 | const filesToCopy = [ 24 | "src", 25 | "public", 26 | "db", 27 | "scripts", 28 | "astro.config.mjs", 29 | "tailwind.config.mjs", 30 | "tsconfig.json", 31 | ".prettierrc", 32 | ".nvmrc" 33 | ]; 34 | 35 | filesToCopy.forEach((file) => { 36 | const sourcePath = path.join(templateDir, file); 37 | const targetPath = path.join(projectDir, file); 38 | if (fs.existsSync(sourcePath)) { 39 | fs.cpSync(sourcePath, targetPath, { recursive: true }); 40 | } else { 41 | console.warn(`Warning: Could not find ${file} in template`); 42 | } 43 | }); 44 | 45 | // Read and modify package.json 46 | const packageJson = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8")); 47 | 48 | // Modify package.json for new project 49 | const newPackageJson = { 50 | ...packageJson, 51 | name: projectName, 52 | version: "0.1.0", 53 | description: `${projectName} - Built with Freedom Stack`, 54 | repository: undefined, 55 | bin: undefined, 56 | files: undefined, 57 | keywords: undefined, 58 | author: "" 59 | }; 60 | 61 | // Write modified package.json 62 | fs.writeFileSync(path.join(projectDir, "package.json"), JSON.stringify(newPackageJson, null, 2)); 63 | 64 | // Create .env from .env.example with generated BETTER_AUTH_SECRET 65 | const envExamplePath = path.join(templateDir, ".env.example"); 66 | const envPath = path.join(projectDir, ".env"); 67 | 68 | // Check if .env.example exists 69 | if (!fs.existsSync(envExamplePath)) { 70 | console.warn("Warning: .env.example not found in template"); 71 | return; 72 | } 73 | 74 | // Create .env if it doesn't exist 75 | if (!fs.existsSync(envPath)) { 76 | let envContent = fs.readFileSync(envExamplePath, "utf8"); 77 | 78 | // Generate and set BETTER_AUTH_SECRET 79 | const authSecret = randomUUID(); 80 | envContent = envContent.replace('BETTER_AUTH_SECRET=""', `BETTER_AUTH_SECRET="${authSecret}"`); 81 | 82 | fs.writeFileSync(envPath, envContent); 83 | console.log("Created .env file with default configuration"); 84 | } 85 | 86 | // Copy .env.example to new project 87 | fs.copyFileSync(envExamplePath, path.join(projectDir, ".env.example")); 88 | 89 | // Initialize git 90 | process.chdir(projectDir); 91 | execSync("git init"); 92 | 93 | // Create .gitignore if it doesn't exist 94 | const gitignorePath = path.join(projectDir, ".gitignore"); 95 | if (!fs.existsSync(gitignorePath)) { 96 | fs.writeFileSync( 97 | gitignorePath, 98 | ` 99 | # build output 100 | dist/ 101 | .output/ 102 | 103 | # dependencies 104 | node_modules/ 105 | 106 | # logs 107 | npm-debug.log* 108 | yarn-debug.log* 109 | yarn-error.log* 110 | pnpm-debug.log* 111 | 112 | # environment variables 113 | .env 114 | .env.* 115 | !.env.example 116 | 117 | # macOS-specific files 118 | .DS_Store 119 | 120 | # Astro 121 | .astro/ 122 | 123 | # Netlify 124 | .netlify/ 125 | ` 126 | ); 127 | } 128 | 129 | console.log("\n🕊️ Running create-freedom-stack...\n\n"); 130 | 131 | // Install dependencies 132 | const spinner = ora("Installing dependencies...").start(); 133 | try { 134 | execSync("npm install", { stdio: ["pipe", "pipe", "pipe"] }); 135 | spinner.succeed("Dependencies installed successfully!"); 136 | } catch (error) { 137 | spinner.fail("Failed to install dependencies"); 138 | console.error(error.message); 139 | process.exit(1); 140 | } 141 | 142 | console.log(` 143 | 🚀 Freedom Stack project created successfully! 144 | 145 | To get started: 146 | 1. cd ${projectName} 147 | 2. npm run db:setup # Set up your Turso database 148 | 3. npm run dev # Start the development server 149 | 150 | Visit http://localhost:4321 to see your app. 151 | `); 152 | } 153 | 154 | const argv = cli({ 155 | name: "create-freedom-stack", 156 | version: "0.1.0", 157 | description: "Create a new Freedom Stack project with best practices and modern tooling", 158 | flags: {}, 159 | parameters: [""], 160 | help: { 161 | description: "Create a new Freedom Stack project", 162 | examples: ["npx create-freedom-stack my-app"] 163 | } 164 | }); 165 | 166 | const { projectName } = argv._; 167 | createProject(projectName); 168 | -------------------------------------------------------------------------------- /scripts/setup-turso.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { cli } from "cleye"; 4 | import { execSync } from "child_process"; 5 | import fs from "fs"; 6 | import path from "path"; 7 | import readline from "readline"; 8 | 9 | const rl = readline.createInterface({ 10 | input: process.stdin, 11 | output: process.stdout 12 | }); 13 | 14 | async function question(query) { 15 | return new Promise((resolve) => rl.question(query, resolve)); 16 | } 17 | 18 | function checkTursoAuth() { 19 | try { 20 | const output = execSync("turso db list", { encoding: "utf8" }); 21 | return !output.includes("please login with turso auth login"); 22 | } catch (error) { 23 | return false; 24 | } 25 | } 26 | 27 | async function setupTurso(argv) { 28 | console.log("🔧 Setting up Turso database..."); 29 | 30 | try { 31 | // Check if Turso CLI is installed 32 | try { 33 | execSync("turso --version", { stdio: "ignore" }); 34 | } catch (error) { 35 | console.log("📦 Installing Turso CLI..."); 36 | if (process.platform === "darwin") { 37 | execSync("brew install tursodatabase/tap/turso", { stdio: "inherit" }); 38 | } else { 39 | execSync("curl -sSfL https://get.turso.tech/install.sh | bash", { stdio: "inherit" }); 40 | } 41 | } 42 | 43 | // Check if user is authenticated with Turso 44 | if (!checkTursoAuth()) { 45 | console.log("\n❌ You need to authenticate with Turso first."); 46 | console.log("\nRun these commands in order:"); 47 | console.log("1. turso auth login"); 48 | console.log("2. npm run db:setup"); 49 | process.exit(1); 50 | } 51 | 52 | // Use database name from CLI args or prompt 53 | const dbName = 54 | argv.flags.name || 55 | (await question("\nEnter a name for your database (default: freedom-stack-db): ")) || 56 | "freedom-stack-db"; 57 | 58 | // Create database 59 | console.log(`\n📚 Creating database: ${dbName}...`); 60 | try { 61 | execSync(`turso db create ${dbName}`, { stdio: "inherit" }); 62 | } catch (error) { 63 | if (error.message.includes("not logged in")) { 64 | console.log("\n❌ Turso authentication required."); 65 | console.log("\nRun these commands in order:"); 66 | console.log("1. turso auth login"); 67 | console.log("2. npm run db:setup"); 68 | process.exit(1); 69 | } 70 | throw error; 71 | } 72 | 73 | // Get database URL 74 | console.log("\n🔗 Getting database URL..."); 75 | const dbUrl = execSync(`turso db show ${dbName} --url`, { encoding: "utf8" }).trim(); 76 | 77 | // Create auth token 78 | console.log("\n🔑 Creating auth token..."); 79 | const authToken = execSync(`turso db tokens create ${dbName}`, { encoding: "utf8" }).trim(); 80 | 81 | // Update .env file 82 | const envPath = path.join(process.cwd(), ".env"); 83 | let envContent = ""; 84 | 85 | if (fs.existsSync(envPath)) { 86 | envContent = fs.readFileSync(envPath, "utf8"); 87 | } 88 | 89 | // Replace or add Turso environment variables 90 | const envVars = { 91 | ASTRO_DB_REMOTE_URL: dbUrl, 92 | ASTRO_DB_APP_TOKEN: authToken 93 | }; 94 | 95 | for (const [key, value] of Object.entries(envVars)) { 96 | const regex = new RegExp(`^${key}=.*$`, "m"); 97 | if (envContent.match(regex)) { 98 | envContent = envContent.replace(regex, `${key}=${value}`); 99 | } else { 100 | envContent += `\n${key}=${value}`; 101 | } 102 | } 103 | 104 | fs.writeFileSync(envPath, envContent.trim() + "\n"); 105 | 106 | console.log(` 107 | ✅ Turso database setup complete! 108 | 109 | The following environment variables have been added to your .env file: 110 | ASTRO_DB_REMOTE_URL=${dbUrl} 111 | ASTRO_DB_APP_TOKEN=${authToken} 112 | 113 | You can now run: 114 | npm run db:update-schemas # To push your schema to the database 115 | npm run dev # To start your development server 116 | `); 117 | } catch (error) { 118 | console.error("❌ Error setting up Turso:", error.message); 119 | process.exit(1); 120 | } finally { 121 | rl.close(); 122 | } 123 | } 124 | 125 | cli( 126 | { 127 | name: "setup-turso", 128 | version: "0.1.0", 129 | description: "Set up a Turso database for your Freedom Stack project", 130 | flags: { 131 | name: { 132 | type: String, 133 | description: "Name of the database to create", 134 | alias: "n" 135 | }, 136 | force: { 137 | type: Boolean, 138 | description: "Override existing database if it exists", 139 | alias: "f", 140 | default: false 141 | } 142 | }, 143 | help: { 144 | examples: ["npm run db:setup", "npm run db:setup --name my-database", "npm run db:setup -n my-database --force"] 145 | } 146 | }, 147 | async (argv) => { 148 | await setupTurso(argv); 149 | } 150 | ); 151 | -------------------------------------------------------------------------------- /src/actions/auth.ts: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/HiDeoo/starlight-better-auth-example/blob/main/src/actions/index.ts 2 | import { defineAction, ActionError, type ActionErrorCode } from "astro:actions"; 3 | import { z } from "astro:schema"; 4 | import type { AstroCookies } from "astro"; 5 | import { APIError } from "better-auth/api"; 6 | import { auth as betterAuth } from "@/lib/auth"; 7 | import type { ActionAPIContext } from "astro:actions"; 8 | 9 | function parseCookiesFromResponse(cookiesArray: string[]) { 10 | return cookiesArray.map((cookieString) => { 11 | const [nameValue, ...options] = cookieString.split(";").map((s) => s.trim()); 12 | const [name, value] = nameValue.split("="); 13 | 14 | const cookieOptions = Object.fromEntries( 15 | options.map((opt) => { 16 | const [key, val] = opt.split("="); 17 | return [key.toLowerCase(), val ?? true]; 18 | }) 19 | ); 20 | 21 | return { name, value: decodeURIComponent(value), options: cookieOptions }; 22 | }); 23 | } 24 | 25 | export function setAuthCookiesFromResponse(cookiesArray: string[], cookies: AstroCookies) { 26 | const cookiesToSet = parseCookiesFromResponse(cookiesArray); 27 | for (const cookie of cookiesToSet) { 28 | cookies.set(cookie.name, cookie.value, cookie.options); 29 | } 30 | } 31 | 32 | async function handleAuthResponse( 33 | apiCall: () => Promise, 34 | _context: ActionAPIContext, 35 | errorCode: ActionErrorCode 36 | ) { 37 | try { 38 | const response = await apiCall(); 39 | if (!response.ok) { 40 | throw new Error(`Failed to ${errorCode.toLowerCase()}`); 41 | } 42 | 43 | return { success: true, cookiesToSet: response.headers.getSetCookie() }; 44 | } catch (error) { 45 | throwActionAuthError(errorCode, error); 46 | } 47 | } 48 | 49 | export const auth = { 50 | signUp: defineAction({ 51 | accept: "form", 52 | input: z.object({ 53 | email: z.string().email(), 54 | password: z.string(), 55 | name: z.string(), 56 | imageUrl: z.string().optional(), 57 | middleware: z.string().optional() 58 | }), 59 | handler: async (input, context) => { 60 | if (input.middleware) { 61 | throw new ActionError({ 62 | code: "BAD_REQUEST", 63 | message: "Bots are not allowed to sign up" 64 | }); 65 | } 66 | 67 | return await handleAuthResponse( 68 | () => 69 | betterAuth.api.signUpEmail({ 70 | body: { ...input, image: input.imageUrl || "" }, 71 | headers: context.request.headers, 72 | asResponse: true 73 | }), 74 | context, 75 | "BAD_REQUEST" 76 | ); 77 | } 78 | }), 79 | 80 | signIn: defineAction({ 81 | accept: "form", 82 | input: z.object({ 83 | email: z.string().email(), 84 | password: z.string() 85 | }), 86 | handler: async (input, context) => 87 | await handleAuthResponse( 88 | () => 89 | betterAuth.api.signInEmail({ 90 | body: input, 91 | headers: context.request.headers, 92 | asResponse: true 93 | }), 94 | context, 95 | "UNAUTHORIZED" 96 | ) 97 | }), 98 | 99 | signOut: defineAction({ 100 | accept: "form", 101 | handler: async (_, context) => 102 | await handleAuthResponse( 103 | () => 104 | betterAuth.api.signOut({ 105 | headers: context.request.headers, 106 | asResponse: true 107 | }), 108 | context, 109 | "BAD_REQUEST" 110 | ) 111 | }) 112 | }; 113 | 114 | function throwActionAuthError(code: ActionErrorCode, error: any): never { 115 | console.log(error.message); 116 | if (error?.message?.toLowerCase().includes("unauthorized")) { 117 | throw new ActionError({ 118 | code, 119 | message: "Check your credentials and try again, or make sure you've verified your email." 120 | }); 121 | } 122 | 123 | throw new ActionError({ 124 | code, 125 | message: "Something went wrong, please try again later." 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { posts } from "./posts"; 2 | import { auth } from "./auth"; 3 | 4 | export const server = { 5 | posts, 6 | auth 7 | }; 8 | -------------------------------------------------------------------------------- /src/actions/posts.ts: -------------------------------------------------------------------------------- 1 | import { defineAction, ActionError } from "astro:actions"; 2 | import { db, Posts, eq } from "astro:db"; 3 | import { z } from "astro:schema"; 4 | import { purgeCache } from "@netlify/functions"; 5 | 6 | export const posts = { 7 | // Create post 8 | create: defineAction({ 9 | accept: "form", 10 | input: z.object({ 11 | title: z.string(), 12 | pubDate: z.string().transform((str) => new Date(str)), 13 | description: z.string(), 14 | author: z.string(), 15 | imageUrl: z.string().nullable(), 16 | imageAlt: z.string().nullable(), 17 | tags: z 18 | .string() 19 | .transform((str) => JSON.parse(str)) 20 | .nullable(), 21 | slug: z.string(), 22 | content: z.string() 23 | }), 24 | handler: async (input) => { 25 | try { 26 | const posts = await db.insert(Posts).values(input).returning(); 27 | 28 | const post = posts[0]; 29 | 30 | if (import.meta.env.PROD) { 31 | try { 32 | await purgeCache({ tags: ["posts"] }); 33 | } catch (error) { 34 | console.error("Error purging cache:", error); 35 | } 36 | } 37 | 38 | return { 39 | success: true as const, 40 | post 41 | }; 42 | } catch (error) { 43 | throw new ActionError({ 44 | code: "INTERNAL_SERVER_ERROR", 45 | message: "Error creating post" 46 | }); 47 | } 48 | } 49 | }), 50 | 51 | // Read post 52 | get: defineAction({ 53 | input: z.object({ 54 | slug: z.string() 55 | }), 56 | handler: async ({ slug }) => { 57 | const post = await db.select().from(Posts).where(eq(Posts.slug, slug)); 58 | if (!post) { 59 | throw new ActionError({ 60 | code: "NOT_FOUND", 61 | message: "Post not found" 62 | }); 63 | } 64 | return { 65 | success: true as const, 66 | post 67 | }; 68 | } 69 | }), 70 | 71 | // Update post 72 | update: defineAction({ 73 | accept: "form", 74 | input: z.object({ 75 | id: z.number(), 76 | title: z.string().optional(), 77 | pubDate: z 78 | .string() 79 | .transform((str) => new Date(str)) 80 | .optional(), 81 | description: z.string().optional(), 82 | author: z.string().optional(), 83 | imageUrl: z.string().nullable().optional(), 84 | imageAlt: z.string().nullable().optional(), 85 | tags: z 86 | .string() 87 | .transform((str) => JSON.parse(str)) 88 | .nullable() 89 | .optional(), 90 | slug: z.string().optional(), 91 | content: z.string().optional() 92 | }), 93 | handler: async (input) => { 94 | try { 95 | const posts = await db.update(Posts).set(input).where(eq(Posts.id, input.id)).returning(); 96 | 97 | const post = posts[0]; 98 | 99 | if (import.meta.env.PROD) { 100 | try { 101 | await purgeCache({ tags: [`post-${post.slug}`] }); 102 | } catch (error) { 103 | console.error("Error purging cache:", error); 104 | } 105 | } 106 | 107 | return { 108 | success: true as const, 109 | post 110 | }; 111 | } catch (error) { 112 | throw new ActionError({ 113 | code: "INTERNAL_SERVER_ERROR", 114 | message: "Error updating post" 115 | }); 116 | } 117 | } 118 | }), 119 | 120 | // Delete post 121 | delete: defineAction({ 122 | accept: "form", 123 | input: z.object({ 124 | id: z.number() 125 | }), 126 | handler: async ({ id }) => { 127 | try { 128 | const posts = await db.delete(Posts).where(eq(Posts.id, id)); 129 | if (!posts) { 130 | throw new ActionError({ 131 | code: "NOT_FOUND", 132 | message: "Post not found" 133 | }); 134 | } 135 | 136 | if (import.meta.env.PROD) { 137 | try { 138 | await purgeCache({ tags: ["posts"] }); 139 | } catch (error) { 140 | console.error("Error purging cache:", error); 141 | } 142 | } 143 | 144 | return { 145 | success: true as const 146 | }; 147 | } catch (error) { 148 | throw new ActionError({ 149 | code: "INTERNAL_SERVER_ERROR", 150 | message: "Error deleting post" 151 | }); 152 | } 153 | } 154 | }) 155 | }; 156 | -------------------------------------------------------------------------------- /src/assets/deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameronapak/freedom-stack/2d5ac9ca7f50c8d4781edd41f4e70532303e985f/src/assets/deploy.png -------------------------------------------------------------------------------- /src/assets/full-stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameronapak/freedom-stack/2d5ac9ca7f50c8d4781edd41f4e70532303e985f/src/assets/full-stack.png -------------------------------------------------------------------------------- /src/assets/sections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameronapak/freedom-stack/2d5ac9ca7f50c8d4781edd41f4e70532303e985f/src/assets/sections.png -------------------------------------------------------------------------------- /src/components/IconBlock.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Icon } from "astro-iconify"; 3 | 4 | interface Props { 5 | title: string; 6 | description: string; 7 | icon: string; 8 | } 9 | 10 | const { title, description, icon } = Astro.props as Props; 11 | --- 12 | 13 |
14 | 15 |
16 |

17 | {title} 18 |

19 |

20 | {description} 21 |

22 |
23 |
24 | -------------------------------------------------------------------------------- /src/components/RenderMarkdown.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import DOMPurify from "isomorphic-dompurify"; 3 | import { marked } from "marked"; 4 | 5 | interface Props { 6 | content: string; 7 | } 8 | 9 | const { content } = Astro.props; 10 | 11 | const html = await marked.parse(content); 12 | 13 | // Convert markdown to HTML and sanitize 14 | const purifiedHTML = DOMPurify.sanitize(html, { 15 | FORBID_TAGS: ["script", "style", "iframe", "form"], 16 | FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover"] 17 | }); 18 | --- 19 | 20 |
21 | -------------------------------------------------------------------------------- /src/components/ShowIfAuthenticated.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const session = Astro.locals.session; 3 | --- 4 | 5 | {session ? : null} 6 | -------------------------------------------------------------------------------- /src/components/TrixEditor.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | id?: string; 4 | name?: string; 5 | content: string; 6 | alpineRefName?: string; 7 | } 8 | 9 | const { content, id = "content", name = "content", alpineRefName = "contentInputEl" } = Astro.props as Props; 10 | --- 11 | 12 |
13 | 14 | 15 |
16 | 17 | 20 | -------------------------------------------------------------------------------- /src/components/sections/Container.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title?: string; 4 | subtitle?: string; 5 | align?: "center" | "left" | "right"; 6 | titleIsH1?: boolean; 7 | className?: string; 8 | } 9 | 10 | const { title, subtitle, align = "left", titleIsH1 = false, className = "" } = Astro.props; 11 | --- 12 | 13 |
14 | { 15 | title || subtitle ? ( 16 |
17 | {title && !titleIsH1 ?

{title}

: null} 18 | {title && titleIsH1 ?

{title}

: null} 19 | {subtitle &&

{subtitle}

} 20 |
21 | ) : null 22 | } 23 | 24 | 25 |
26 | -------------------------------------------------------------------------------- /src/components/sections/FAQ.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Container from "@sections/Container.astro"; 3 | import RenderMarkdown from "@/components/RenderMarkdown.astro"; 4 | 5 | interface Props { 6 | title: string; 7 | subtitle?: string; 8 | align?: "center" | "left" | "right"; 9 | items: { 10 | question: string; 11 | answer: string; 12 | }[]; 13 | } 14 | 15 | const { title, subtitle, align = "center", items } = Astro.props as Props; 16 | --- 17 | 18 | 19 | { 20 | items.map((item, index) => ( 21 |
22 | 23 |

{item.question}

24 |
25 | 26 |
27 |
28 | )) 29 | } 30 |
31 | 32 | 38 | -------------------------------------------------------------------------------- /src/components/sections/Features/Card.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | description: string; 5 | } 6 | 7 | const { title, description } = Astro.props as Props; 8 | --- 9 | 10 |
11 |
12 |

{title}

13 |

{description}

14 |
15 |
16 | -------------------------------------------------------------------------------- /src/components/sections/Features/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Container from "@sections/Container.astro"; 3 | import Card from "@sections/Features/Card.astro"; 4 | 5 | interface Props { 6 | features: { 7 | title: string; 8 | description: string; 9 | }[]; 10 | } 11 | 12 | const { features } = Astro.props as Props; 13 | --- 14 | 15 | 16 |
17 | {features.map((feature) => )} 18 |
19 |
20 | -------------------------------------------------------------------------------- /src/components/sections/Footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title?: string; 4 | description?: string; 5 | links?: { 6 | title: string; 7 | items: { 8 | text: string; 9 | href: string; 10 | }[]; 11 | }[]; 12 | copyright?: string; 13 | } 14 | 15 | const { 16 | title = "Freedom Stack", 17 | description = "A full-stack Astro starter kit that feels freeing and is free.", 18 | links = [ 19 | { 20 | title: "Product", 21 | items: [ 22 | { text: "Features", href: "#" }, 23 | { text: "Documentation", href: "#" } 24 | ] 25 | }, 26 | { 27 | title: "Company", 28 | items: [ 29 | { text: "About", href: "#" }, 30 | { text: "Blog", href: "#" } 31 | ] 32 | } 33 | ], 34 | copyright = `© ${new Date().getFullYear()} Freedom Stack. All rights reserved.` 35 | } = Astro.props; 36 | --- 37 | 38 |
39 | 63 |
64 | -------------------------------------------------------------------------------- /src/components/sections/HalfTextHalfImage.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | subtitle?: string; 5 | image: string; 6 | reverse?: boolean; 7 | } 8 | 9 | const { title, subtitle, image, reverse = false } = Astro.props as Props; 10 | --- 11 | 12 |
13 |
14 |
15 |

{title}

16 | {subtitle &&

{subtitle}

} 17 |
18 |
19 | {title} 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/components/sections/Hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | subtitle?: string; 5 | ctaText?: string; 6 | ctaHref?: string; 7 | secondaryCtaText?: string; 8 | secondaryCtaHref?: string; 9 | } 10 | 11 | const { title, subtitle, ctaText = "Get Started", ctaHref = "#", secondaryCtaText, secondaryCtaHref } = Astro.props; 12 | --- 13 | 14 |
15 |

{title}

16 | {subtitle &&

{subtitle}

} 17 |
18 | {ctaText} 19 | { 20 | secondaryCtaText && secondaryCtaHref && ( 21 | 22 | {secondaryCtaText} 23 | 24 | ) 25 | } 26 |
27 |
28 | -------------------------------------------------------------------------------- /src/components/sections/Navbar.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | links?: { 5 | text: string; 6 | href: string; 7 | }[]; 8 | ctaText?: string; 9 | ctaHref?: string; 10 | sticky?: boolean; 11 | } 12 | 13 | const { title, links = [], ctaText, ctaHref, sticky = false } = Astro.props; 14 | 15 | const hasLinks = links.length > 0; 16 | const hasCta = Boolean(ctaText && ctaHref); 17 | --- 18 | 19 | 85 | -------------------------------------------------------------------------------- /src/components/sections/Quote.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | quote: string; 4 | author: string; 5 | title?: string; 6 | image?: string; 7 | } 8 | 9 | const { quote, author, title, image } = Astro.props as Props; 10 | --- 11 | 12 |
13 |
14 |
15 | {image && {author}} 16 |
17 | "{quote}" 18 |
19 |
20 | {author} 21 | {title && {title}} 22 |
23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /src/components/server/GithubButton.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Icon } from "astro-iconify"; 3 | 4 | // https://docs.astro.build/en/basics/astro-pages/#page-partials 5 | const repo = await fetch("https://api.github.com/repos/cameronapak/freedom-stack", { 6 | headers: { 7 | // Cache for 4 hours 8 | "Cache-Control": "public, max-age=14400", 9 | // Accept JSON response 10 | Accept: "application/json" 11 | } 12 | }); 13 | const repoData = await repo.json(); 14 | const stars = repoData.stargazers_count; 15 | --- 16 | 17 | 22 | GitHub 23 |
24 | 25 | {stars} 26 |
27 |
28 | -------------------------------------------------------------------------------- /src/entrypoint.ts: -------------------------------------------------------------------------------- 1 | import type { Alpine } from "alpinejs"; 2 | // @ts-ignore - Has no associated types. 3 | import intersect from "@alpinejs/intersect"; 4 | // @ts-ignore - Has no associated types. 5 | import persist from "@alpinejs/persist"; 6 | // @ts-ignore - Has no associated types. 7 | import collapse from "@alpinejs/collapse"; 8 | // @ts-ignore - Has no associated types. 9 | import mask from "@alpinejs/mask"; 10 | 11 | export default (Alpine: Alpine) => { 12 | Alpine.plugin(intersect); 13 | Alpine.plugin(persist); 14 | Alpine.plugin(collapse); 15 | Alpine.plugin(mask); 16 | }; 17 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | import * as htmx from "htmx.org"; 6 | 7 | declare global { 8 | interface Window { 9 | Alpine: import("alpinejs").Alpine; 10 | htmx: typeof htmx; 11 | } 12 | 13 | namespace App { 14 | interface Locals { 15 | user: import("better-auth").User | null; 16 | session: import("better-auth").Session | null; 17 | } 18 | } 19 | } 20 | 21 | // https://docs.astro.build/en/guides/environment-variables/#intellisense-for-typescript 22 | interface ImportMetaEnv { 23 | /** https://docs.astro.build/en/guides/astro-db/#libsql */ 24 | readonly ASTRO_DB_REMOTE_URL: string; 25 | /** https://docs.astro.build/en/guides/astro-db/#libsql */ 26 | readonly ASTRO_DB_APP_TOKEN: string; 27 | /** https://better-auth.com/ */ 28 | readonly BETTER_AUTH_URL: string; 29 | /** https://better-auth.com/ */ 30 | readonly BETTER_AUTH_SECRET: string; 31 | /** Toggle on email verification */ 32 | readonly BETTER_AUTH_EMAIL_VERIFICATION: "true" | "false"; 33 | /** Mail server host */ 34 | readonly MAIL_HOST: string; 35 | /** Mail server port */ 36 | readonly MAIL_PORT: string; 37 | /** Mail server secure setting */ 38 | readonly MAIL_SECURE: string; 39 | /** Mail server auth user */ 40 | readonly MAIL_AUTH_USER: string; 41 | /** Mail server auth password */ 42 | readonly MAIL_AUTH_PASS: string; 43 | /** Email address to send from */ 44 | readonly MAIL_FROM: string; 45 | } 46 | 47 | interface ImportMeta { 48 | readonly env: ImportMetaEnv; 49 | } 50 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/typography"; 3 | @plugin "daisyui"; 4 | @plugin "daisyui/theme" { 5 | name: "light"; 6 | default: true; 7 | prefersdark: false; 8 | color-scheme: "light"; 9 | --color-base-100: oklch(100% 0 0); 10 | --color-base-200: oklch(98% 0.003 247.858); 11 | --color-base-300: oklch(96% 0.007 247.896); 12 | --color-base-content: oklch(21% 0.006 285.885); 13 | --color-primary: oklch(14% 0.005 285.823); 14 | --color-primary-content: oklch(93% 0.034 272.788); 15 | --color-secondary: oklch(92% 0.013 255.508); 16 | --color-secondary-content: oklch(21% 0.006 285.885); 17 | --color-accent: oklch(93% 0.034 272.788); 18 | --color-accent-content: oklch(38% 0.063 188.416); 19 | --color-neutral: oklch(14% 0.005 285.823); 20 | --color-neutral-content: oklch(92% 0.004 286.32); 21 | --color-info: oklch(95% 0.045 203.388); 22 | --color-info-content: oklch(29% 0.066 243.157); 23 | --color-success: oklch(95% 0.052 163.051); 24 | --color-success-content: oklch(37% 0.077 168.94); 25 | --color-warning: oklch(96% 0.059 95.617); 26 | --color-warning-content: oklch(41% 0.112 45.904); 27 | --color-error: oklch(94% 0.03 12.58); 28 | --color-error-content: oklch(27% 0.105 12.094); 29 | --radius-selector: 1rem; 30 | --radius-field: 1rem; 31 | --radius-box: 1rem; 32 | --size-selector: 0.3125rem; 33 | --size-field: 0.3125rem; 34 | --border: 2px; 35 | --depth: 0; 36 | --noise: 0; 37 | } 38 | 39 | /** 40 | * I could not figure out how to get the theme above to work for both light and dark mode, 41 | * so I copied and pasted the light theme and changed the values to dark. 42 | */ 43 | @plugin "daisyui/theme" { 44 | name: "dark"; 45 | default: false; 46 | prefersdark: true; 47 | color-scheme: "dark"; 48 | --color-base-100: oklch(100% 0 0); 49 | --color-base-200: oklch(98% 0.003 247.858); 50 | --color-base-300: oklch(96% 0.007 247.896); 51 | --color-base-content: oklch(21% 0.006 285.885); 52 | --color-primary: oklch(14% 0.005 285.823); 53 | --color-primary-content: oklch(93% 0.034 272.788); 54 | --color-secondary: oklch(92% 0.013 255.508); 55 | --color-secondary-content: oklch(21% 0.006 285.885); 56 | --color-accent: oklch(93% 0.034 272.788); 57 | --color-accent-content: oklch(38% 0.063 188.416); 58 | --color-neutral: oklch(14% 0.005 285.823); 59 | --color-neutral-content: oklch(92% 0.004 286.32); 60 | --color-info: oklch(95% 0.045 203.388); 61 | --color-info-content: oklch(29% 0.066 243.157); 62 | --color-success: oklch(95% 0.052 163.051); 63 | --color-success-content: oklch(37% 0.077 168.94); 64 | --color-warning: oklch(96% 0.059 95.617); 65 | --color-warning-content: oklch(41% 0.112 45.904); 66 | --color-error: oklch(94% 0.03 12.58); 67 | --color-error-content: oklch(27% 0.105 12.094); 68 | --radius-selector: 1rem; 69 | --radius-field: 1rem; 70 | --radius-box: 1rem; 71 | --size-selector: 0.3125rem; 72 | --size-field: 0.3125rem; 73 | --border: 2px; 74 | --depth: 0; 75 | --noise: 0; 76 | } 77 | 78 | @layer components { 79 | .container { 80 | max-width: 768px !important; 81 | padding-inline: 24px; 82 | margin: 0 auto; 83 | } 84 | } 85 | 86 | a.btn { 87 | @apply no-underline; 88 | } 89 | 90 | /* Add CSS styles below. */ 91 | .example-class { 92 | /* You can even apply Tailwind classes here. */ 93 | @apply bg-red-500 text-white; 94 | } 95 | 96 | /* Override some of the Tailwind .prose CSS */ 97 | .prose h1, 98 | .prose h2, 99 | .prose h3, 100 | .prose h4, 101 | .prose h5, 102 | .prose h6 { 103 | @apply m-0 mb-2; 104 | text-wrap: balance; 105 | } 106 | 107 | .balanced { 108 | max-inline-size: 50ch; 109 | text-wrap: balance; 110 | } 111 | 112 | /* Daisy UI Overrides */ 113 | .breadcrumbs > ul > li, 114 | .breadcrumbs > ol > li { 115 | @apply p-0; 116 | } 117 | 118 | .text-muted { 119 | @apply text-gray-500; 120 | } 121 | 122 | /* Alpine.js */ 123 | [x-cloak] { 124 | display: none; 125 | } 126 | 127 | .btn.btn-outline { 128 | @apply border-2 border-slate-200 hover:border-slate-200 hover:bg-inherit hover:text-inherit; 129 | } 130 | 131 | .btn.btn-primary { 132 | @apply text-white; 133 | } 134 | 135 | .btn.btn-primary:disabled { 136 | @apply bg-slate-200 text-slate-400 cursor-not-allowed shadow-none; 137 | } 138 | 139 | /* Trix Editor */ 140 | #trix-editor trix-toolbar { 141 | .trix-button-group { 142 | button.trix-button, 143 | input[type="button"].trix-button { 144 | &.trix-active { 145 | @apply bg-slate-200; 146 | } 147 | 148 | &:hover { 149 | @apply bg-slate-200; 150 | } 151 | 152 | @apply !border-0 rounded-lg; 153 | } 154 | 155 | @apply border-0 rounded-lg mb-0; 156 | } 157 | 158 | [data-trix-button-group="file-tools"] { 159 | @apply !hidden; 160 | } 161 | 162 | [data-trix-attribute="quote"] { 163 | @apply !border-l-0; 164 | } 165 | 166 | [data-trix-dialog][data-trix-active] { 167 | .trix-input { 168 | @apply border-2 border-slate-200 rounded-lg; 169 | } 170 | 171 | @apply !border-0 rounded-xl p-6; 172 | } 173 | 174 | @apply mb-6 sticky top-2 left-0 right-0 z-50 border-0 bg-slate-100 px-3 py-2 rounded-full; 175 | } 176 | 177 | trix-editor, 178 | .rendered-markdown { 179 | h1 { 180 | @apply text-2xl font-extrabold; 181 | } 182 | 183 | :first-child { 184 | @apply mt-0; 185 | } 186 | 187 | :last-child { 188 | @apply mb-0; 189 | } 190 | 191 | @apply p-0 m-0 border-0 text-xl leading-relaxed; 192 | } 193 | 194 | #trix-editor trix-editor { 195 | a { 196 | @apply font-medium underline; 197 | } 198 | 199 | ul { 200 | @apply list-disc list-inside; 201 | } 202 | 203 | ol { 204 | @apply list-decimal list-inside; 205 | } 206 | 207 | @apply p-6 border-2 border-slate-200 rounded-lg; 208 | } 209 | 210 | input[type="text"], 211 | input[type="password"], 212 | input[type="email"], 213 | textarea { 214 | &:-internal-autofill-selected { 215 | @apply !border-2 !border-slate-200 !bg-white; 216 | } 217 | 218 | @apply !border-2 !border-slate-200 !bg-white; 219 | } 220 | -------------------------------------------------------------------------------- /src/icons/astro.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 14 | 17 | 20 | 23 | 24 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import "trix/dist/trix.css"; 3 | import { SEO } from "astro-seo"; 4 | import Icon from "astro-iconify"; 5 | 6 | interface Props { 7 | title: string; 8 | description?: string; 9 | bodyClasses?: string; 10 | canonicalUrl?: string; 11 | faviconUrl?: string; 12 | ogImageUrl?: string; 13 | } 14 | 15 | const { title, description = "", bodyClasses, canonicalUrl, faviconUrl, ogImageUrl = "/og-image.png" } = Astro.props; 16 | --- 17 | 18 | 19 | 27 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 77 | 78 | 79 | 80 |
{ 85 | if (toastMessage) { 86 | this.backupOfToastMessage = toastMessage 87 | } 88 | }) 89 | } 90 | }`} 91 | x-cloak 92 | x-show="toastMessage" 93 | x-transition:enter="transition ease-out duration-300" 94 | x-transition:enter-start="opacity-0 transform -translate-y-2" 95 | x-transition:enter-end="opacity-100 transform translate-y-0" 96 | x-transition:leave="transition ease-in duration-300" 97 | x-transition:leave-start="opacity-100 transform translate-y-0" 98 | x-transition:leave-end="opacity-0 transform -translate-y-2" 99 | x-init="setTimeout(() => toastMessage = '', 5000)" 100 | class="z-50 toast toast-top toast-center max-w-sm w-full" 101 | > 102 | 106 |
107 | 108 |
{ 113 | if (toastErrorMessage) { 114 | this.backupOfToastErrorMessage = toastErrorMessage 115 | } 116 | }) 117 | } 118 | }`} 119 | x-cloak 120 | x-show="toastErrorMessage" 121 | x-transition:enter="transition ease-out duration-300" 122 | x-transition:enter-start="opacity-0 transform -translate-y-2" 123 | x-transition:enter-end="opacity-100 transform translate-y-0" 124 | x-transition:leave="transition ease-in duration-300" 125 | x-transition:leave-start="opacity-100 transform translate-y-0" 126 | x-transition:leave-end="opacity-0 transform -translate-y-2" 127 | class="z-50 toast toast-top toast-center max-w-sm w-full" 128 | > 129 | 136 |
137 | 138 | { 139 | /* The prose class from @tailwindcss/typography plugin provides beautiful typographic defaults for HTML content like articles, blog posts, and documentation. It styles headings, lists, code blocks, tables etc. */ 140 | } 141 |
142 | 143 |
144 | 145 | {/* One Dollar Stats, by Drizzle */} 146 | 148 | 149 | 150 | 151 | 157 | 158 | 161 | -------------------------------------------------------------------------------- /src/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/client"; 2 | 3 | export const client = createAuthClient({ 4 | baseURL: import.meta.env.BETTER_AUTH_URL 5 | }); 6 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { Account, db, Session, User, Verification } from "astro:db"; 3 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 | import { sendEmail } from "@/lib/email"; 5 | 6 | export const auth = betterAuth({ 7 | baseURL: import.meta.env.BETTER_AUTH_URL, 8 | secret: import.meta.env.BETTER_AUTH_SECRET, 9 | account: { 10 | accountLinking: { 11 | enabled: true 12 | } 13 | }, 14 | database: drizzleAdapter(db, { 15 | schema: { 16 | user: User, 17 | account: Account, 18 | session: Session, 19 | verification: Verification 20 | }, 21 | provider: "sqlite" 22 | }), 23 | emailVerification: { 24 | sendOnSignUp: Boolean(import.meta.env.BETTER_AUTH_EMAIL_VERIFICATION === "true"), 25 | sendVerificationEmail: async ({ user, url, token }, request) => { 26 | const updatedUrl = new URL(url); 27 | updatedUrl.searchParams.set("callbackURL", "/sign-out"); 28 | await sendEmail({ 29 | to: user.email, 30 | subject: "Verify your email address", 31 | html: `Click the link to verify your email` 32 | }); 33 | } 34 | }, 35 | emailAndPassword: { 36 | enabled: true, 37 | requireEmailVerification: Boolean(import.meta.env.BETTER_AUTH_EMAIL_VERIFICATION === "true") 38 | }, 39 | // https://www.better-auth.com/docs/concepts/session-management#session-caching 40 | session: { 41 | cookieCache: { 42 | enabled: true, 43 | maxAge: 5 * 60 // Cache duration in seconds 44 | } 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /src/lib/email.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from: https://developers.netlify.com/guides/send-emails-with-astro-and-resend/ 3 | */ 4 | import { createTransport, type Transporter } from "nodemailer"; 5 | 6 | type SendEmailOptions = { 7 | /** Email address of the recipient */ 8 | to: string; 9 | /** Subject line of the email */ 10 | subject: string; 11 | /** Message used for the body of the email */ 12 | html: string; 13 | }; 14 | 15 | // Singleton instance 16 | let transporter: Transporter | null = null; 17 | 18 | async function getEmailTransporter(): Promise { 19 | const requiredEnvVars = ["MAIL_HOST", "MAIL_PORT", "MAIL_SECURE", "MAIL_AUTH_USER", "MAIL_AUTH_PASS", "MAIL_FROM"]; 20 | 21 | const missingEnvVars = requiredEnvVars.filter((envVar) => !import.meta.env[envVar]); 22 | 23 | if (missingEnvVars.length > 0) { 24 | throw new Error(`Missing mail configuration: ${missingEnvVars.join(", ")}`); 25 | } 26 | 27 | // Create new transporter if none exists 28 | transporter = createTransport({ 29 | host: import.meta.env.MAIL_HOST, 30 | port: parseInt(import.meta.env.MAIL_PORT), 31 | secure: import.meta.env.MAIL_SECURE === "true", 32 | auth: { 33 | user: import.meta.env.MAIL_AUTH_USER, 34 | pass: import.meta.env.MAIL_AUTH_PASS 35 | } 36 | }); 37 | 38 | return transporter; 39 | } 40 | 41 | export async function sendEmail(options: SendEmailOptions): Promise { 42 | const emailTransporter = await getEmailTransporter(); 43 | return new Promise(async (resolve, reject) => { 44 | // Build the email message 45 | const { to, subject, html } = options; 46 | const from = import.meta.env.MAIL_FROM; 47 | const message = { to, subject, html, from }; 48 | 49 | // Send the email 50 | // `await` added because of this bug: https://community.redwoodjs.com/t/sending-smtp-emails-via-netlify/4551/5 51 | await emailTransporter.sendMail(message, (err, info) => { 52 | if (err) { 53 | console.error(err); 54 | reject(err); 55 | } 56 | console.log("Message sent:", info.messageId); 57 | resolve(info); 58 | }); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { defineMiddleware } from "astro:middleware"; 3 | 4 | export const onRequest = defineMiddleware(async (context, next) => { 5 | const isAuthed = await auth.api.getSession({ 6 | headers: context.request.headers 7 | }); 8 | 9 | if (isAuthed) { 10 | context.locals.user = isAuthed.user; 11 | context.locals.session = isAuthed.session; 12 | } else { 13 | context.locals.user = null; 14 | context.locals.session = null; 15 | } 16 | 17 | return next(); 18 | }); 19 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...all].ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import type { APIRoute } from "astro"; 3 | 4 | export const ALL: APIRoute = async (ctx) => { 5 | return auth.handler(ctx.request); 6 | }; 7 | -------------------------------------------------------------------------------- /src/pages/api/create-post.ts: -------------------------------------------------------------------------------- 1 | import { db, Posts } from "astro:db"; 2 | import type { APIContext } from "astro"; 3 | import { purgeCache } from "@netlify/functions"; 4 | 5 | export async function POST(context: APIContext): Promise { 6 | try { 7 | const formData = await context.request.formData(); 8 | 9 | const post = { 10 | title: formData.get("title") as string, 11 | pubDate: new Date(formData.get("pubDate") as string), 12 | description: formData.get("description") as string, 13 | author: formData.get("author") as string, 14 | imageUrl: formData.get("imageUrl") as string | null, 15 | imageAlt: formData.get("imageAlt") as string | null, 16 | tags: formData.get("tags") ? JSON.parse(formData.get("tags") as string) : null, 17 | slug: formData.get("slug") as string, 18 | content: formData.get("content") as string 19 | }; 20 | 21 | // Validate required fields 22 | if (!post.title || !post.pubDate || !post.description || !post.author || !post.slug || !post.content) { 23 | return new Response("Missing required fields", { status: 400 }); 24 | } 25 | 26 | await db.insert(Posts).values(post); 27 | 28 | if (import.meta.env.PROD) { 29 | try { 30 | await purgeCache({ 31 | tags: ["posts"] 32 | }); 33 | } catch (error) { 34 | console.error("Error purging cache:", error); 35 | } 36 | } 37 | 38 | return context.redirect("/posts"); 39 | } catch (error) { 40 | console.error("Error creating post:", error); 41 | return new Response("Error creating post", { status: 500 }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/api/htmx-partials/github-stars.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Icon } from "astro-iconify"; 3 | 4 | // https://docs.astro.build/en/basics/astro-pages/#page-partials 5 | const repo = await fetch("https://api.github.com/repos/cameronapak/freedom-stack", { 6 | headers: { 7 | // Cache for 4 hours 8 | "Cache-Control": "public, max-age=14400", 9 | // Accept JSON response 10 | Accept: "application/json" 11 | } 12 | }); 13 | const repoData = await repo.json(); 14 | const stars = repoData.stargazers_count; 15 | 16 | export const partial = true; 17 | --- 18 | 19 | 25 | GitHub 26 |
27 | 28 | {stars} 29 |
30 |
31 | -------------------------------------------------------------------------------- /src/pages/api/htmx-partials/rick-roll.astro: -------------------------------------------------------------------------------- 1 | --- 2 | // https://docs.astro.build/en/basics/astro-pages/#page-partials 3 | export const partial = true; 4 | --- 5 | 6 | 12 | -------------------------------------------------------------------------------- /src/pages/api/htmx-partials/uneed-launch.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Icon } from "astro-iconify"; 3 | 4 | const launchDate = new Date("2024-11-29"); 5 | const today = new Date(); 6 | const isLaunchDay = today.toDateString() === launchDate.toDateString(); 7 | const isBeforeLaunch = today < launchDate; 8 | 9 | const voteUrl = "https://www.uneed.best/tool/freedom-stack---starter-kit"; 10 | --- 11 | 12 | { 13 | isBeforeLaunch && ( 14 |
15 |
16 |
17 | 18 | Uneed Embed Badge 19 | 20 |

Launching on Black Friday, 2024.

21 | 22 | 23 | Get Notified 24 | 25 |
26 |
27 |
28 | ) 29 | } 30 | { 31 | isLaunchDay && ( 32 |
33 |
34 |
35 | 36 | Uneed Embed Badge 37 | 38 |

Vote for Freedom Stack on Uneed (Today-Only)

39 | 40 | 41 | Vote Now 42 | 43 |
44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Posts, db, desc, eq } from "astro:db"; 3 | import Icon from "astro-iconify"; 4 | import Layout from "@/layouts/Layout.astro"; 5 | import Navbar from "@sections/Navbar.astro"; 6 | import Container from "@sections/Container.astro"; 7 | import Footer from "@sections/Footer.astro"; 8 | 9 | if (!Astro.locals.session) { 10 | return Astro.redirect("/sign-in"); 11 | } 12 | 13 | const user = Astro.locals.user; 14 | 15 | if (!user) { 16 | return Astro.redirect("/sign-in"); 17 | } else if (!user.emailVerified && import.meta.env.BETTER_AUTH_EMAIL_VERIFICATION === "true") { 18 | return Astro.redirect("/verify-email"); 19 | } 20 | 21 | const allPosts = await db.select().from(Posts).where(eq(Posts.author, user.email)).orderBy(desc(Posts.pubDate)); 22 | --- 23 | 24 | 25 | 26 | 27 | 28 |
29 |

My Posts

30 | 31 | 32 | Create Post 33 | 34 |
35 | 53 |
54 |