diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..67a6f3a2 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,136 @@ +/** + * This is intended to be a basic starting point for linting in the Indie Stack. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + "prettier", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + }, + rules: { + "react/jsx-no-leaked-render": [ + "warn", + { validStrategies: ["ternary"] }, + ], + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/stylistic", + "plugin:import/recommended", + "plugin:import/typescript", + "prettier", + ], + rules: { + "import/order": [ + "error", + { + alphabetize: { caseInsensitive: true, order: "asc" }, + groups: ["builtin", "external", "internal", "parent", "sibling"], + "newlines-between": "always", + }, + ], + }, + }, + + // Markdown + { + files: ["**/*.md"], + plugins: ["markdown"], + extends: ["plugin:markdown/recommended-legacy", "prettier"], + }, + + // Jest/Vitest + { + files: ["**/*.test.{js,jsx,ts,tsx}"], + plugins: ["jest", "jest-dom", "testing-library"], + extends: [ + "plugin:jest/recommended", + "plugin:jest-dom/recommended", + "plugin:testing-library/react", + "prettier", + ], + env: { + "jest/globals": true, + }, + settings: { + jest: { + // We're using vitest which has a very similar API to jest + // (so the linting plugins work nicely), but it means we + // have to set the jest version explicitly. + version: 28, + }, + }, + }, + + // Cypress + { + files: ["cypress/**/*.ts"], + plugins: ["cypress"], + extends: ["plugin:cypress/recommended", "prettier"], + }, + + // Node + { + files: [".eslintrc.js", "mocks/**/*.js"], + env: { + node: true, + }, + }, + ], +}; diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 8ab077d6..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,21 +0,0 @@ -/** @type {import('@types/eslint').Linter.BaseConfig} */ -module.exports = { - extends: [ - "@remix-run/eslint-config", - "@remix-run/eslint-config/node", - "@remix-run/eslint-config/jest-testing-library", - "prettier", - ], - env: { - "cypress/globals": true, - }, - plugins: ["cypress"], - // we're using vitest which has a very similar API to jest - // (so the linting plugins work nicely), but it means we have to explicitly - // set the jest version. - settings: { - jest: { - version: 28, - }, - }, -}; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c78eb1b6..8f7ec3ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,15 @@ name: 🚀 Deploy + on: push: branches: - main - dev - pull_request: {} + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: actions: write @@ -15,21 +20,18 @@ jobs: name: ⬣ ESLint runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.10.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: 🔬 Lint run: npm run lint @@ -38,21 +40,18 @@ jobs: name: ʦ TypeScript runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.10.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: 🔎 Type check run: npm run typecheck --if-present @@ -61,21 +60,18 @@ jobs: name: ⚡ Vitest runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.10.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: ⚡ Run vitest run: npm run test -- --coverage @@ -84,24 +80,21 @@ jobs: name: ⚫️ Cypress runs-on: ubuntu-latest steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.10.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🏄 Copy test env vars run: cp .env.example .env - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 16 + cache: npm + cache-dependency-path: ./package.json + node-version: 18 - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false + - name: 📥 Install deps + run: npm install - name: 🛠 Setup Database run: npx prisma migrate reset --force @@ -110,105 +103,42 @@ jobs: run: npm run build - name: 🌳 Cypress run - uses: cypress-io/github-action@v4 + uses: cypress-io/github-action@v6 with: start: npm run start:mocks - wait-on: "http://localhost:8811" + wait-on: http://localhost:8811 env: - PORT: "8811" - - build: - name: 🐳 Build - # only build/deploy main branch on pushes - if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} - runs-on: ubuntu-latest - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.10.0 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 - - - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.0 - id: app_name - with: - file: "fly.toml" - field: "app" - - - name: 🐳 Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - # Setup cache - - name: ⚡️ Cache Docker layers - uses: actions/cache@v3 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - - name: 🔑 Fly Registry Auth - uses: docker/login-action@v2 - with: - registry: registry.fly.io - username: x - password: ${{ secrets.FLY_API_TOKEN }} - - - name: 🐳 Docker build - uses: docker/build-push-action@v3 - with: - context: . - push: true - tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} - build-args: | - COMMIT_SHA=${{ github.sha }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new - - # This ugly bit is necessary if you don't want your cache to grow forever - # till it hits GitHub's limit of 5GB. - # Temp fix - # https://github.com/docker/build-push-action/issues/252 - # https://github.com/moby/buildkit/issues/1896 - - name: 🚚 Move cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + PORT: 8811 deploy: name: 🚀 Deploy runs-on: ubuntu-latest - needs: [lint, typecheck, vitest, cypress, build] - # only build/deploy main branch on pushes + needs: [lint, typecheck, vitest, cypress] + # only deploy main/dev branch on pushes if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.10.0 - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.0 + uses: SebRollen/toml-action@v1.2.0 id: app_name with: - file: "fly.toml" - field: "app" + file: fly.toml + field: app + + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@v1 - name: 🚀 Deploy Staging if: ${{ github.ref == 'refs/heads/dev' }} - uses: superfly/flyctl-actions@1.3 - with: - args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + run: flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }}-staging env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - name: 🚀 Deploy Production if: ${{ github.ref == 'refs/heads/main' }} - uses: superfly/flyctl-actions@1.3 - with: - args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + run: flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }} env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/format-repo.yml b/.github/workflows/format-repo.yml new file mode 100644 index 00000000..240b0ecc --- /dev/null +++ b/.github/workflows/format-repo.yml @@ -0,0 +1,46 @@ +name: 👔 Format + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + if: github.repository == 'remix-run/indie-stack' + runs-on: ubuntu-latest + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + cache: npm + cache-dependency-path: ./package.json + node-version: 18 + + - name: 📥 Install deps + run: npm install + + - name: 👔 Format + run: npm run format:repo + + - name: 💪 Commit + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + git add . + if [ -z "$(git status --porcelain)" ]; then + echo "💿 no formatting changed" + exit 0 + fi + git commit -m "chore: format" + git push + echo "💿 pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" diff --git a/.github/workflows/lint-repo.yml b/.github/workflows/lint-repo.yml new file mode 100644 index 00000000..b2d38564 --- /dev/null +++ b/.github/workflows/lint-repo.yml @@ -0,0 +1,33 @@ +name: ⬣ Lint repository + +on: + push: + branches: + - main + - dev + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: ⬣ Lint repo + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + cache: npm + cache-dependency-path: ./package.json + node-version: 18 + + - name: 📥 Install deps + run: npm install + + - name: 🔬 Lint + run: npm run lint diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 00000000..96426f67 --- /dev/null +++ b/.github/workflows/no-response.yml @@ -0,0 +1,34 @@ +name: 🥺 No Response + +on: + schedule: + # Schedule for five minutes after the hour, every hour + - cron: "5 * * * *" + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + if: github.repository == 'remix-run/indie-stack' + runs-on: ubuntu-latest + steps: + - name: 🥺 Handle Ghosting + uses: actions/stale@v9 + with: + days-before-close: 10 + close-issue-message: > + This issue has been automatically closed because we haven't received a + response from the original author 🙈. This automation helps keep the issue + tracker clean from issues that are unactionable. Please reach out if you + have more information for us! 🙂 + close-pr-message: > + This PR has been automatically closed because we haven't received a + response from the original author 🙈. This automation helps keep the issue + tracker clean from PRs that are unactionable. Please reach out if you + have more information for us! 🙂 + # don't automatically mark issues/PRs as stale + days-before-stale: -1 + stale-issue-label: needs-response + stale-pr-label: needs-response diff --git a/.gitignore b/.gitignore index ca3adffb..d5f63bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ # We don't want lockfiles in stacks, as people could use a different package manager # This part will be removed by `remix.init` +bun.lockb package-lock.json -yarn.lock pnpm-lock.yaml pnpm-lock.yml +yarn.lock node_modules @@ -15,5 +16,3 @@ node_modules /cypress/videos /prisma/data.db /prisma/data.db-journal - -/app/styles/tailwind.css diff --git a/Dockerfile b/Dockerfile index db9ea014..093ace78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # base node image -FROM node:16-bullseye-slim as base +FROM node:18-bullseye-slim as base # set for base and all layer that inherit from it ENV NODE_ENV production @@ -13,7 +13,7 @@ FROM base as deps WORKDIR /myapp ADD package.json .npmrc ./ -RUN npm install --production=false +RUN npm install --include=dev # Setup production node_modules FROM base as production-deps @@ -22,7 +22,7 @@ WORKDIR /myapp COPY --from=deps /myapp/node_modules /myapp/node_modules ADD package.json .npmrc ./ -RUN npm prune --production +RUN npm prune --omit=dev # Build the app FROM base as build diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..8ed8d952 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) Remix Software Inc. 2021 +Copyright (c) Shopify Inc. 2022-2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b4196bd9..8687ded0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ +> [!NOTE] +> This repo has been archived. Please refer instead to: +> - The official [React Router templates](https://github.com/remix-run/react-router-templates/) for simple templates to get started with +> - [The Epic Stack](https://github.com/epicweb-dev/epic-stack) for a more comprehensive, batteries-included option +> - [Remix Discord](https://rmx.as/discord) to ask and share community templates + # Remix Indie Stack ![The Remix Indie Stack](https://repository-images.githubusercontent.com/465928257/a241fa49-bd4d-485a-a2a5-5cb8e4ee0abf) Learn more about [Remix Stacks](https://remix.run/stacks). -``` -npx create-remix --template remix-run/indie-stack +```sh +npx create-remix@latest --template remix-run/indie-stack ``` ## What's in the stack @@ -14,7 +20,7 @@ npx create-remix --template remix-run/indie-stack - Production-ready [SQLite Database](https://sqlite.org) - Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks) - [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments -- Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) +- Email/Password Authentication with [cookie-based sessions](https://remix.run/utils/sessions#md-createcookiesessionstorage) - Database ORM with [Prisma](https://prisma.io) - Styling with [Tailwind](https://tailwindcss.com/) - End-to-end testing with [Cypress](https://cypress.io) @@ -30,17 +36,20 @@ Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix -- Click this button to create a [Gitpod](https://gitpod.io) workspace with the project set up and Fly pre-installed -[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/from-referrer/) +[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/remix-run/indie-stack/tree/main) ## Development -- This step only applies if you've opted out of having the CLI install dependencies for you: - +- First run this stack's `remix.init` script and commit the changes it makes to your project. + ```sh npx remix init + git init # if you haven't already + git add . + git commit -m "Initialize project" ``` -- Initial setup: _If you just generated this project, this step has been done for you._ +- Initial setup: ```sh npm run setup @@ -89,6 +98,7 @@ Prior to your first deployment, you'll need to do a few things: fly apps create indie-stack-template fly apps create indie-stack-template-staging ``` + > **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy. - Initialize Git. @@ -112,7 +122,7 @@ Prior to your first deployment, you'll need to do a few things: fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app indie-stack-template-staging ``` - If you don't have openssl installed, you can also use [1password](https://1password.com/password-generator/) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. + If you don't have openssl installed, you can also use [1Password](https://1password.com/password-generator) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. - Create a persistent volume for the sqlite database for both your staging and production environments. Run the following: @@ -172,7 +182,7 @@ This project uses TypeScript. It's recommended to get TypeScript set up for your ### Linting -This project uses ESLint for linting. That is configured in `.eslintrc.js`. +This project uses ESLint for linting. That is configured in `.eslintrc.cjs`. ### Formatting diff --git a/app/db.server.ts b/app/db.server.ts index 843cc97e..ac5701fa 100644 --- a/app/db.server.ts +++ b/app/db.server.ts @@ -1,23 +1,9 @@ import { PrismaClient } from "@prisma/client"; -let prisma: PrismaClient; +import { singleton } from "./singleton.server"; -declare global { - var __db__: PrismaClient; -} - -// this is needed because in development we don't want to restart -// the server with every change, but we want to make sure we don't -// create a new connection to the DB with every change either. -// in production we'll have a single connection to the DB. -if (process.env.NODE_ENV === "production") { - prisma = new PrismaClient(); -} else { - if (!global.__db__) { - global.__db__ = new PrismaClient(); - } - prisma = global.__db__; - prisma.$connect(); -} +// Hard-code a unique key, so we can look up the client when this module gets re-imported +const prisma = singleton("prisma", () => new PrismaClient()); +prisma.$connect(); export { prisma }; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 1d4ba68d..186cd934 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,22 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client + */ + import { RemixBrowser } from "@remix-run/react"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; -const hydrate = () => { - startTransition(() => { - hydrateRoot( - document, - - - - ); - }); -}; - -if (window.requestIdleCallback) { - window.requestIdleCallback(hydrate); -} else { - // Safari doesn't support requestIdleCallback - // https://caniuse.com/requestidlecallback - window.setTimeout(hydrate, 1); -} +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 7b9fd913..78d4f1e2 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,51 +1,118 @@ -import { PassThrough } from "stream"; +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server + */ + +import { PassThrough } from "node:stream"; + import type { EntryContext } from "@remix-run/node"; -import { Response } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; -import isbot from "isbot"; +import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; -const ABORT_DELAY = 5000; +const ABORT_DELAY = 5_000; export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, - remixContext: EntryContext + remixContext: EntryContext, ) { - const callbackName = isbot(request.headers.get("user-agent")) - ? "onAllReady" - : "onShellReady"; + return isbot(request.headers.get("user-agent")) + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext, + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext, + ); +} +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { return new Promise((resolve, reject) => { - let didError = false; - - const { pipe, abort } = renderToPipeableStream( - , + const { abort, pipe } = renderToPipeableStream( + , { - [callbackName]: () => { + onAllReady() { const body = new PassThrough(); responseHeaders.set("Content-Type", "text/html"); resolve( - new Response(body, { + new Response(createReadableStreamFromReadable(body), { headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }) + status: responseStatusCode, + }), ); pipe(body); }, - onShellError: (err: unknown) => { - reject(err); + onShellError(error: unknown) { + reject(error); }, - onError: (error: unknown) => { - didError = true; + onError(error: unknown) { + responseStatusCode = 500; + console.error(error); + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + const { abort, pipe } = renderToPipeableStream( + , + { + onShellReady() { + const body = new PassThrough(); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(createReadableStreamFromReadable(body), { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { console.error(error); + responseStatusCode = 500; }, - } + }, ); setTimeout(abort, ABORT_DELAY); diff --git a/app/models/note.server.ts b/app/models/note.server.ts index c266dfc0..f385491a 100644 --- a/app/models/note.server.ts +++ b/app/models/note.server.ts @@ -2,8 +2,6 @@ import type { User, Note } from "@prisma/client"; import { prisma } from "~/db.server"; -export type { Note } from "@prisma/client"; - export function getNote({ id, userId, diff --git a/app/models/user.server.ts b/app/models/user.server.ts index cce401e2..42be9b7f 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -34,7 +34,7 @@ export async function deleteUserByEmail(email: User["email"]) { export async function verifyLogin( email: User["email"], - password: Password["hash"] + password: Password["hash"], ) { const userWithPassword = await prisma.user.findUnique({ where: { email }, @@ -49,13 +49,14 @@ export async function verifyLogin( const isValid = await bcrypt.compare( password, - userWithPassword.password.hash + userWithPassword.password.hash, ); if (!isValid) { return null; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _password, ...userWithoutPassword } = userWithPassword; return userWithoutPassword; diff --git a/app/root.tsx b/app/root.tsx index ff782f4b..426fac35 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,5 @@ -import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Links, @@ -9,29 +10,24 @@ import { ScrollRestoration, } from "@remix-run/react"; -import tailwindStylesheetUrl from "./styles/tailwind.css"; -import { getUser } from "./session.server"; +import { getUser } from "~/session.server"; +import stylesheet from "~/tailwind.css"; -export const links: LinksFunction = () => { - return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; -}; - -export const meta: MetaFunction = () => ({ - charset: "utf-8", - title: "Remix Notes", - viewport: "width=device-width,initial-scale=1", -}); +export const links: LinksFunction = () => [ + { rel: "stylesheet", href: stylesheet }, + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; -export async function loader({ request }: LoaderArgs) { - return json({ - user: await getUser(request), - }); -} +export const loader = async ({ request }: LoaderFunctionArgs) => { + return json({ user: await getUser(request) }); +}; export default function App() { return ( + + diff --git a/app/routes/index.tsx b/app/routes/_index.tsx similarity index 95% rename from app/routes/index.tsx rename to app/routes/_index.tsx index e47a988a..d266ff72 100644 --- a/app/routes/index.tsx +++ b/app/routes/_index.tsx @@ -1,7 +1,10 @@ +import type { MetaFunction } from "@remix-run/node"; import { Link } from "@remix-run/react"; import { useOptionalUser } from "~/utils"; +export const meta: MetaFunction = () => [{ title: "Remix Notes" }]; + export default function Index() { const user = useOptionalUser(); return ( @@ -17,7 +20,7 @@ export default function Index() { />
-
+

Indie Stack @@ -63,7 +66,7 @@ export default function Index() {

-
+
{[ { diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx index a2743adc..53168b88 100644 --- a/app/routes/healthcheck.tsx +++ b/app/routes/healthcheck.tsx @@ -1,9 +1,9 @@ // learn more: https://fly.io/docs/reference/configuration/#services-http_checks -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { prisma } from "~/db.server"; -export async function loader({ request }: LoaderArgs) { +export const loader = async ({ request }: LoaderFunctionArgs) => { const host = request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); @@ -22,4 +22,4 @@ export async function loader({ request }: LoaderArgs) { console.log("healthcheck ❌", { error }); return new Response("ERROR", { status: 500 }); } -} +}; diff --git a/app/routes/join.tsx b/app/routes/join.tsx index 91f704fc..f1ea5660 100644 --- a/app/routes/join.tsx +++ b/app/routes/join.tsx @@ -1,20 +1,23 @@ -import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; +import type { + ActionFunctionArgs, + LoaderFunctionArgs, + MetaFunction, +} from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; -import * as React from "react"; - -import { getUserId, createUserSession } from "~/session.server"; +import { useEffect, useRef } from "react"; import { createUser, getUserByEmail } from "~/models/user.server"; +import { createUserSession, getUserId } from "~/session.server"; import { safeRedirect, validateEmail } from "~/utils"; -export async function loader({ request }: LoaderArgs) { +export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await getUserId(request); if (userId) return redirect("/"); return json({}); -} +}; -export async function action({ request }: ActionArgs) { +export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const email = formData.get("email"); const password = formData.get("password"); @@ -23,21 +26,21 @@ export async function action({ request }: ActionArgs) { if (!validateEmail(email)) { return json( { errors: { email: "Email is invalid", password: null } }, - { status: 400 } + { status: 400 }, ); } if (typeof password !== "string" || password.length === 0) { return json( { errors: { email: null, password: "Password is required" } }, - { status: 400 } + { status: 400 }, ); } if (password.length < 8) { return json( { errors: { email: null, password: "Password is too short" } }, - { status: 400 } + { status: 400 }, ); } @@ -50,34 +53,30 @@ export async function action({ request }: ActionArgs) { password: null, }, }, - { status: 400 } + { status: 400 }, ); } const user = await createUser(email, password); return createUserSession({ + redirectTo, + remember: false, request, userId: user.id, - remember: false, - redirectTo, }); -} - -export const meta: MetaFunction = () => { - return { - title: "Sign Up", - }; }; +export const meta: MetaFunction = () => [{ title: "Sign Up" }]; + export default function Join() { const [searchParams] = useSearchParams(); const redirectTo = searchParams.get("redirectTo") ?? undefined; const actionData = useActionData(); - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); + const emailRef = useRef(null); + const passwordRef = useRef(null); - React.useEffect(() => { + useEffect(() => { if (actionData?.errors?.email) { emailRef.current?.focus(); } else if (actionData?.errors?.password) { @@ -101,6 +100,7 @@ export default function Join() { ref={emailRef} id="email" required + // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={true} name="email" type="email" @@ -109,11 +109,11 @@ export default function Join() { aria-describedby="email-error" className="w-full rounded border border-gray-500 px-2 py-1 text-lg" /> - {actionData?.errors?.email && ( + {actionData?.errors?.email ? (
{actionData.errors.email}
- )} + ) : null}
@@ -135,18 +135,18 @@ export default function Join() { aria-describedby="password-error" className="w-full rounded border border-gray-500 px-2 py-1 text-lg" /> - {actionData?.errors?.password && ( + {actionData?.errors?.password ? (
{actionData.errors.password}
- )} + ) : null}
diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 4c4398b5..c61981c8 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,43 +1,47 @@ -import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; +import type { + ActionFunctionArgs, + LoaderFunctionArgs, + MetaFunction, +} from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; -import * as React from "react"; +import { useEffect, useRef } from "react"; -import { createUserSession, getUserId } from "~/session.server"; import { verifyLogin } from "~/models/user.server"; +import { createUserSession, getUserId } from "~/session.server"; import { safeRedirect, validateEmail } from "~/utils"; -export async function loader({ request }: LoaderArgs) { +export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await getUserId(request); if (userId) return redirect("/"); return json({}); -} +}; -export async function action({ request }: ActionArgs) { +export const action = async ({ request }: ActionFunctionArgs) => { const formData = await request.formData(); const email = formData.get("email"); const password = formData.get("password"); - const redirectTo = safeRedirect(formData.get("redirectTo"), "/notes"); + const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); const remember = formData.get("remember"); if (!validateEmail(email)) { return json( { errors: { email: "Email is invalid", password: null } }, - { status: 400 } + { status: 400 }, ); } if (typeof password !== "string" || password.length === 0) { return json( { errors: { email: null, password: "Password is required" } }, - { status: 400 } + { status: 400 }, ); } if (password.length < 8) { return json( { errors: { email: null, password: "Password is too short" } }, - { status: 400 } + { status: 400 }, ); } @@ -46,32 +50,28 @@ export async function action({ request }: ActionArgs) { if (!user) { return json( { errors: { email: "Invalid email or password", password: null } }, - { status: 400 } + { status: 400 }, ); } return createUserSession({ + redirectTo, + remember: remember === "on" ? true : false, request, userId: user.id, - remember: remember === "on" ? true : false, - redirectTo, }); -} - -export const meta: MetaFunction = () => { - return { - title: "Login", - }; }; +export const meta: MetaFunction = () => [{ title: "Login" }]; + export default function LoginPage() { const [searchParams] = useSearchParams(); const redirectTo = searchParams.get("redirectTo") || "/notes"; const actionData = useActionData(); - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); + const emailRef = useRef(null); + const passwordRef = useRef(null); - React.useEffect(() => { + useEffect(() => { if (actionData?.errors?.email) { emailRef.current?.focus(); } else if (actionData?.errors?.password) { @@ -95,6 +95,7 @@ export default function LoginPage() { ref={emailRef} id="email" required + // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={true} name="email" type="email" @@ -103,11 +104,11 @@ export default function LoginPage() { aria-describedby="email-error" className="w-full rounded border border-gray-500 px-2 py-1 text-lg" /> - {actionData?.errors?.email && ( + {actionData?.errors?.email ? (
{actionData.errors.email}
- )} + ) : null} @@ -129,18 +130,18 @@ export default function LoginPage() { aria-describedby="password-error" className="w-full rounded border border-gray-500 px-2 py-1 text-lg" /> - {actionData?.errors?.password && ( + {actionData?.errors?.password ? (
{actionData.errors.password}
- )} + ) : null} @@ -160,7 +161,7 @@ export default function LoginPage() {
- Don't have an account?{" "} + Don't have an account?{" "} + logout(request); -export async function loader() { - return redirect("/"); -} +export const loader = async () => redirect("/"); diff --git a/app/routes/notes/$noteId.tsx b/app/routes/notes.$noteId.tsx similarity index 51% rename from app/routes/notes/$noteId.tsx rename to app/routes/notes.$noteId.tsx index 5b58da67..3edd6ff2 100644 --- a/app/routes/notes/$noteId.tsx +++ b/app/routes/notes.$noteId.tsx @@ -1,30 +1,35 @@ -import type { ActionArgs, LoaderArgs } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; -import { Form, useCatch, useLoaderData } from "@remix-run/react"; +import { + Form, + isRouteErrorResponse, + useLoaderData, + useRouteError, +} from "@remix-run/react"; import invariant from "tiny-invariant"; import { deleteNote, getNote } from "~/models/note.server"; import { requireUserId } from "~/session.server"; -export async function loader({ request, params }: LoaderArgs) { +export const loader = async ({ params, request }: LoaderFunctionArgs) => { const userId = await requireUserId(request); invariant(params.noteId, "noteId not found"); - const note = await getNote({ userId, id: params.noteId }); + const note = await getNote({ id: params.noteId, userId }); if (!note) { throw new Response("Not Found", { status: 404 }); } return json({ note }); -} +}; -export async function action({ request, params }: ActionArgs) { +export const action = async ({ params, request }: ActionFunctionArgs) => { const userId = await requireUserId(request); invariant(params.noteId, "noteId not found"); - await deleteNote({ userId, id: params.noteId }); + await deleteNote({ id: params.noteId, userId }); return redirect("/notes"); -} +}; export default function NoteDetailsPage() { const data = useLoaderData(); @@ -37,7 +42,7 @@ export default function NoteDetailsPage() {
@@ -46,18 +51,20 @@ export default function NoteDetailsPage() { ); } -export function ErrorBoundary({ error }: { error: Error }) { - console.error(error); +export function ErrorBoundary() { + const error = useRouteError(); - return
An unexpected error occurred: {error.message}
; -} + if (error instanceof Error) { + return
An unexpected error occurred: {error.message}
; + } -export function CatchBoundary() { - const caught = useCatch(); + if (!isRouteErrorResponse(error)) { + return

Unknown Error

; + } - if (caught.status === 404) { + if (error.status === 404) { return
Note not found
; } - throw new Error(`Unexpected caught response with status: ${caught.status}`); + return
An unexpected error occurred: {error.statusText}
; } diff --git a/app/routes/notes/index.tsx b/app/routes/notes._index.tsx similarity index 100% rename from app/routes/notes/index.tsx rename to app/routes/notes._index.tsx diff --git a/app/routes/notes/new.tsx b/app/routes/notes.new.tsx similarity index 76% rename from app/routes/notes/new.tsx rename to app/routes/notes.new.tsx index 2428f19d..48dd52de 100644 --- a/app/routes/notes/new.tsx +++ b/app/routes/notes.new.tsx @@ -1,12 +1,12 @@ -import type { ActionArgs } from "@remix-run/node"; +import type { ActionFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; -import * as React from "react"; +import { useEffect, useRef } from "react"; import { createNote } from "~/models/note.server"; import { requireUserId } from "~/session.server"; -export async function action({ request }: ActionArgs) { +export const action = async ({ request }: ActionFunctionArgs) => { const userId = await requireUserId(request); const formData = await request.formData(); @@ -15,29 +15,29 @@ export async function action({ request }: ActionArgs) { if (typeof title !== "string" || title.length === 0) { return json( - { errors: { title: "Title is required", body: null } }, - { status: 400 } + { errors: { body: null, title: "Title is required" } }, + { status: 400 }, ); } if (typeof body !== "string" || body.length === 0) { return json( - { errors: { title: null, body: "Body is required" } }, - { status: 400 } + { errors: { body: "Body is required", title: null } }, + { status: 400 }, ); } - const note = await createNote({ title, body, userId }); + const note = await createNote({ body, title, userId }); return redirect(`/notes/${note.id}`); -} +}; export default function NewNotePage() { const actionData = useActionData(); - const titleRef = React.useRef(null); - const bodyRef = React.useRef(null); + const titleRef = useRef(null); + const bodyRef = useRef(null); - React.useEffect(() => { + useEffect(() => { if (actionData?.errors?.title) { titleRef.current?.focus(); } else if (actionData?.errors?.body) { @@ -68,11 +68,11 @@ export default function NewNotePage() { } /> - {actionData?.errors?.title && ( + {actionData?.errors?.title ? (
{actionData.errors.title}
- )} + ) : null}
@@ -82,24 +82,24 @@ export default function NewNotePage() { ref={bodyRef} name="body" rows={8} - className="w-full flex-1 rounded-md border-2 border-blue-500 py-2 px-3 text-lg leading-6" + className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6" aria-invalid={actionData?.errors?.body ? true : undefined} aria-errormessage={ actionData?.errors?.body ? "body-error" : undefined } /> - {actionData?.errors?.body && ( + {actionData?.errors?.body ? (
{actionData.errors.body}
- )} + ) : null}
diff --git a/app/routes/notes.tsx b/app/routes/notes.tsx index 29e13db2..ab9d6a47 100644 --- a/app/routes/notes.tsx +++ b/app/routes/notes.tsx @@ -1,16 +1,16 @@ -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; +import { getNoteListItems } from "~/models/note.server"; import { requireUserId } from "~/session.server"; import { useUser } from "~/utils"; -import { getNoteListItems } from "~/models/note.server"; -export async function loader({ request }: LoaderArgs) { +export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const noteListItems = await getNoteListItems({ userId }); return json({ noteListItems }); -} +}; export default function NotesPage() { const data = useLoaderData(); @@ -26,7 +26,7 @@ export default function NotesPage() { diff --git a/app/session.server.ts b/app/session.server.ts index 31a861e4..10cebe5a 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -25,7 +25,7 @@ export async function getSession(request: Request) { } export async function getUserId( - request: Request + request: Request, ): Promise { const session = await getSession(request); const userId = session.get(USER_SESSION_KEY); @@ -44,7 +44,7 @@ export async function getUser(request: Request) { export async function requireUserId( request: Request, - redirectTo: string = new URL(request.url).pathname + redirectTo: string = new URL(request.url).pathname, ) { const userId = await getUserId(request); if (!userId) { diff --git a/app/singleton.server.ts b/app/singleton.server.ts new file mode 100644 index 00000000..3e179aa5 --- /dev/null +++ b/app/singleton.server.ts @@ -0,0 +1,13 @@ +// Since the dev server re-requires the bundle, do some shenanigans to make +// certain things persist across that 😆 +// Borrowed/modified from https://github.com/jenseng/abuse-the-platform/blob/2993a7e846c95ace693ce61626fa072174c8d9c7/app/utils/singleton.ts + +export const singleton = ( + name: string, + valueFactory: () => Value, +): Value => { + const g = global as unknown as { __singletons: Record }; + g.__singletons ??= {}; + g.__singletons[name] ??= valueFactory(); + return g.__singletons[name] as Value; +}; diff --git a/app/tailwind.css b/app/tailwind.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/app/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/app/utils.ts b/app/utils.ts index a7eb66eb..f6da6bd0 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -14,7 +14,7 @@ const DEFAULT_REDIRECT = "/"; */ export function safeRedirect( to: FormDataEntryValue | string | null | undefined, - defaultRedirect: string = DEFAULT_REDIRECT + defaultRedirect: string = DEFAULT_REDIRECT, ) { if (!to || typeof to !== "string") { return defaultRedirect; @@ -34,18 +34,23 @@ export function safeRedirect( * @returns {JSON|undefined} The router data or undefined if not found */ export function useMatchesData( - id: string + id: string, ): Record | undefined { const matchingRoutes = useMatches(); const route = useMemo( () => matchingRoutes.find((route) => route.id === id), - [matchingRoutes, id] + [matchingRoutes, id], ); - return route?.data; + return route?.data as Record; } -function isUser(user: any): user is User { - return user && typeof user === "object" && typeof user.email === "string"; +function isUser(user: unknown): user is User { + return ( + user != null && + typeof user === "object" && + "email" in user && + typeof user.email === "string" + ); } export function useOptionalUser(): User | undefined { @@ -60,7 +65,7 @@ export function useUser(): User { const maybeUser = useOptionalUser(); if (!maybeUser) { throw new Error( - "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." + "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.", ); } return maybeUser; diff --git a/cypress.config.ts b/cypress.config.ts index eaaf3ee1..5c07d6ad 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ const port = process.env.PORT ?? (isDev ? "3000" : "8811"); const configOverrides: Partial = { baseUrl: `http://localhost:${port}`, - video: !process.env.CI, screenshotOnRunFailure: !process.env.CI, }; diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.cjs similarity index 100% rename from cypress/.eslintrc.js rename to cypress/.eslintrc.cjs diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 88e7febd..cf27053b 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,6 +1,7 @@ import { faker } from "@faker-js/faker"; declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { interface Chainable { /** @@ -43,19 +44,19 @@ declare global { } function login({ - email = faker.internet.email(undefined, undefined, "example.com"), + email = faker.internet.email({ provider: "example.com" }), }: { email?: string; } = {}) { cy.then(() => ({ email })).as("user"); - cy.exec( - `npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts "${email}"` - ).then(({ stdout }) => { - const cookieValue = stdout - .replace(/.*(?.*)<\/cookie>.*/s, "$") - .trim(); - cy.setCookie("__session", cookieValue); - }); + cy.exec(`npx tsx ./cypress/support/create-user.ts "${email}"`).then( + ({ stdout }) => { + const cookieValue = stdout + .replace(/.*(?.*)<\/cookie>.*/s, "$") + .trim(); + cy.setCookie("__session", cookieValue); + }, + ); return cy.get("@user"); } @@ -74,9 +75,7 @@ function cleanupUser({ email }: { email?: string } = {}) { } function deleteUserByEmail(email: string) { - cy.exec( - `npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts "${email}"` - ); + cy.exec(`npx tsx ./cypress/support/delete-user.ts "${email}"`); cy.clearCookie("__session"); } @@ -85,11 +84,13 @@ function deleteUserByEmail(email: string) { // Also added custom types to avoid getting detached // https://github.com/cypress-io/cypress/issues/7306#issuecomment-1152752612 // =========================================================== -function visitAndCheck(url: string, waitTime: number = 1000) { +function visitAndCheck(url: string, waitTime = 1000) { cy.visit(url); cy.location("pathname").should("contain", url).wait(waitTime); } -Cypress.Commands.add("login", login); -Cypress.Commands.add("cleanupUser", cleanupUser); -Cypress.Commands.add("visitAndCheck", visitAndCheck); +export const registerCommands = () => { + Cypress.Commands.add("login", login); + Cypress.Commands.add("cleanupUser", cleanupUser); + Cypress.Commands.add("visitAndCheck", visitAndCheck); +}; diff --git a/cypress/support/create-user.ts b/cypress/support/create-user.ts index d3fb0c5b..50dd6872 100644 --- a/cypress/support/create-user.ts +++ b/cypress/support/create-user.ts @@ -1,6 +1,6 @@ // Use this to create a new user and login with that user // Simply call this with: -// npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts username@example.com +// npx tsx ./cypress/support/create-user.ts username@example.com, // and it will log out the cookie value you can use to interact with the server // as that new user. @@ -41,7 +41,7 @@ async function createAndLogin(email: string) { ${parsedCookie.__session} - `.trim() + `.trim(), ); } diff --git a/cypress/support/delete-user.ts b/cypress/support/delete-user.ts index 2b45754c..da6d48ae 100644 --- a/cypress/support/delete-user.ts +++ b/cypress/support/delete-user.ts @@ -1,9 +1,9 @@ // Use this to delete a user by their email // Simply call this with: -// npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts username@example.com +// npx tsx ./cypress/support/delete-user.ts username@example.com, // and that user will get deleted -import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { installGlobals } from "@remix-run/node"; import { prisma } from "~/db.server"; diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index baa9411a..e2c28ef0 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,5 +1,7 @@ import "@testing-library/cypress/add-commands"; -import "./commands"; +import { registerCommands } from "./commands"; + +registerCommands(); Cypress.on("uncaught:exception", (err) => { // Cypress and React Hydrating the document don't get along diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index f90cf4e2..732bfd9a 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -15,12 +15,11 @@ "types": ["node", "cypress", "@testing-library/cypress"], "esModuleInterop": true, "jsx": "react-jsx", - "moduleResolution": "node", - "target": "es2019", + "moduleResolution": "Bundler", + "target": "ES2020", "strict": true, "skipLibCheck": true, "resolveJsonModule": true, - "typeRoots": ["../types", "../node_modules/@types"], "paths": { "~/*": ["../app/*"] diff --git a/fly.toml b/fly.toml index 61ed0817..8a768eb7 100644 --- a/fly.toml +++ b/fly.toml @@ -3,6 +3,7 @@ app = "indie-stack-template" kill_signal = "SIGINT" kill_timeout = 5 processes = [] +swap_size_mb = 512 [experimental] allowed_public_ports = [] diff --git a/mocks/README.md b/mocks/README.md index c219a411..a1bd34d8 100644 --- a/mocks/README.md +++ b/mocks/README.md @@ -4,4 +4,4 @@ Use this to mock any third party HTTP resources that you don't have running loca Learn more about how to use this at [mswjs.io](https://mswjs.io/) -For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/start.ts) +For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/index.ts) diff --git a/mocks/index.js b/mocks/index.js index 2e399910..2cbbbe76 100644 --- a/mocks/index.js +++ b/mocks/index.js @@ -1,6 +1,12 @@ -const { setupServer } = require("msw/node"); +import { http, passthrough } from "msw"; +import { setupServer } from "msw/node"; -const server = setupServer(); +// put one-off handlers that don't really need an entire file to themselves here +const miscHandlers = [ + http.post(`${process.env.REMIX_DEV_HTTP_ORIGIN}/ping`, () => passthrough()), +]; + +const server = setupServer(...miscHandlers); server.listen({ onUnhandledRequest: "bypass" }); console.info("🔶 Mock server running"); diff --git a/package.json b/package.json index 16522288..e69af296 100644 --- a/package.json +++ b/package.json @@ -2,89 +2,90 @@ "name": "indie-stack-template", "private": true, "sideEffects": false, + "type": "module", "scripts": { - "build": "run-s build:*", - "build:css": "npm run generate:css -- --minify", - "build:remix": "remix build", - "dev": "run-p dev:*", - "dev:css": "npm run generate:css -- --watch", - "dev:remix": "cross-env NODE_ENV=development binode --require ./mocks -- @remix-run/dev:remix dev", + "build": "remix build", + "dev": "remix dev -c \"npm run dev:serve\"", + "dev:serve": "NODE_OPTIONS=\"--import ./mocks/index.js\" remix-serve ./build/index.js", "format": "prettier --write .", - "generate:css": "tailwindcss -o ./app/styles/tailwind.css", + "format:repo": "npm run format && npm run lint -- --fix", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "setup": "prisma generate && prisma migrate deploy && prisma db seed", - "start": "remix-serve build", - "start:mocks": "binode --require ./mocks -- @remix-run/serve:remix-serve build", + "start": "remix-serve ./build/index.js", + "start:mocks": "NODE_OPTIONS=\"--import ./mocks/index.js\" remix-serve ./build/index.js", "test": "vitest", "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"", "pretest:e2e:run": "npm run build", "test:e2e:run": "cross-env PORT=8811 start-server-and-test start:mocks http://localhost:8811 \"npx cypress run\"", - "typecheck": "tsc -b && tsc -b cypress", - "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run" + "typecheck": "tsc && tsc -p cypress", + "validate": "npm-run-all --parallel \"test -- --run\" lint typecheck test:e2e:run" }, - "prettier": {}, "eslintIgnore": [ "/node_modules", "/build", "/public/build" ], "dependencies": { - "@prisma/client": "^4.2.1", + "@prisma/client": "^5.22.0", + "@remix-run/css-bundle": "*", "@remix-run/node": "*", "@remix-run/react": "*", "@remix-run/serve": "*", - "@remix-run/server-runtime": "*", "bcryptjs": "^2.4.3", - "isbot": "^3.5.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "tiny-invariant": "^1.2.0" + "isbot": "^5.1.17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tiny-invariant": "^1.3.3" }, "devDependencies": { - "@faker-js/faker": "^7.4.0", + "@faker-js/faker": "^9.2.0", "@remix-run/dev": "*", - "@remix-run/eslint-config": "*", - "@testing-library/cypress": "^8.0.3", - "@testing-library/dom": "^8.17.1", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.3.0", - "@testing-library/user-event": "^14.4.3", - "@types/bcryptjs": "^2.4.2", - "@types/eslint": "^8.4.6", - "@types/node": "^18.7.13", - "@types/react": "^18.0.17", - "@types/react-dom": "^18.0.6", - "@vitejs/plugin-react": "^2.0.1", - "@vitest/coverage-c8": "^0.22.1", - "autoprefixer": "^10.4.8", - "binode": "^1.0.5", - "c8": "^7.12.0", - "cookie": "^0.5.0", + "@testing-library/cypress": "^10.0.2", + "@testing-library/jest-dom": "^6.6.3", + "@types/bcryptjs": "^2.4.6", + "@types/eslint": "^8.56.12", + "@types/node": "^22.9.3", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", + "@vitejs/plugin-react": "^4.3.3", + "@vitest/coverage-v8": "^2.1.5", + "autoprefixer": "^10.4.20", + "cookie": "^1.0.2", "cross-env": "^7.0.3", - "cypress": "^10.6.0", - "eslint": "^8.22.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-cypress": "^2.12.1", - "happy-dom": "^6.0.4", - "msw": "^0.45.0", - "npm-run-all": "^4.1.5", - "postcss": "^8.4.16", - "prettier": "2.7.1", - "prettier-plugin-tailwindcss": "^0.1.13", - "prisma": "^4.2.1", - "start-server-and-test": "^1.14.0", - "tailwindcss": "^3.1.8", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.1.0", - "typescript": "^4.7.4", - "vite": "^3.0.9", - "vite-tsconfig-paths": "^3.5.0", - "vitest": "^0.22.1" + "cypress": "^13.16.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.3", + "eslint-plugin-cypress": "^3.6.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^28.9.0", + "eslint-plugin-jest-dom": "^5.5.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-markdown": "^5.1.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-testing-library": "^7.0.0", + "happy-dom": "^15.11.6", + "msw": "^2.6.6", + "npm-run-all2": "^7.0.1", + "postcss": "^8.4.49", + "prettier": "3.3.3", + "prettier-plugin-tailwindcss": "^0.6.9", + "prisma": "^5.22.0", + "start-server-and-test": "^2.0.8", + "tailwindcss": "^3.4.15", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vite-tsconfig-paths": "^5.1.3", + "vitest": "^2.1.5" }, "engines": { - "node": ">=14" + "node": ">=18.0.0" }, "prisma": { - "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts" + "seed": "tsx prisma/seed.ts" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 00000000..fb6fba22 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +export default { + plugins: ["prettier-plugin-tailwindcss"], +}; diff --git a/remix.config.js b/remix.config.js index 45770994..4047ae45 100644 --- a/remix.config.js +++ b/remix.config.js @@ -1,7 +1,5 @@ -/** - * @type {import('@remix-run/dev').AppConfig} - */ -module.exports = { +/** @type {import('@remix-run/dev').AppConfig} */ +export default { cacheDirectory: "./node_modules/.cache/remix", - ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], + ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], }; diff --git a/remix.env.d.ts b/remix.env.d.ts index 72e2affe..dcf8c45e 100644 --- a/remix.env.d.ts +++ b/remix.env.d.ts @@ -1,2 +1,2 @@ /// -/// +/// diff --git a/remix.init/gitignore b/remix.init/gitignore index 05f0cbc1..38e24676 100644 --- a/remix.init/gitignore +++ b/remix.init/gitignore @@ -8,5 +8,3 @@ node_modules /cypress/videos /prisma/data.db /prisma/data.db-journal - -/app/styles/tailwind.css diff --git a/remix.init/index.js b/remix.init/index.js index c3b18908..132c6a63 100644 --- a/remix.init/index.js +++ b/remix.init/index.js @@ -1,47 +1,22 @@ -const { execSync } = require("child_process"); -const crypto = require("crypto"); -const fs = require("fs/promises"); -const path = require("path"); +const { execSync } = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const path = require("node:path"); const toml = require("@iarna/toml"); const PackageJson = require("@npmcli/package-json"); const semver = require("semver"); -const YAML = require("yaml"); -const cleanupCypressFiles = ({ fileEntries, isTypeScript, packageManager }) => +const cleanupCypressFiles = ({ fileEntries, packageManager }) => fileEntries.flatMap(([filePath, content]) => { - let newContent = content.replace( - new RegExp("npx ts-node", "g"), - isTypeScript ? `${packageManager.exec} ts-node` : "node" + const newContent = content.replace( + new RegExp("npx tsx", "g"), + packageManager.name === "bun" ? "bun" : `${packageManager.exec} tsx`, ); - if (!isTypeScript) { - newContent = newContent - .replace(new RegExp("create-user.ts", "g"), "create-user.js") - .replace(new RegExp("delete-user.ts", "g"), "delete-user.js"); - } - return [fs.writeFile(filePath, newContent)]; }); -const cleanupDeployWorkflow = (deployWorkflow, deployWorkflowPath) => { - delete deployWorkflow.jobs.typecheck; - deployWorkflow.jobs.deploy.needs = deployWorkflow.jobs.deploy.needs.filter( - (need) => need !== "typecheck" - ); - - return [fs.writeFile(deployWorkflowPath, YAML.stringify(deployWorkflow))]; -}; - -const cleanupVitestConfig = (vitestConfig, vitestConfigPath) => { - const newVitestConfig = vitestConfig.replace( - "setup-test-env.ts", - "setup-test-env.js" - ); - - return [fs.writeFile(vitestConfigPath, newVitestConfig)]; -}; - const escapeRegExp = (string) => // $& means the whole matched string string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -49,9 +24,16 @@ const escapeRegExp = (string) => const getPackageManagerCommand = (packageManager) => // Inspired by https://github.com/nrwl/nx/blob/bd9b33eaef0393d01f747ea9a2ac5d2ca1fb87c6/packages/nx/src/utils/package-manager.ts#L38-L103 ({ + bun: () => ({ + exec: "bunx", + lockfile: "bun.lockb", + name: "bun", + run: (script, args) => `bun run ${script} ${args || ""}`, + }), npm: () => ({ exec: "npx", lockfile: "package-lock.json", + name: "npm", run: (script, args) => `npm run ${script} ${args ? `-- ${args}` : ""}`, }), pnpm: () => { @@ -62,6 +44,7 @@ const getPackageManagerCommand = (packageManager) => return { exec: useExec ? "pnpm exec" : "pnpx", lockfile: "pnpm-lock.yaml", + name: "pnpm", run: (script, args) => includeDoubleDashBeforeArgs ? `pnpm run ${script} ${args ? `-- ${args}` : ""}` @@ -71,9 +54,10 @@ const getPackageManagerCommand = (packageManager) => yarn: () => ({ exec: "yarn", lockfile: "yarn.lock", + name: "yarn", run: (script, args) => `yarn ${script} ${args || ""}`, }), - }[packageManager]()); + })[packageManager](); const getPackageManagerVersion = (packageManager) => // Copied over from https://github.com/nrwl/nx/blob/bd9b33eaef0393d01f747ea9a2ac5d2ca1fb87c6/packages/nx/src/utils/package-manager.ts#L105-L114 @@ -81,79 +65,58 @@ const getPackageManagerVersion = (packageManager) => const getRandomString = (length) => crypto.randomBytes(length).toString("hex"); -const readFileIfNotTypeScript = ( - isTypeScript, - filePath, - parseFunction = (result) => result -) => - isTypeScript - ? Promise.resolve() - : fs.readFile(filePath, "utf-8").then(parseFunction); - const removeUnusedDependencies = (dependencies, unusedDependencies) => Object.fromEntries( Object.entries(dependencies).filter( - ([key]) => !unusedDependencies.includes(key) - ) + ([key]) => !unusedDependencies.includes(key), + ), ); -const updatePackageJson = ({ APP_NAME, isTypeScript, packageJson }) => { +const updatePackageJson = ({ APP_NAME, packageJson, packageManager }) => { const { devDependencies, prisma: { seed: prismaSeed, ...prisma }, - scripts: { typecheck, validate, ...scripts }, + scripts: { + // eslint-disable-next-line no-unused-vars + "format:repo": _repoFormatScript, + ...scripts + }, } = packageJson.content; packageJson.update({ name: APP_NAME, - devDependencies: isTypeScript - ? devDependencies - : removeUnusedDependencies(devDependencies, ["ts-node"]), - prisma: isTypeScript - ? { ...prisma, seed: prismaSeed } - : { - ...prisma, - seed: prismaSeed - .replace("ts-node", "node") - .replace("seed.ts", "seed.js"), - }, - scripts: isTypeScript - ? { ...scripts, typecheck, validate } - : { ...scripts, validate: validate.replace(" typecheck", "") }, + devDependencies: + packageManager.name === "bun" + ? removeUnusedDependencies(devDependencies, ["tsx"]) + : devDependencies, + prisma: { + ...prisma, + seed: + packageManager.name === "bun" + ? prismaSeed.replace("tsx", "bun") + : prismaSeed, + }, + scripts, }); }; -const main = async ({ isTypeScript, packageManager, rootDirectory }) => { +const main = async ({ packageManager, rootDirectory }) => { const pm = getPackageManagerCommand(packageManager); - const FILE_EXTENSION = isTypeScript ? "ts" : "js"; const README_PATH = path.join(rootDirectory, "README.md"); const FLY_TOML_PATH = path.join(rootDirectory, "fly.toml"); const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example"); const ENV_PATH = path.join(rootDirectory, ".env"); - const DEPLOY_WORKFLOW_PATH = path.join( - rootDirectory, - ".github", - "workflows", - "deploy.yml" - ); const DOCKERFILE_PATH = path.join(rootDirectory, "Dockerfile"); const CYPRESS_SUPPORT_PATH = path.join(rootDirectory, "cypress", "support"); - const CYPRESS_COMMANDS_PATH = path.join( - CYPRESS_SUPPORT_PATH, - `commands.${FILE_EXTENSION}` - ); + const CYPRESS_COMMANDS_PATH = path.join(CYPRESS_SUPPORT_PATH, "commands.ts"); const CREATE_USER_COMMAND_PATH = path.join( CYPRESS_SUPPORT_PATH, - `create-user.${FILE_EXTENSION}` + "create-user.ts", ); const DELETE_USER_COMMAND_PATH = path.join( CYPRESS_SUPPORT_PATH, - `delete-user.${FILE_EXTENSION}` - ); - const VITEST_CONFIG_PATH = path.join( - rootDirectory, - `vitest.config.${FILE_EXTENSION}` + "delete-user.ts", ); const REPLACER = "indie-stack-template"; @@ -173,8 +136,6 @@ const main = async ({ isTypeScript, packageManager, rootDirectory }) => { cypressCommands, createUserCommand, deleteUserCommand, - deployWorkflow, - vitestConfig, packageJson, ] = await Promise.all([ fs.readFile(FLY_TOML_PATH, "utf-8"), @@ -184,36 +145,42 @@ const main = async ({ isTypeScript, packageManager, rootDirectory }) => { fs.readFile(CYPRESS_COMMANDS_PATH, "utf-8"), fs.readFile(CREATE_USER_COMMAND_PATH, "utf-8"), fs.readFile(DELETE_USER_COMMAND_PATH, "utf-8"), - readFileIfNotTypeScript(isTypeScript, DEPLOY_WORKFLOW_PATH, (s) => - YAML.parse(s) - ), - readFileIfNotTypeScript(isTypeScript, VITEST_CONFIG_PATH), PackageJson.load(rootDirectory), ]); const newEnv = env.replace( /^SESSION_SECRET=.*$/m, - `SESSION_SECRET="${getRandomString(16)}"` + `SESSION_SECRET="${getRandomString(16)}"`, ); const prodToml = toml.parse(prodContent); prodToml.app = prodToml.app.replace(REPLACER, APP_NAME); - const newReadme = readme.replace( - new RegExp(escapeRegExp(REPLACER), "g"), - APP_NAME - ); + const initInstructions = ` +- First run this stack's \`remix.init\` script and commit the changes it makes to your project. + + \`\`\`sh + npx remix init + git init # if you haven't already + git add . + git commit -m "Initialize project" + \`\`\` +`; + + const newReadme = readme + .replace(new RegExp(escapeRegExp(REPLACER), "g"), APP_NAME) + .replace(initInstructions, ""); const newDockerfile = pm.lockfile ? dockerfile.replace( new RegExp(escapeRegExp("ADD package.json"), "g"), - `ADD package.json ${pm.lockfile}` + `ADD package.json ${pm.lockfile}`, ) : dockerfile; - updatePackageJson({ APP_NAME, isTypeScript, packageJson }); + updatePackageJson({ APP_NAME, packageJson, packageManager: pm }); - const fileOperationPromises = [ + await Promise.all([ fs.writeFile(FLY_TOML_PATH, toml.stringify(prodToml)), fs.writeFile(README_PATH, newReadme), fs.writeFile(ENV_PATH, newEnv), @@ -224,36 +191,27 @@ const main = async ({ isTypeScript, packageManager, rootDirectory }) => { [CREATE_USER_COMMAND_PATH, createUserCommand], [DELETE_USER_COMMAND_PATH, deleteUserCommand], ], - isTypeScript, packageManager: pm, }), packageJson.save(), fs.copyFile( path.join(rootDirectory, "remix.init", "gitignore"), - path.join(rootDirectory, ".gitignore") + path.join(rootDirectory, ".gitignore"), ), fs.rm(path.join(rootDirectory, ".github", "ISSUE_TEMPLATE"), { recursive: true, }), + fs.rm(path.join(rootDirectory, ".github", "workflows", "format-repo.yml")), + fs.rm(path.join(rootDirectory, ".github", "workflows", "lint-repo.yml")), + fs.rm(path.join(rootDirectory, ".github", "workflows", "no-response.yml")), fs.rm(path.join(rootDirectory, ".github", "dependabot.yml")), fs.rm(path.join(rootDirectory, ".github", "PULL_REQUEST_TEMPLATE.md")), - ]; - - if (!isTypeScript) { - fileOperationPromises.push( - ...cleanupDeployWorkflow(deployWorkflow, DEPLOY_WORKFLOW_PATH) - ); - - fileOperationPromises.push( - ...cleanupVitestConfig(vitestConfig, VITEST_CONFIG_PATH) - ); - } - - await Promise.all(fileOperationPromises); + fs.rm(path.join(rootDirectory, "LICENSE.md")), + ]); execSync(pm.run("setup"), { cwd: rootDirectory, stdio: "inherit" }); - execSync(pm.run("format", "--loglevel warn"), { + execSync(pm.run("format", "--log-level warn"), { cwd: rootDirectory, stdio: "inherit", }); @@ -262,7 +220,7 @@ const main = async ({ isTypeScript, packageManager, rootDirectory }) => { `Setup is complete. You're now ready to rock and roll 🤘 Start development with \`${pm.run("dev")}\` - `.trim() + `.trim(), ); }; diff --git a/remix.init/package.json b/remix.init/package.json index 93638ce9..d95f5052 100644 --- a/remix.init/package.json +++ b/remix.init/package.json @@ -5,8 +5,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", - "@npmcli/package-json": "^2.0.0", - "semver": "^7.3.7", - "yaml": "^2.1.1" + "@npmcli/package-json": "^5.2.1", + "semver": "^7.6.3" } } diff --git a/start.sh b/start.sh index d81332a4..109a13c3 100755 --- a/start.sh +++ b/start.sh @@ -1,10 +1,9 @@ -#!/bin/sh +#!/bin/sh -ex # This file is how Fly starts the server (configured in fly.toml). Before starting # the server though, we need to run any prisma migrations that haven't yet been # run, which is why this file exists in the first place. # Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386 -set -ex npx prisma migrate deploy npm run start diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 8cff356f..00000000 --- a/tailwind.config.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./app/**/*.{ts,tsx,jsx,js}"], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 00000000..64a5243e --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; diff --git a/test/setup-test-env.ts b/test/setup-test-env.ts index 48fcc431..8f910daf 100644 --- a/test/setup-test-env.ts +++ b/test/setup-test-env.ts @@ -1,4 +1,4 @@ import { installGlobals } from "@remix-run/node"; -import "@testing-library/jest-dom/extend-expect"; +import "@testing-library/jest-dom/vitest"; installGlobals(); diff --git a/tsconfig.json b/tsconfig.json index 9bacef83..7c4c7a7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,15 +2,15 @@ "exclude": ["./cypress", "./cypress.config.ts"], "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2019"], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["vitest/globals"], "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx", - "module": "CommonJS", - "moduleResolution": "node", + "module": "ES2022", + "moduleResolution": "Bundler", "resolveJsonModule": true, - "target": "ES2019", + "target": "ES2022", "strict": true, "allowJs": true, "forceConsistentCasingInFileNames": true, diff --git a/vitest.config.ts b/vitest.config.ts index 7793d54a..898aecae 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,8 @@ /// import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react(), tsconfigPaths()],