diff --git a/demo/the-epic-stack/.cursor/rules/avoid-use-effect.mdc b/demo/the-epic-stack/.cursor/rules/avoid-use-effect.mdc new file mode 100644 index 000000000..64e53b5de --- /dev/null +++ b/demo/the-epic-stack/.cursor/rules/avoid-use-effect.mdc @@ -0,0 +1,81 @@ +--- +description: +globs: *.tsx,*.jsx +alwaysApply: false +--- +### Avoid useEffect + +[You Might Not Need `useEffect`](https://react.dev/learn/you-might-not-need-an-effect) + +Instead of using `useEffect`, use ref callbacks, event handlers with +`flushSync`, css, `useSyncExternalStore`, etc. + +```tsx +// This example was ripped from the docs: +// βœ… Good +function ProductPage({ product, addToCart }) { + function buyProduct() { + addToCart(product) + showNotification(`Added ${product.name} to the shopping cart!`) + } + + function handleBuyClick() { + buyProduct() + } + + function handleCheckoutClick() { + buyProduct() + navigateTo('/checkout') + } + // ... +} + +useEffect(() => { + setCount(count + 1) +}, [count]) + +// ❌ Avoid +function ProductPage({ product, addToCart }) { + useEffect(() => { + if (product.isInCart) { + showNotification(`Added ${product.name} to the shopping cart!`) + } + }, [product]) + + function handleBuyClick() { + addToCart(product) + } + + function handleCheckoutClick() { + addToCart(product) + navigateTo('/checkout') + } + // ... +} +``` + +There are a lot more examples in the docs. `useEffect` is not banned or +anything. There are just better ways to handle most cases. + +Here's an example of a situation where `useEffect` is appropriate: + +```tsx +// βœ… Good +useEffect(() => { + const controller = new AbortController() + + window.addEventListener( + 'keydown', + (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + + // do something based on escape key being pressed + }, + { signal: controller.signal }, + ) + + return () => { + controller.abort() + } +}, []) +``` diff --git a/demo/the-epic-stack/.env.example b/demo/the-epic-stack/.env.example new file mode 100644 index 000000000..c5daeb201 --- /dev/null +++ b/demo/the-epic-stack/.env.example @@ -0,0 +1,29 @@ +LITEFS_DIR="/litefs/data" +DATABASE_PATH="./prisma/data.db" +DATABASE_URL="file:./data.db?connection_limit=1" +CACHE_DATABASE_PATH="./other/cache.db" +SESSION_SECRET="super-duper-s3cret" +HONEYPOT_SECRET="super-duper-s3cret" +RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" +SENTRY_DSN="your-dsn" + +# this is set to a random value in the Dockerfile +INTERNAL_COMMAND_TOKEN="some-made-up-token" + +# the mocks and some code rely on these two being prefixed with "MOCK_" +# if they aren't then the real github api will be attempted +GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" +GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" +GITHUB_TOKEN="MOCK_GITHUB_TOKEN" +GITHUB_REDIRECT_URI="https://example.com/auth/github/callback" + +# set this to false to prevent search engines from indexing the website +# default to allow indexing for seo safety +ALLOW_INDEXING="true" + +# Tigris Object Storage (S3-compatible) Configuration +AWS_ACCESS_KEY_ID="mock-access-key" +AWS_SECRET_ACCESS_KEY="mock-secret-key" +AWS_REGION="auto" +AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev" +BUCKET_NAME="mock-bucket" diff --git a/demo/the-epic-stack/.github/PULL_REQUEST_TEMPLATE.md b/demo/the-epic-stack/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..84a20848c --- /dev/null +++ b/demo/the-epic-stack/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + +## Test Plan + + + +## Checklist + +- [ ] Tests updated +- [ ] Docs updated + +## Screenshots + + diff --git a/demo/the-epic-stack/.github/workflows/deploy.yml b/demo/the-epic-stack/.github/workflows/deploy.yml new file mode 100644 index 000000000..49cbbad44 --- /dev/null +++ b/demo/the-epic-stack/.github/workflows/deploy.yml @@ -0,0 +1,229 @@ +name: πŸš€ Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: write + contents: read + +jobs: + lint: + name: ⬣ ESLint + runs-on: ubuntu-22.04 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ„ Copy test env vars + run: cp .env.example .env + + - name: πŸ›  Setup Database + run: npx prisma migrate deploy && npx prisma generate --sql + + - name: πŸ”¬ Lint + run: npm run lint + + typecheck: + name: Κ¦ TypeScript + runs-on: ubuntu-22.04 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ— Build + run: npm run build + + - name: πŸ„ Copy test env vars + run: cp .env.example .env + + - name: πŸ›  Setup Database + run: npx prisma migrate deploy && npx prisma generate --sql + + - name: πŸ”Ž Type check + run: npm run typecheck --if-present + + vitest: + name: ⚑ Vitest + runs-on: ubuntu-22.04 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ„ Copy test env vars + run: cp .env.example .env + + - name: πŸ›  Setup Database + run: npx prisma migrate deploy && npx prisma generate --sql + + - name: ⚑ Run vitest + run: npm run test -- --coverage + + playwright: + name: 🎭 Playwright + runs-on: ubuntu-22.04 + timeout-minutes: 60 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: πŸ„ Copy test env vars + run: cp .env.example .env + + - name: βŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + + - name: πŸ“₯ Install Playwright Browsers + run: npm run test:e2e:install + + - name: πŸ›  Setup Database + run: npx prisma migrate deploy && npx prisma generate --sql + + - name: 🏦 Cache Database + id: db-cache + uses: actions/cache@v4 + with: + path: prisma/data.db + key: + db-cache-schema_${{ hashFiles('./prisma/schema.prisma') + }}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql') + }} + + - name: 🌱 Seed Database + if: steps.db-cache.outputs.cache-hit != 'true' + run: npx prisma migrate reset --force + + - name: πŸ— Build + run: npm run build + + - name: 🎭 Playwright tests + run: npx playwright test + + - name: πŸ“Š Upload report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + container: + name: πŸ“¦ Prepare Container + runs-on: ubuntu-24.04 + # only prepare container on pushes + if: ${{ github.event_name == 'push' }} + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 50 + + - name: πŸ‘€ Read app name + uses: SebRollen/toml-action@v1.2.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@1.5 + + - name: πŸ“¦ Build Staging Container + if: ${{ github.ref == 'refs/heads/dev' }} + run: | + flyctl deploy \ + --build-only \ + --push \ + --image-label ${{ github.sha }} \ + --build-arg COMMIT_SHA=${{ github.sha }} \ + --app ${{ steps.app_name.outputs.value }}-staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: πŸ“¦ Build Production Container + if: ${{ github.ref == 'refs/heads/main' }} + run: | + flyctl deploy \ + --build-only \ + --push \ + --image-label ${{ github.sha }} \ + --build-arg COMMIT_SHA=${{ github.sha }} \ + --build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \ + --app ${{ steps.app_name.outputs.value }} + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + deploy: + name: πŸš€ Deploy + runs-on: ubuntu-24.04 + needs: [lint, typecheck, vitest, playwright, container] + # only deploy on pushes + if: ${{ github.event_name == 'push' }} + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: '50' + + - name: πŸ‘€ Read app name + uses: SebRollen/toml-action@v1.2.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@1.5 + + - name: πŸš€ Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + run: | + flyctl deploy \ + --image "registry.fly.io/${{ steps.app_name.outputs.value }}-staging:${{ 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' }} + run: | + flyctl deploy \ + --image "registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.sha }}" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/demo/the-epic-stack/.gitignore b/demo/the-epic-stack/.gitignore new file mode 100644 index 000000000..2345034e4 --- /dev/null +++ b/demo/the-epic-stack/.gitignore @@ -0,0 +1,28 @@ +node_modules +.DS_store + +/build +/server-build +.env +.cache + +/prisma/data.db +/prisma/data.db-journal +/tests/prisma + +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/ +/tests/fixtures/uploaded/ +/tests/fixtures/openimg/ +/coverage + +/other/cache.db + +# Easy way to create temporary files/folders that won't accidentally be added to git +*.local.* + +# generated files +/app/components/ui/icons +.react-router/ diff --git a/demo/the-epic-stack/.npmrc b/demo/the-epic-stack/.npmrc new file mode 100644 index 000000000..668efa17f --- /dev/null +++ b/demo/the-epic-stack/.npmrc @@ -0,0 +1,2 @@ +legacy-peer-deps=true +registry=https://registry.npmjs.org/ diff --git a/demo/the-epic-stack/.prettierignore b/demo/the-epic-stack/.prettierignore new file mode 100644 index 000000000..f022d0280 --- /dev/null +++ b/demo/the-epic-stack/.prettierignore @@ -0,0 +1,15 @@ +node_modules + +/build +/public/build +/server-build +.env + +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/*.json +/coverage +/prisma/migrations + +package-lock.json diff --git a/demo/the-epic-stack/.vscode/extensions.json b/demo/the-epic-stack/.vscode/extensions.json new file mode 100644 index 000000000..3c0a690df --- /dev/null +++ b/demo/the-epic-stack/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "prisma.prisma", + "qwtel.sqlite-viewer", + "yoavbls.pretty-ts-errors", + "github.vscode-github-actions" + ] +} diff --git a/demo/the-epic-stack/.vscode/remix.code-snippets b/demo/the-epic-stack/.vscode/remix.code-snippets new file mode 100644 index 000000000..079bee570 --- /dev/null +++ b/demo/the-epic-stack/.vscode/remix.code-snippets @@ -0,0 +1,80 @@ +{ + "loader": { + "prefix": "/loader", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export async function loader({ request }: Route.LoaderArgs) {", + " return {}", + "}", + ], + }, + "action": { + "prefix": "/action", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export async function action({ request }: Route.ActionArgs) {", + " return {}", + "}", + ], + }, + "default": { + "prefix": "/default", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {", + " return (", + "
", + "

Unknown Route

", + "
", + " )", + "}", + ], + }, + "headers": { + "prefix": "/headers", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "export const headers: Route.HeadersFunction = ({ loaderHeaders }) => ({", + " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',", + "})", + ], + }, + "links": { + "prefix": "/links", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export const links: Route.LinksFunction = () => {", + " return []", + "}", + ], + }, + "meta": { + "prefix": "/meta", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", + "", + "export const meta: Route.MetaFunction = ({ data }) => [{", + " title: 'Title',", + "}]", + ], + }, + "shouldRevalidate": { + "prefix": "/shouldRevalidate", + "scope": "typescriptreact,javascriptreact,typescript,javascript", + "body": [ + "import { type ShouldRevalidateFunctionArgs } from 'react-router'", + "", + "export function shouldRevalidate({ defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {", + " return defaultShouldRevalidate", + "}", + ], + }, +} diff --git a/demo/the-epic-stack/.vscode/settings.json b/demo/the-epic-stack/.vscode/settings.json new file mode 100644 index 000000000..9ec5cad64 --- /dev/null +++ b/demo/the-epic-stack/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "typescript.preferences.autoImportFileExcludePatterns": [ + "@remix-run/server-runtime", + "express", + "@radix-ui/**", + "@react-email/**", + "node:stream/consumers", + "node:test", + "node:console" + ], + "workbench.editorAssociations": { + "*.db": "sqlite-viewer.view" + } +} diff --git a/demo/the-epic-stack/README.md b/demo/the-epic-stack/README.md new file mode 100644 index 000000000..873bc50a7 --- /dev/null +++ b/demo/the-epic-stack/README.md @@ -0,0 +1,47 @@ +# Lingo.dev Compiler with The Epic Stack + +## Introduction + +This example demonstrates how to set up [Lingo.dev Compiler](https://lingo.dev/en/compiler/) with [The Epic Stack](https://github.com/epicweb-dev/epic-stack). + +## Running this example + +To run this example: + +1. Set the `LINGODOTDEV_API_KEY` environment variable: + + ```bash + export LINGODOTDEV_API_KEY="" + ``` + + To get an API key, sign up for a free account at [lingo.dev](https://lingo.dev). + +2. Navigate into this example's directory: + + ```bash + cd demo/the-epic-stack + ``` + +3. Install the dependencies: + + ```bash + pnpm install + ``` + +4. Run the development server: + + ```bash + pnpm run dev + ``` + +5. Navigate to . + +## Changed files + +These are the files that were changed to get **Lingo.dev Compiler** up and running: + +- [app/routes/_marketing+/index.tsx](./app/routes/_marketing+/index.tsx) +- [app/root.tsx](./app/root.tsx) +- [vite.config.ts](./vite.config.ts) + +You can use these files as a reference when setting up the compiler in your own project. diff --git a/demo/the-epic-stack/app/assets/favicons/apple-touch-icon.png b/demo/the-epic-stack/app/assets/favicons/apple-touch-icon.png new file mode 100644 index 000000000..8bf4632e7 Binary files /dev/null and b/demo/the-epic-stack/app/assets/favicons/apple-touch-icon.png differ diff --git a/demo/the-epic-stack/app/assets/favicons/favicon.svg b/demo/the-epic-stack/app/assets/favicons/favicon.svg new file mode 100644 index 000000000..72be6f084 --- /dev/null +++ b/demo/the-epic-stack/app/assets/favicons/favicon.svg @@ -0,0 +1,13 @@ + + + + diff --git a/demo/the-epic-stack/app/components/error-boundary.tsx b/demo/the-epic-stack/app/components/error-boundary.tsx new file mode 100644 index 000000000..b881ed921 --- /dev/null +++ b/demo/the-epic-stack/app/components/error-boundary.tsx @@ -0,0 +1,53 @@ +import { captureException } from '@sentry/react-router' +import { useEffect, type ReactElement } from 'react' +import { + type ErrorResponse, + isRouteErrorResponse, + useParams, + useRouteError, +} from 'react-router' +import { getErrorMessage } from '#app/utils/misc' + +type StatusHandler = (info: { + error: ErrorResponse + params: Record +}) => ReactElement | null + +export function GeneralErrorBoundary({ + defaultStatusHandler = ({ error }) => ( +

+ {error.status} {error.data} +

+ ), + statusHandlers, + unexpectedErrorHandler = (error) =>

{getErrorMessage(error)}

, +}: { + defaultStatusHandler?: StatusHandler + statusHandlers?: Record + unexpectedErrorHandler?: (error: unknown) => ReactElement | null +}) { + const error = useRouteError() + const params = useParams() + const isResponse = isRouteErrorResponse(error) + + if (typeof document !== 'undefined') { + console.error(error) + } + + useEffect(() => { + if (isResponse) return + + captureException(error) + }, [error, isResponse]) + + return ( +
+ {isResponse + ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ + error, + params, + }) + : unexpectedErrorHandler(error)} +
+ ) +} diff --git a/demo/the-epic-stack/app/components/floating-toolbar.tsx b/demo/the-epic-stack/app/components/floating-toolbar.tsx new file mode 100644 index 000000000..5e0e82364 --- /dev/null +++ b/demo/the-epic-stack/app/components/floating-toolbar.tsx @@ -0,0 +1,2 @@ +export const floatingToolbarClassName = + 'absolute bottom-3 inset-x-3 flex items-center gap-2 rounded-lg bg-muted/80 p-4 pl-5 shadow-xl shadow-accent backdrop-blur-xs md:gap-4 md:pl-7 justify-end' diff --git a/demo/the-epic-stack/app/components/forms.tsx b/demo/the-epic-stack/app/components/forms.tsx new file mode 100644 index 000000000..abaabf7ef --- /dev/null +++ b/demo/the-epic-stack/app/components/forms.tsx @@ -0,0 +1,202 @@ +import { useInputControl } from '@conform-to/react' +import { REGEXP_ONLY_DIGITS_AND_CHARS, type OTPInputProps } from 'input-otp' +import React, { useId } from 'react' +import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx' +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from './ui/input-otp.tsx' +import { Input } from './ui/input.tsx' +import { Label } from './ui/label.tsx' +import { Textarea } from './ui/textarea.tsx' + +export type ListOfErrors = Array | null | undefined + +export function ErrorList({ + id, + errors, +}: { + errors?: ListOfErrors + id?: string +}) { + const errorsToRender = errors?.filter(Boolean) + if (!errorsToRender?.length) return null + return ( +
    + {errorsToRender.map((e) => ( +
  • + {e} +
  • + ))} +
+ ) +} + +export function Field({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: React.InputHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + +export function OTPField({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: Partial + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + +export function TextareaField({ + labelProps, + textareaProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + textareaProps: React.TextareaHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = textareaProps.id ?? textareaProps.name ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+