diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 953980b3a..751ccad27 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -22,7 +22,7 @@ jobs: - run: vp install - - run: vp fmt - - run: vp lint --fix + - run: vp test + - run: vp check --fix - uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 diff --git a/.gitignore b/.gitignore index 31a64b97e..a30d18add 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist .output .vercel .cursor +.claude TODO.md coverage .netlify diff --git a/.vscode/settings.json b/.vscode/settings.json index 4df46df87..024298fdf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "editor.defaultFormatter": "oxc.oxc-vscode" }, "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", "source.fixAll.oxc": "explicit" }, "editor.defaultFormatter": "oxc.oxc-vscode", diff --git a/AGENTS.md b/AGENTS.md index 6403965c5..12a582364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,71 @@ # AGENTS.md -## Cursor Cloud specific instructions +## Overview -### Overview +Reactive Resume is a single-package full-stack TypeScript app (not a monorepo) built with [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/overview) (React, Vite, Nitro). It serves both frontend and API on port 3000. -Reactive Resume is a single-package full-stack TypeScript app (not a monorepo) built with TanStack Start (React 19, Vite, Nitro). It serves both frontend and API on port 3000. +This project uses [Vite+](https://vite.dev/blog/announcing-viteplus), a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. All modules should be imported from the `vite-plus` dependency (e.g., `import { defineConfig } from 'vite-plus'` or `import { expect, test, vi } from 'vite-plus/test'`). -### Infrastructure services +## Key Libraries + +| Area | Library | Docs | +| -------------------- | ------------------------------------------------------------------------ | ---------------------------------- | +| Frontend framework | React | https://react.dev | +| Full-stack framework | TanStack Start | https://tanstack.com/start/latest | +| Router | TanStack React Router | https://tanstack.com/router/latest | +| Server state | TanStack React Query | https://tanstack.com/query/latest | +| Client state | Zustand (+ Zundo for undo/redo, Immer for immutable updates) | https://zustand.docs.pmnd.rs | +| Type-safe API | oRPC | https://orpc.unnoq.com | +| Database ORM | Drizzle ORM (PostgreSQL) | https://orm.drizzle.team | +| Authentication | Better Auth (+ Drizzle adapter, OAuth provider, API keys, 2FA, Passkeys) | https://www.better-auth.com | +| Styling | Tailwind CSS | https://tailwindcss.com | +| UI Components | shadcn/ui (built on Base UI) | https://ui.shadcn.com | +| Icons | Phosphor Icons | https://phosphoricons.com | +| Forms | React Hook Form (+ Zod resolvers) | https://react-hook-form.com | +| Rich text editor | Tiptap | https://tiptap.dev | +| Validation | Zod | https://zod.dev | +| AI | Vercel AI SDK (OpenAI, Anthropic, Google, Ollama providers) | https://ai-sdk.dev | +| MCP | Model Context Protocol SDK | https://modelcontextprotocol.io | +| i18n | Lingui | https://lingui.dev | +| Animations | Motion (Framer Motion) | https://motion.dev | +| PDF export | Puppeteer Core (via Browserless) | https://pptr.dev | +| Drag and drop | dnd-kit | https://dndkit.com | +| Server engine | Nitro | https://nitro.build | +| PWA | Vite PWA Plugin | https://vite-pwa-org.netlify.app | +| Unused deps | Knip | https://knip.dev | + +## Project Structure + +``` +src/ + components/ UI, resume, layout, animation, theme, locale components + routes/ File-based routing (TanStack React Router) + integrations/ Feature modules (auth, drizzle, orpc, ai, email, jobs, mcp, storage) + schema/ Zod schemas for resume data validation + utils/ Utility functions (locale, theme, env, resume processing) + dialogs/ Modal/dialog components + hooks/ Custom React hooks + styles/ CSS and Tailwind configuration + stores/ Zustand stores (resume, AI, dialog, command palette) +migrations/ Drizzle database migrations +locales/ Lingui i18n message catalogs (47+ locales) +``` + +### Key Config Files + +- `vite.config.ts` — Vite + Nitro + TanStack Start + PWA + Tailwind + Lingui +- `drizzle.config.ts` — PostgreSQL dialect, schema at `./src/integrations/drizzle/schema.ts` +- `tsconfig.json` — ES2022, strict mode, path alias `@/*` → `./src/*` +- `lingui.config.ts` — i18n extraction and locale configuration +- `components.json` — shadcn CLI configuration + +### API Architecture + +- **oRPC API** (`/api/rpc/*`) — Type-safe RPC with routers for: `ai`, `auth`, `resume`, `storage`, `printer`, `jobs`, `statistics`, `flags`. Three procedure types: `publicProcedure`, `protectedProcedure`, `serverOnlyProcedure`. +- **Better Auth API** (`/api/auth/*`) — OAuth, session management, social provider callbacks. +- **MCP Server** (`/mcp/`) — Model Context Protocol with OAuth Bearer tokens and API key auth. Exposes resumes as resources and tools for resume CRUD. + +## Infrastructure Services Before running the dev server, Docker must be running with at least PostgreSQL. Start services via `compose.dev.yml`: @@ -18,7 +77,7 @@ sudo docker compose -f compose.dev.yml up -d postgres browserless - **PostgreSQL** (port 5432) — required. The app auto-runs Drizzle migrations on startup via a Nitro plugin. - **Browserless** (port 4000) — required for PDF export. Maps container port 3000 to host port 4000. -### Environment variables +## Environment Variables Copy `.env.example` to `.env` if not present. Key notes for local dev: @@ -28,99 +87,47 @@ Copy `.env.example` to `.env` if not present. Key notes for local dev: - `DATABASE_URL` — PostgreSQL connection using `postgres:postgres` credentials on localhost:5432. - S3/Storage and SMTP vars can be left empty — the app falls back to local filesystem and console-logged emails. -### Common commands +## Common Commands -See `scripts` in `package.json`. Key ones: +`vp` is the global CLI for Vite+. Do not use pnpm/npm/yarn directly — Vite+ wraps the underlying package manager. -| Task | Command | -| -------------- | --------------------------------------------------------------- | -| Dev server | `pnpm dev` (port 3000) | -| Lint (Oxlint) | `pnpm lint` | -| Format (Oxfmt) | `pnpm fmt` | -| Typecheck | `pnpm typecheck` | -| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) | +| Task | Command | +| -------------------------- | --------------------------------------------------------------- | +| Install dependencies | `vp install` | +| Dev server (port 3000) | `vp dev` | +| Lint (Oxlint, type-aware) | `vp lint --type-aware` | +| Format (Oxfmt) | `vp fmt` | +| Check (lint + fmt + types) | `vp check` | +| Typecheck | `pnpm typecheck` (uses tsgo) | +| Run tests | `vp test` | +| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) | +| DB studio | `pnpm db:studio` | +| i18n extraction | `pnpm lingui:extract` | +| Add a dependency | `vp add ` | +| Remove a dependency | `vp remove ` | +| One-off binary | `vp dlx ` | +| Build for production | `vp build` | +| Preview production build | `vp preview` | +| Start production server | `pnpm start` | -### Gotchas +## Vite+ Pitfalls + +- **Do not use pnpm/npm/yarn directly** for package operations — use `vp add`, `vp remove`, `vp install`, etc. +- **Do not run `vp vitest` or `vp oxlint`** — they don't exist. Use `vp test` and `vp lint`. +- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly** — Vite+ bundles them. +- **Import from `vite-plus`**, not from `vite` or `vitest` directly (e.g., `import { defineConfig } from 'vite-plus'`). +- **Vite+ commands take precedence** over `package.json` scripts. If there's a naming conflict, use `vp run ')).toBe(""); + }); + + it("should strip event handlers", () => { + expect(sanitizeHtml('')).toBe(""); + }); + + it("should allow links with href", () => { + const html = 'link'; + expect(sanitizeHtml(html)).toContain('href="https://example.com"'); + }); + + it("should strip javascript: hrefs", () => { + const result = sanitizeHtml('click'); + expect(result).not.toContain("javascript:"); + }); + + it("should allow list elements", () => { + const html = "
  • one
  • two
"; + expect(sanitizeHtml(html)).toBe(html); + }); + + it("should allow table elements", () => { + const html = "
cell
"; + expect(sanitizeHtml(html)).toContain(""); + }); +}); + +describe("sanitizeCss", () => { + it("should return empty string for empty input", () => { + expect(sanitizeCss("")).toBe(""); + }); + + it("should pass through normal CSS", () => { + expect(sanitizeCss("color: red;")).toBe("color: red;"); + }); + + it("should strip javascript: expressions", () => { + expect(sanitizeCss("background: javascript:alert(1)")).not.toContain("javascript:"); + }); + + it("should strip expression() calls", () => { + expect(sanitizeCss("width: expression(alert(1))")).not.toContain("expression("); + }); + + it("should strip behavior: property", () => { + expect(sanitizeCss("behavior: url(evil.htc)")).not.toContain("behavior:"); + }); + + it("should strip -moz-binding", () => { + expect(sanitizeCss("-moz-binding: url(evil.xml)")).not.toContain("-moz-binding:"); + }); +}); + +describe("isObject", () => { + it("should return true for plain objects", () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + }); + + it("should return false for arrays", () => { + expect(isObject([])).toBe(false); + }); + + it("should return false for null", () => { + expect(isObject(null)).toBe(false); + }); + + it("should return false for primitives", () => { + expect(isObject("string")).toBe(false); + expect(isObject(42)).toBe(false); + expect(isObject(undefined)).toBe(false); + }); +}); diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts new file mode 100644 index 000000000..694fbd050 --- /dev/null +++ b/src/utils/string.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getInitials, slugify, stripHtml, toUsername } from "./string"; + +describe("slugify", () => { + it("should lowercase and hyphenate spaces", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("should strip special characters", () => { + expect(slugify("Résumé & CV!")).toBe("resume-and-cv"); + }); + + it("should not decamelize camelCase strings", () => { + expect(slugify("camelCase")).toBe("camelcase"); + }); + + it("should return empty string for empty input", () => { + expect(slugify("")).toBe(""); + }); +}); + +describe("getInitials", () => { + it("should return up to two uppercase initials", () => { + expect(getInitials("John Doe")).toBe("JD"); + }); + + it("should return one initial for a single name", () => { + expect(getInitials("Alice")).toBe("A"); + }); + + it("should take only the first two words", () => { + expect(getInitials("John Michael Doe")).toBe("JM"); + }); +}); + +describe("toUsername", () => { + it("should lowercase and strip disallowed characters", () => { + expect(toUsername("John Doe!")).toBe("johndoe"); + }); + + it("should keep dots, hyphens, and underscores", () => { + expect(toUsername("john.doe-name_ok")).toBe("john.doe-name_ok"); + }); + + it("should trim whitespace", () => { + expect(toUsername(" alice ")).toBe("alice"); + }); + + it("should truncate to 64 characters", () => { + const long = "a".repeat(100); + expect(toUsername(long)).toHaveLength(64); + }); +}); + +describe("stripHtml", () => { + it("should remove HTML tags and trim", () => { + expect(stripHtml("

Hello World

")).toBe("Hello World"); + }); + + it("should return empty string for undefined", () => { + expect(stripHtml(undefined)).toBe(""); + }); + + it("should return empty string for empty string", () => { + expect(stripHtml("")).toBe(""); + }); + + it("should handle nested tags", () => { + expect(stripHtml("
  • item
")).toBe("item"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 877803658..9e08775a1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -222,6 +222,11 @@ const config = defineConfig({ tsconfigPaths: true, }, + test: { + environment: "jsdom", + reporters: process.env.GITHUB_ACTIONS ? ["github-actions"] : ["dot"], + }, + build: { sourcemap: true, chunkSizeWarningLimit: 10 * 1024, // 10mb