mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-01-11 04:22:58 -05:00
Remove Cloudflare Turnstile integration and replace it with Vercel's BotID for spam protection in the contact form. Update environment variables and dependencies accordingly.
This commit is contained in:
@@ -11,10 +11,8 @@ GITHUB_TOKEN=
|
|||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
RESEND_FROM_EMAIL=
|
RESEND_FROM_EMAIL=
|
||||||
RESEND_TO_EMAIL=
|
RESEND_TO_EMAIL=
|
||||||
TURNSTILE_SECRET_KEY=
|
|
||||||
NEXT_PUBLIC_BASE_URL=
|
NEXT_PUBLIC_BASE_URL=
|
||||||
NEXT_PUBLIC_GITHUB_REPO=
|
NEXT_PUBLIC_GITHUB_REPO=
|
||||||
NEXT_PUBLIC_ONION_DOMAIN=
|
NEXT_PUBLIC_ONION_DOMAIN=
|
||||||
NEXT_PUBLIC_SITE_LOCALE=
|
NEXT_PUBLIC_SITE_LOCALE=
|
||||||
NEXT_PUBLIC_SITE_TZ=
|
NEXT_PUBLIC_SITE_TZ=
|
||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
|
||||||
|
|||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,13 +42,11 @@ Required server environment variables:
|
|||||||
- `GITHUB_TOKEN`: GitHub API token for projects page
|
- `GITHUB_TOKEN`: GitHub API token for projects page
|
||||||
- `RESEND_API_KEY`: Resend API key for contact form
|
- `RESEND_API_KEY`: Resend API key for contact form
|
||||||
- `RESEND_TO_EMAIL`: Destination email for contact form
|
- `RESEND_TO_EMAIL`: Destination email for contact form
|
||||||
- `TURNSTILE_SECRET_KEY`: Cloudflare Turnstile secret key
|
|
||||||
|
|
||||||
Required client environment variables:
|
Required client environment variables:
|
||||||
|
|
||||||
- `NEXT_PUBLIC_GITHUB_REPO`: Repository in format "username/repo"
|
- `NEXT_PUBLIC_GITHUB_REPO`: Repository in format "username/repo"
|
||||||
- `NEXT_PUBLIC_GITHUB_USERNAME`: GitHub username
|
- `NEXT_PUBLIC_GITHUB_USERNAME`: GitHub username
|
||||||
- `NEXT_PUBLIC_TURNSTILE_SITE_KEY`: Cloudflare Turnstile site key
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
73
CLAUDE.md
Normal file
73
CLAUDE.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start development server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
pnpm typecheck
|
||||||
|
|
||||||
|
# Lint code
|
||||||
|
pnpm lint
|
||||||
|
|
||||||
|
# Generate database migrations
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# Apply database migrations
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
Environment variables are strictly type-checked in `lib/env.ts`. Copy `.env.example` to `.env` or `.env.local` and populate required values.
|
||||||
|
|
||||||
|
Key required variables:
|
||||||
|
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string (Neon)
|
||||||
|
- `AUTH_SECRET`: Random value for authentication encryption
|
||||||
|
- `AUTH_GITHUB_CLIENT_ID` / `AUTH_GITHUB_CLIENT_SECRET`: GitHub OAuth App credentials
|
||||||
|
- `RESEND_API_KEY` / `RESEND_TO_EMAIL`: For contact form emails
|
||||||
|
- `NEXT_PUBLIC_GITHUB_REPO` / `NEXT_PUBLIC_GITHUB_USERNAME`: For projects page
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Next.js App Router**: Server components with TypeScript
|
||||||
|
- **Tailwind CSS v4**: Utility-first styling with shadcn/ui components
|
||||||
|
- **Drizzle ORM**: Type-safe database interactions with Neon PostgreSQL
|
||||||
|
- **Better Auth**: Authentication with GitHub OAuth
|
||||||
|
- **MDX**: Enhanced markdown for blog posts with custom plugins
|
||||||
|
|
||||||
|
### Key Structure
|
||||||
|
|
||||||
|
- `/app`: App Router pages and API routes
|
||||||
|
- `/components`: Feature-organized React components
|
||||||
|
- `/lib`: Core utilities, configs, and database schema
|
||||||
|
- `/notes`: MDX blog content files
|
||||||
|
- Database schema: `lib/db/schema.ts` with users, sessions, comments, and page views
|
||||||
|
|
||||||
|
### Content & Features
|
||||||
|
|
||||||
|
- Blog posts (`/notes`) are MDX files with frontmatter
|
||||||
|
- Comments system with nested threads
|
||||||
|
- Hit counter API (`/api/hits`)
|
||||||
|
- GitHub projects showcase
|
||||||
|
- Contact form with Vercel BotID protection
|
||||||
|
- RSS/Atom feeds
|
||||||
|
- Theme toggle (dark/light mode)
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- Always prefer React Server Components over client components
|
||||||
|
- Database operations use Drizzle ORM with Neon's serverless client
|
||||||
|
- MDX processing includes custom remark/rehype plugins configured in `next.config.ts`
|
||||||
|
- Deployment assumes Vercel with specific environment handling
|
||||||
|
- Strict ESLint and TypeScript configuration enforced
|
||||||
10
app/instrumentation.client.ts
Normal file
10
app/instrumentation.client.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { initBotId } from "botid/client/core";
|
||||||
|
|
||||||
|
initBotId({
|
||||||
|
protect: [
|
||||||
|
{
|
||||||
|
path: "/contact",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -43,6 +43,4 @@ Occasionally, embedded content from third-party services is included in posts, a
|
|||||||
|
|
||||||
## Fighting Spam
|
## Fighting Spam
|
||||||
|
|
||||||
Using [**Cloudflare Turnstile**](https://www.cloudflare.com/products/turnstile/) to fight bot spam on the [contact form](/contact) was an easy choice over seemingly unavoidable alternatives like [reCAPTCHA](https://developers.google.com/recaptcha/).
|
Form submissions on this site are protected by Vercel's [BotID](https://vercel.com/blog/introducing-botid) deep analysis. Refer to [Vercel's](https://vercel.com/legal/privacy-policy) and [Kasada's](https://www.kasada.io/privacy-policy/) privacy policies for details.
|
||||||
|
|
||||||
You can refer to Cloudflare's [privacy policy](https://www.cloudflare.com/privacypolicy/) and [terms of service](https://www.cloudflare.com/website-terms/) for more details. While some information is sent to the Turnstile API about your behavior (on the contact page only), at least you won't be helping a certain internet conglomerate [train their self-driving cars](https://blog.cloudflare.com/moving-from-recaptcha-to-hcaptcha/). 🚗
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import { Turnstile } from "@marsidev/react-turnstile";
|
|
||||||
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
|
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import Input from "@/components/ui/input";
|
import Input from "@/components/ui/input";
|
||||||
@@ -91,15 +89,6 @@ const ContactForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Turnstile siteKey={env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
|
||||||
{formState.errors?.["cf-turnstile-response"] && (
|
|
||||||
<span className="text-destructive text-[0.8rem] font-semibold">
|
|
||||||
{formState.errors["cf-turnstile-response"][0]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-16 items-center space-x-4">
|
<div className="flex min-h-16 items-center space-x-4">
|
||||||
{!formState.success && (
|
{!formState.success && (
|
||||||
<Button type="submit" size="lg" disabled={pending}>
|
<Button type="submit" size="lg" disabled={pending}>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ const compat = new FlatCompat({
|
|||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
{ ignores: ["README.md", ".next", ".vercel", "node_modules", "lib/db/migrations"] },
|
{
|
||||||
|
ignores: ["README.md", "next-env.d.ts", ".next", ".vercel", "node_modules", "lib/db/migrations"],
|
||||||
|
},
|
||||||
...compat.config({
|
...compat.config({
|
||||||
plugins: ["react-compiler", "css-modules"],
|
plugins: ["react-compiler", "css-modules"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
16
lib/env.ts
16
lib/env.ts
@@ -60,13 +60,6 @@ export const env = createEnv({
|
|||||||
|
|
||||||
/** Required. The destination email for contact form submissions. */
|
/** Required. The destination email for contact form submissions. */
|
||||||
RESEND_TO_EMAIL: z.string().email(),
|
RESEND_TO_EMAIL: z.string().email(),
|
||||||
|
|
||||||
/**
|
|
||||||
* Required. Secret for Cloudflare `siteverify` API to validate a form's turnstile result on the backend.
|
|
||||||
*
|
|
||||||
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
|
||||||
*/
|
|
||||||
TURNSTILE_SECRET_KEY: z.string().default("1x0000000000000000000000000000000AA"),
|
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
/**
|
/**
|
||||||
@@ -146,14 +139,6 @@ export const env = createEnv({
|
|||||||
* @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
|
* @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
|
||||||
*/
|
*/
|
||||||
NEXT_PUBLIC_SITE_TZ: z.string().default("America/New_York"),
|
NEXT_PUBLIC_SITE_TZ: z.string().default("America/New_York"),
|
||||||
|
|
||||||
/**
|
|
||||||
* Required. Site key must be prefixed with NEXT_PUBLIC_ since it is used to embed the captcha widget. Falls back to
|
|
||||||
* testing keys if not set or in dev environment.
|
|
||||||
*
|
|
||||||
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
|
||||||
*/
|
|
||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().default("1x00000000000000000000AA"),
|
|
||||||
},
|
},
|
||||||
experimental__runtimeEnv: {
|
experimental__runtimeEnv: {
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
@@ -163,7 +148,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_ONION_DOMAIN: process.env.NEXT_PUBLIC_ONION_DOMAIN,
|
NEXT_PUBLIC_ONION_DOMAIN: process.env.NEXT_PUBLIC_ONION_DOMAIN,
|
||||||
NEXT_PUBLIC_SITE_LOCALE: process.env.NEXT_PUBLIC_SITE_LOCALE,
|
NEXT_PUBLIC_SITE_LOCALE: process.env.NEXT_PUBLIC_SITE_LOCALE,
|
||||||
NEXT_PUBLIC_SITE_TZ: process.env.NEXT_PUBLIC_SITE_TZ,
|
NEXT_PUBLIC_SITE_TZ: process.env.NEXT_PUBLIC_SITE_TZ,
|
||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
|
||||||
},
|
},
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import siteConfig from "@/lib/config/site";
|
import siteConfig from "@/lib/config/site";
|
||||||
|
import { checkBotId } from "botid/server";
|
||||||
|
|
||||||
const ContactSchema = z
|
const ContactSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(1, { message: "Your name is required." }),
|
name: z.string().trim().min(1, { message: "Your name is required." }),
|
||||||
email: z.string().email({ message: "Your email address is required." }),
|
email: z.string().email({ message: "Your email address is required." }),
|
||||||
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
|
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
|
||||||
"cf-turnstile-response": z.string().min(1, { message: "Are you sure you're not a robot...? 🤖" }),
|
|
||||||
})
|
})
|
||||||
.readonly();
|
.readonly();
|
||||||
|
|
||||||
@@ -28,6 +27,12 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
|
|||||||
console.debug("[server/resend] received payload:", payload);
|
console.debug("[server/resend] received payload:", payload);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// BotID server-side verification
|
||||||
|
const verification = await checkBotId();
|
||||||
|
if (verification.isBot) {
|
||||||
|
return { success: false, message: "Bot detection failed. 🤖" };
|
||||||
|
}
|
||||||
|
|
||||||
const data = ContactSchema.safeParse(Object.fromEntries(payload));
|
const data = ContactSchema.safeParse(Object.fromEntries(payload));
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
@@ -38,37 +43,6 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
|
|
||||||
let remoteip;
|
|
||||||
try {
|
|
||||||
remoteip = (await headers()).get("x-forwarded-for");
|
|
||||||
} catch {} // eslint-disable-line no-empty
|
|
||||||
|
|
||||||
// validate captcha
|
|
||||||
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
secret: env.TURNSTILE_SECRET_KEY,
|
|
||||||
response: data.data["cf-turnstile-response"],
|
|
||||||
remoteip,
|
|
||||||
}),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!turnstileResponse || !turnstileResponse.ok) {
|
|
||||||
throw new Error(`[server/resend] turnstile validation failed: ${turnstileResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
|
|
||||||
|
|
||||||
if (!turnstileData.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") {
|
if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") {
|
||||||
// https://resend.com/docs/api-reference/emails/send-email
|
// https://resend.com/docs/api-reference/emails/send-email
|
||||||
console.warn("[server/resend] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
|
console.warn("[server/resend] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ const nextPlugins: Array<
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
require("botid/next/config").withBotId,
|
||||||
];
|
];
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-anonymous-default-export
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -22,12 +22,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@date-fns/tz": "^1.4.1",
|
"@date-fns/tz": "^1.4.1",
|
||||||
"@date-fns/utc": "^2.1.1",
|
"@date-fns/utc": "^2.1.1",
|
||||||
"@marsidev/react-turnstile": "^1.3.0",
|
|
||||||
"@mdx-js/loader": "^3.1.0",
|
"@mdx-js/loader": "^3.1.0",
|
||||||
"@mdx-js/react": "^3.1.0",
|
"@mdx-js/react": "^3.1.0",
|
||||||
"@neondatabase/serverless": "^1.0.1",
|
"@neondatabase/serverless": "^1.0.1",
|
||||||
"@next/bundle-analyzer": "15.5.1-canary.9",
|
"@next/bundle-analyzer": "15.5.1-canary.16",
|
||||||
"@next/mdx": "15.5.1-canary.9",
|
"@next/mdx": "15.5.1-canary.16",
|
||||||
"@octokit/graphql": "^9.0.1",
|
"@octokit/graphql": "^9.0.1",
|
||||||
"@octokit/graphql-schema": "^15.26.0",
|
"@octokit/graphql-schema": "^15.26.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -46,6 +45,7 @@
|
|||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@vercel/speed-insights": "^1.2.0",
|
||||||
"better-auth": "1.3.7",
|
"better-auth": "1.3.7",
|
||||||
|
"botid": "^1.5.4",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"geist": "^1.4.2",
|
"geist": "^1.4.2",
|
||||||
"html-entities": "^2.6.0",
|
"html-entities": "^2.6.0",
|
||||||
"lucide-react": "0.542.0",
|
"lucide-react": "0.542.0",
|
||||||
"next": "15.5.1-canary.9",
|
"next": "15.5.1-canary.16",
|
||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-activity-calendar": "^2.7.13",
|
"react-activity-calendar": "^2.7.13",
|
||||||
"react-countup": "^6.5.3",
|
"react-countup": "^6.5.3",
|
||||||
@@ -87,13 +87,13 @@
|
|||||||
"remark-strip-mdx-imports-exports": "^1.0.1",
|
"remark-strip-mdx-imports-exports": "^1.0.1",
|
||||||
"resend": "^6.0.1",
|
"resend": "^6.0.1",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"shiki": "^3.11.0",
|
"shiki": "^3.12.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.12",
|
"tailwindcss": "^4.1.12",
|
||||||
"tw-animate-css": "^1.3.7",
|
"tw-animate-css": "^1.3.7",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"zod": "4.1.3"
|
"zod": "4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
@@ -102,14 +102,14 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.12",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "19.1.11",
|
"@types/react": "19.1.12",
|
||||||
"@types/react-dom": "19.1.8",
|
"@types/react-dom": "19.1.9",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
"eslint-config-next": "15.5.1-canary.9",
|
"eslint-config-next": "15.5.1-canary.16",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-css-modules": "^2.12.0",
|
"eslint-plugin-css-modules": "^2.12.0",
|
||||||
"eslint-plugin-drizzle": "^0.2.3",
|
"eslint-plugin-drizzle": "^0.2.3",
|
||||||
|
|||||||
849
pnpm-lock.yaml
generated
849
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user