diff --git a/.changeset/cold-trams-see.md b/.changeset/cold-trams-see.md new file mode 100644 index 0000000..145dea7 --- /dev/null +++ b/.changeset/cold-trams-see.md @@ -0,0 +1,8 @@ +--- +"create-vorsteh-queue": patch +"@vorsteh-queue/adapter-drizzle": patch +"@vorsteh-queue/adapter-prisma": patch +"@vorsteh-queue/core": patch +--- + +update JSDOC descriptions diff --git a/.changeset/loose-bikes-clap.md b/.changeset/loose-bikes-clap.md new file mode 100644 index 0000000..2ead1e6 --- /dev/null +++ b/.changeset/loose-bikes-clap.md @@ -0,0 +1,8 @@ +--- +"create-vorsteh-queue": patch +"@vorsteh-queue/adapter-drizzle": patch +"@vorsteh-queue/adapter-prisma": patch +"@vorsteh-queue/core": patch +--- + +Updated dependencies diff --git a/.gitignore b/.gitignore index 258cd99..dc49614 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ yarn-error.log* # turbo .turbo +apps/docs/public/pagefind/ \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index f0585d5..9211984 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ turbo/generators/scaffold/templates/**/*.hbs -**/.cache \ No newline at end of file +**/.cache +CHANGELOG.md +**/CHANGELOG.md \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 5fcd845..94c00fa 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,12 +2,33 @@ "version": "0.2.0", "configurations": [ { - "name": "Next.js", + "name": "Next.js: debug server-side", "type": "node-terminal", "request": "launch", - "command": "pnpm dev", - "cwd": "${workspaceFolder}/apps/nextjs/", - "skipFiles": ["/**"] + "command": "pnpm dev:docs", + "cwd": "${workspaceFolder}/apps/docs" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/apps/docs", + "program": "${workspaceFolder}/node_modules/next/dist/bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithChrome", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + } } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e27541..450c669 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,8 +11,7 @@ { "pattern": "tooling/*/" } ], "tailwindCSS.experimental.configFile": { - "apps/frontend/src/app/globals.css": ["apps/frontend/**"], - "packages/ui/src/styles/globals.css": ["packages/**", "apps/frontend/**"] + "apps/docs/src/app/globals.css": ["apps/docs/**"] }, "tailwindCSS.emmetCompletions": true, "tailwindCSS.files.exclude": [ diff --git a/apps/docs/components.json b/apps/docs/components.json index e6a618e..540fc6e 100644 --- a/apps/docs/components.json +++ b/apps/docs/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "app/globals.css", + "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/apps/docs/content/docs/01.getting-started/01.introduction.mdx b/apps/docs/content/docs/01.getting-started/01.introduction.mdx new file mode 100644 index 0000000..804b70c --- /dev/null +++ b/apps/docs/content/docs/01.getting-started/01.introduction.mdx @@ -0,0 +1,33 @@ +--- +title: Introduction +description: Get started with Vorsteh Queue, a powerful TypeScript-first job queue for PostgreSQL. +--- + +Vorsteh Queue is a powerful, type-safe queue engine designed for PostgreSQL 12+. It provides a robust solution for handling background jobs, scheduled tasks, and recurring processes in your Node.js applications. + +## Key Features + +- **Type-safe**: Full TypeScript support with generic job payloads +- **Multiple adapters**: Drizzle ORM (PostgreSQL), Prisma ORM (PostgreSQL), and in-memory implementations +- **Priority queues**: Numeric priority system (lower = higher priority) +- **Delayed jobs**: Schedule jobs for future execution +- **Recurring jobs**: Cron expressions and interval-based repetition +- **UTC-first timezone support**: Reliable timezone handling with UTC storage +- **Progress tracking**: Real-time job progress updates +- **Event system**: Listen to job lifecycle events +- **Graceful shutdown**: Clean job processing termination + +## Requirements + +- **Node.js 20+** +- **ESM only** - This package is ESM-only +- **PostgreSQL 12+** for production use + +## Architecture + +Vorsteh Queue follows a modular architecture with: + +- **Core package** - Contains the main queue logic and interfaces +- **Adapter packages** - ORM-specific implementations +- **UTC-first design** - All timestamps stored as UTC for consistency +- **Event-driven** - Comprehensive event system for monitoring and debugging diff --git a/apps/docs/content/docs/01.getting-started/02.installation.mdx b/apps/docs/content/docs/01.getting-started/02.installation.mdx new file mode 100644 index 0000000..ac3a842 --- /dev/null +++ b/apps/docs/content/docs/01.getting-started/02.installation.mdx @@ -0,0 +1,76 @@ +--- +title: Installation +description: Learn how to install Vorsteh Queue packages for your preferred ORM adapter. +--- + +## Prerequisites + +Before installing Vorsteh Queue, ensure you have: + +- **Node.js 20+** +- **PostgreSQL 12+** database +- **ESM project** - Add `"type": "module"` to your `package.json` or use `.mjs` file extensions + +## Quick Start with CLI (Recommended) + +The fastest way to get started is using the `create-vorsteh-queue` CLI tool: + +```bash +# Interactive setup +npx create-vorsteh-queue my-queue-app + +# Or with specific options +npx create-vorsteh-queue worker-service \ + --template=drizzle-postgres \ + --package-manager=pnpm \ + --quiet +``` + +### Available Templates + +- **drizzle-pg** - Basic Drizzle ORM with node-postgres +- **drizzle-pglite** - Zero-setup with embedded PostgreSQL +- **drizzle-postgres** - Advanced with recurring jobs +- **event-system** - Comprehensive event monitoring +- **progress-tracking** - Real-time progress updates +- **pm2-workers** - Multi-process workers with PM2 + +## Manual Installation + +If you prefer to add Vorsteh Queue to an existing project: + +### Drizzle ORM (PostgreSQL) + + + +### Prisma ORM (PostgreSQL) + + + +## Database Setup + +After installation, you'll need to set up the required database tables. Each adapter provides migration scripts or schema definitions: + +- **Drizzle**: Use the provided schema and run migrations +- **Prisma**: Add the schema to your `schema.prisma` and run `prisma migrate` + +Refer to the specific adapter documentation for detailed setup instructions. + +## Verify Installation + +Create a simple test file to verify your installation: + +```typescript +import { Queue } from "@vorsteh-queue/core" + +// Your adapter import will vary based on your choice +console.log("Vorsteh Queue installed successfully!") +``` + +Run the file with Node.js to confirm everything is working correctly. diff --git a/apps/docs/content/docs/02.packages/01.core.mdx b/apps/docs/content/docs/02.packages/01.core.mdx new file mode 100644 index 0000000..d722029 --- /dev/null +++ b/apps/docs/content/docs/02.packages/01.core.mdx @@ -0,0 +1,326 @@ +--- +title: Core Package +navTitle: "@vorsteh-queue/core" +description: The main queue engine containing all core functionality for job processing, scheduling, and management. +--- + +The core package contains the main queue engine and all essential functionality for job processing, scheduling, and event management. + +## Installation + + + +## Key Features + +- **Queue Management** - Create and manage multiple queues +- **Job Processing** - Register handlers and process jobs +- **Event System** - Comprehensive event lifecycle +- **Progress Tracking** - Real-time job progress updates +- **Scheduling** - Delayed and recurring jobs +- **Priority System** - Numeric priority-based processing +- **Graceful Shutdown** - Clean termination handling + +## Core Methods + +### Registering Job Handlers + +Define how jobs should be processed: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +interface Payload { + to: string + subject: string +} + +interface Result { + messageId: string + sent: true +} + +queue.register("send-email", async (job) => { + //await sendEmail(job.payload) + return { messageId: "msg_123", sent: true } +}) +``` + +### Adding Jobs + +Add jobs to the queue for processing: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +interface EmailPayload { + to: string + subject: string +} + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +// Basic job +await queue.add("send-email", { + to: "user@example.com", + subject: "Welcome!", +}) + +// Job with options +const payload: EmailPayload = { + to: "user@example.com", + subject: "Welcome!", +} + +await queue.add("send-email", payload, { + priority: 1, + delay: 5000, + maxAttempts: 5, +}) +``` + +### Queue Control + +Manage queue processing: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +// Start processing jobs +queue.start() + +// Pause processing +queue.pause() + +// Resume processing +queue.resume() + +// Graceful shutdown +await queue.stop() +``` + +### Progress Tracking + +Update job progress in real-time: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +interface ProcessPayload { + items: string[] +} + +interface ProcessResult { + processed: number +} + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +queue.register("process-data", async (job) => { + const items = job.payload.items + + for (let i = 0; i < items.length; i++) { + //await processItem(items[i]) + + // Update progress (0-100) + const progress = Math.round(((i + 1) / items.length) * 100) + await job.updateProgress(progress) + } + + return { processed: items.length } +}) +``` + +### Scheduling Jobs + +Schedule jobs for future execution: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +interface CleanupPayload { + type: string +} + +interface ReportPayload { + date: string +} + +interface HealthCheckPayload { + endpoint: string +} + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +// Delayed job +await queue.add( + "cleanup", + { type: "temp-files" }, + { + delay: 60000, // 1 minute + }, +) + +// Recurring job with cron +await queue.add( + "daily-report", + { date: new Date().toISOString() }, + { + cron: "0 9 * * *", // Every day at 9 AM + timezone: "America/New_York", + }, +) + +// Recurring job with interval +await queue.add( + "health-check", + { endpoint: "/api/health" }, + { + repeat: { every: 30000, limit: 10 }, // Every 30s, 10 times + }, +) +``` + +### Event Handling + +Listen to job and queue events: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +// Job lifecycle events +queue.on("job:added", (job) => { + console.log(`Job ${job.name} added to queue`) +}) + +queue.on("job:completed", (job) => { + console.log(`Job ${job.name} completed`) + console.log("Result:", job.result) +}) + +queue.on("job:failed", (job) => { + console.error(`Job ${job.name} failed:`, job.error) +}) + +queue.on("job:progress", (job) => { + console.log(`Job ${job.name}: ${job.progress}% complete`) +}) + +// Queue control events +queue.on("queue:paused", () => { + console.log("Queue paused") +}) + +queue.on("queue:resumed", () => { + console.log("Queue resumed") +}) +``` + +### Queue Statistics + +Get queue metrics and status: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +// Get current stats +const stats = await queue.getStats() +console.log(stats) // { pending: 5, processing: 2, completed: 100, failed: 3 } + +// Clear jobs +await queue.clear() // Clear all jobs +await queue.clear("failed") // Clear only failed jobs +``` + +### Error Handling + +Handle job failures and timeouts: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +interface RiskyJobPayload { + operation: string + data: unknown +} + +interface RiskyJobResult { + success: boolean + error?: string +} + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +queue.register("risky-job", async (job) => { + try { + //await riskyOperation(job.payload) + return { success: true } + } catch (error) { + throw error // Re-throw to mark job as failed + } +}) + +// Set job timeout +const payload: RiskyJobPayload = { + operation: "data-processing", + data: { items: [1, 2, 3] }, +} + +await queue.add("risky-job", payload, { + timeout: 30000, // 30 seconds +}) +``` + +## Memory Adapter + +Includes a built-in memory adapter for testing and development: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) +``` + +## TypeScript Support + +Full TypeScript support with generic job payloads: + +```ts +import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" + +const queue = new Queue(new MemoryQueueAdapter(), { name: "example" }) + +interface EmailPayload { + to: string + subject: string + body: string +} + +interface EmailResult { + success: boolean +} + +queue.register("send-email", async (job) => { + // job.payload is typed as EmailPayload + //await sendEmail(job.payload) + + return { + success: true, + } +}) +``` + +## References + +- Sources: https://github.com/noxify/vorsteh-queue/tree/main/packages/core +- NPM: https://www.npmjs.com/package/@vorsteh-queue/core diff --git a/apps/docs/content/docs/02.packages/02.create-vorsteh-queue.mdx b/apps/docs/content/docs/02.packages/02.create-vorsteh-queue.mdx new file mode 100644 index 0000000..0020fa3 --- /dev/null +++ b/apps/docs/content/docs/02.packages/02.create-vorsteh-queue.mdx @@ -0,0 +1,131 @@ +--- +title: "create-vorsteh-queue CLI" +navTitle: create-vorsteh-queue +description: CLI tool for quickly creating new Vorsteh Queue projects with pre-configured templates. +--- + +The `create-vorsteh-queue` CLI tool provides the fastest way to get started with Vorsteh Queue by creating new projects from pre-built templates. + +## Installation + +No installation required - use with `npx`: + +```bash +npx create-vorsteh-queue +``` + +## Usage + +### Interactive Mode + +```bash +# Full interactive experience +npx create-vorsteh-queue +``` + +The CLI will prompt you for: + +- Project name +- Template selection +- Package manager preference +- Dependency installation + +### Direct Mode + +```bash +# With project name +npx create-vorsteh-queue my-queue-app + +# With template selection +npx create-vorsteh-queue my-app --template=drizzle-postgres + +# With package manager +npx create-vorsteh-queue my-app --package-manager=pnpm + +# Fully automated +npx create-vorsteh-queue my-app \ + --template=drizzle-postgres \ + --package-manager=pnpm \ + --quiet +``` + +## CLI Options + +| Option | Short | Description | Example | +| ------------------------ | ----------- | ----------------------- | --------------------- | +| `--template=` | `-t=` | Choose template | `-t=drizzle-postgres` | +| `--package-manager=` | `-pm=` | Package manager | `-pm=pnpm` | +| `--no-install` | - | Skip dependency install | `--no-install` | +| `--quiet` | `-q` | Minimal output | `--quiet` | + +## Available Templates + +Templates are dynamically discovered from the repository: + +- **drizzle-pg** - Basic Drizzle ORM with node-postgres +- **drizzle-pglite** - Zero-setup with embedded PostgreSQL +- **drizzle-postgres** - Advanced with recurring jobs +- **event-system** - Comprehensive event monitoring +- **progress-tracking** - Real-time progress updates +- **pm2-workers** - Multi-process workers with PM2 +- **prisma-client** - Example with the new `prisma-client` +- **prisma-client-js** - Example with the old `prisma-client-js` + +## Package Managers + +Supported package managers: + +- **npm** - Default Node.js package manager +- **pnpm** - Fast, disk space efficient +- **yarn** - Popular alternative +- **bun** - Ultra-fast (experimental) + +## Template Structure + +Each template includes: + +- **Complete source code** - Ready-to-run example +- **Database schema** - Pre-configured tables +- **Environment setup** - Example `.env` files +- **Documentation** - README with setup instructions +- **TypeScript configuration** - Optimized settings + +## Examples + +### Quick Start + +```bash +npx create-vorsteh-queue my-app --template=drizzle-pglite +cd my-app +npm run dev +``` + +### Production Setup + +```bash +npx create-vorsteh-queue worker-service \ + --template=drizzle-postgres \ + --package-manager=pnpm +cd worker-service +pnpm db:push +pnpm dev +``` + +### CI/CD Friendly + +```bash +npx create-vorsteh-queue my-app \ + --template=event-system \ + --package-manager=pnpm \ + --no-install \ + --quiet +``` + +## Template Development + +Templates are automatically discovered from the `examples/` directory in the repository. To add a new template: + +1. Create a new example in `examples/` +2. Include a `package.json` with proper metadata +3. Add a `README.md` with setup instructions +4. The template becomes available automatically diff --git a/apps/docs/content/docs/02.packages/adapter-drizzle.mdx b/apps/docs/content/docs/02.packages/adapter-drizzle.mdx new file mode 100644 index 0000000..e81e43c --- /dev/null +++ b/apps/docs/content/docs/02.packages/adapter-drizzle.mdx @@ -0,0 +1,74 @@ +--- +title: Drizzle Adapter +navTitle: "@vorsteh-queue/adapter-drizzle" +description: Drizzle ORM adapter for PostgreSQL providing type-safe database operations with excellent performance. +--- + +The Drizzle adapter provides PostgreSQL support using Drizzle ORM, offering excellent TypeScript integration and performance. + +## Installation + + + +## Quick Start + +```typescript +import { drizzle } from "drizzle-orm/node-postgres" +import { Pool } from "pg" + +import { PostgresQueueAdapter } from "@vorsteh-queue/adapter-drizzle" +import { Queue } from "@vorsteh-queue/core" + +const pool = new Pool({ connectionString: "postgresql://..." }) +const db = drizzle(pool) +const adapter = new PostgresQueueAdapter(db) +const queue = new Queue(adapter) +``` + +## Supported providers + +- PGlite +- Postgres.JS +- Node Progress + +## Database Setup + +### Schema + +The adapter includes a pre-defined schema: + +```typescript +import { queueJobsTable } from "@vorsteh-queue/adapter-drizzle" + +// Use in your schema file +export { queueJobsTable } +``` + +If you don't want to use the pre-defined schema, you can create your own schema definition. + + + +### Migration + +Push the schema to your database: + +```bash +npx drizzle-kit push +``` + +Or generate and run migrations: + +```bash +npx drizzle-kit generate +npx drizzle-kit migrate +``` + +## References + +- Sources: https://github.com/noxify/vorsteh-queue/tree/main/packages/adapter-drizzle +- NPM: https://www.npmjs.com/package/@vorsteh-queue/adapter-drizzle diff --git a/apps/docs/content/docs/02.packages/adapter-prisma.mdx b/apps/docs/content/docs/02.packages/adapter-prisma.mdx new file mode 100644 index 0000000..5bc8325 --- /dev/null +++ b/apps/docs/content/docs/02.packages/adapter-prisma.mdx @@ -0,0 +1,65 @@ +--- +title: Prisma Adapter +navTitle: "@vorsteh-queue/adapter-prisma" +description: Prisma ORM adapter for PostgreSQL with generated types and excellent developer tooling. +--- + +The Prisma adapter provides PostgreSQL support using Prisma ORM, offering generated types, migrations, and excellent developer tooling. + +## Installation + + + +## Quick Start + +```typescript +import { PrismaClient } from "@prisma/client" + +import { PostgresPrismaQueueAdapter } from "@vorsteh-queue/adapter-prisma" +import { Queue } from "@vorsteh-queue/core" + +const prisma = new PrismaClient() +const adapter = new PostgresPrismaQueueAdapter(prisma) +const queue = new Queue(adapter) +``` + +## Database Setup + +### Schema + +Add the queue job model to your `schema.prisma`: + + + +### Migration + +Push the schema to your database: + +```bash +npx prisma db push +``` + +Or generate and apply the migration: + +```bash +npx prisma migrate dev --name add-queue-jobs +``` + +### Generate Client + +Generate the Prisma client: + +```bash +npx prisma generate +``` + +## References + +- Sources: https://github.com/noxify/vorsteh-queue/tree/main/packages/adapter-prisma +- NPM: https://www.npmjs.com/package/@vorsteh-queue/adapter-prisma diff --git a/apps/docs/content/docs/02.packages/index.mdx b/apps/docs/content/docs/02.packages/index.mdx new file mode 100644 index 0000000..564f901 --- /dev/null +++ b/apps/docs/content/docs/02.packages/index.mdx @@ -0,0 +1,44 @@ +--- +title: Packages +description: Overview of all Vorsteh Queue packages and their purposes. +--- + +Vorsteh Queue is built as a modular system with separate packages for different functionality. This allows you to install only what you need and keeps the core lightweight. + +## Core Package + +- **[@vorsteh-queue/core](/docs/packages/core)** - The main queue engine with all core functionality + +## Adapter Packages + +- **[@vorsteh-queue/adapter-drizzle](/docs/packages/adapter-drizzle)** - Drizzle ORM adapter for PostgreSQL +- **[@vorsteh-queue/adapter-prisma](/docs/packages/adapter-prisma)** - Prisma ORM adapter for PostgreSQL + +## CLI Tools + +- **[create-vorsteh-queue](/docs/packages/create-vorsteh-queue)** - CLI tool for creating new projects + +## Package Architecture + +The modular design allows for: + +- **Minimal dependencies** - Only install what you need +- **Flexible adapters** - Easy to add new database adapters +- **Type safety** - Full TypeScript support across all packages +- **Independent versioning** - Packages can be updated independently + +## Installation Patterns + +### Basic Setup + +```bash +npm install @vorsteh-queue/core @vorsteh-queue/adapter-drizzle +``` + +### With CLI + +```bash +npx create-vorsteh-queue my-app --template=drizzle-postgres +``` + +Choose the packages that match your project's needs and ORM preference. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx new file mode 100644 index 0000000..1595f57 --- /dev/null +++ b/apps/docs/content/docs/index.mdx @@ -0,0 +1,4 @@ +--- +title: Documentation +ignoreSearch: true +--- diff --git a/apps/docs/content/features/10.excellent-dx.mdx b/apps/docs/content/features/10.excellent-dx.mdx new file mode 100644 index 0000000..f930b53 --- /dev/null +++ b/apps/docs/content/features/10.excellent-dx.mdx @@ -0,0 +1,7 @@ +--- +type: key_feature +title: Excellent DX +icon: Zap +--- + +Intuitive API design with TypeScript support, comprehensive documentation, and helpful error messages that make development a breeze. diff --git a/apps/docs/content/features/11.orm-agnostic.mdx b/apps/docs/content/features/11.orm-agnostic.mdx new file mode 100644 index 0000000..fa6b2d7 --- /dev/null +++ b/apps/docs/content/features/11.orm-agnostic.mdx @@ -0,0 +1,7 @@ +--- +type: key_feature +title: ORM Agnostic +icon: Database +--- + +Works with Drizzle ORM and Prisma ORM for PostgreSQL. Adapter pattern allows easy integration with your existing database setup. diff --git a/apps/docs/content/features/12.production-ready.mdx b/apps/docs/content/features/12.production-ready.mdx new file mode 100644 index 0000000..8d8af46 --- /dev/null +++ b/apps/docs/content/features/12.production-ready.mdx @@ -0,0 +1,7 @@ +--- +type: key_feature +title: Production Ready +icon: Shield +--- + +Battle-tested with built-in retry logic, job cleanup, progress tracking, and graceful shutdown handling for mission-critical applications. diff --git a/apps/docs/content/features/13.event-system.mdx b/apps/docs/content/features/13.event-system.mdx new file mode 100644 index 0000000..2d0664d --- /dev/null +++ b/apps/docs/content/features/13.event-system.mdx @@ -0,0 +1,7 @@ +--- +type: key_feature +title: Event System +icon: Zap +--- + +Comprehensive event system for monitoring job lifecycle. Listen to job progress, completion, failures, and queue state changes. diff --git a/apps/docs/content/features/14.timezone.mdx b/apps/docs/content/features/14.timezone.mdx new file mode 100644 index 0000000..af01152 --- /dev/null +++ b/apps/docs/content/features/14.timezone.mdx @@ -0,0 +1,7 @@ +--- +type: key_feature +title: UTC-First Timezone +icon: Globe +--- + +Reliable timezone handling with UTC-first approach. All timestamps stored as UTC with timezone conversion at job creation time. diff --git a/apps/docs/content/features/15.type-safety.mdx b/apps/docs/content/features/15.type-safety.mdx new file mode 100644 index 0000000..87a605c --- /dev/null +++ b/apps/docs/content/features/15.type-safety.mdx @@ -0,0 +1,7 @@ +--- +type: key_feature +title: Type Safety +icon: Shield +--- + +Full TypeScript support with generic job payloads and results. Compile-time type checking ensures your jobs are properly typed and safe. diff --git a/apps/docs/content/features/20.one-time-jobs.mdx b/apps/docs/content/features/20.one-time-jobs.mdx new file mode 100644 index 0000000..9d25639 --- /dev/null +++ b/apps/docs/content/features/20.one-time-jobs.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: One-time Jobs +icon: Play +--- + +Execute tasks once with optional delays and priority levels. diff --git a/apps/docs/content/features/21.recurring-jobs.mdx b/apps/docs/content/features/21.recurring-jobs.mdx new file mode 100644 index 0000000..25cd4c5 --- /dev/null +++ b/apps/docs/content/features/21.recurring-jobs.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Recurring Jobs +icon: RotateCcw +--- + +Set up repeating tasks with flexible intervals and cron expressions. diff --git a/apps/docs/content/features/22.scheduled-jobs.mdx b/apps/docs/content/features/22.scheduled-jobs.mdx new file mode 100644 index 0000000..e7647f0 --- /dev/null +++ b/apps/docs/content/features/22.scheduled-jobs.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Scheduled Jobs +icon: Calendar +--- + +Schedule jobs for specific dates and times with UTC-first timezone conversion for reliable execution. diff --git a/apps/docs/content/features/23.retry-logic.mdx b/apps/docs/content/features/23.retry-logic.mdx new file mode 100644 index 0000000..e8780a9 --- /dev/null +++ b/apps/docs/content/features/23.retry-logic.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Retry Logic +icon: RotateCcw +--- + +Configurable retry strategies with exponential backoff and maximum attempt limits for failed jobs. diff --git a/apps/docs/content/features/24.job-delays.mdx b/apps/docs/content/features/24.job-delays.mdx new file mode 100644 index 0000000..ebeb80a --- /dev/null +++ b/apps/docs/content/features/24.job-delays.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Job Delays +icon: Clock +--- + +Delay job execution with precise timing control. diff --git a/apps/docs/content/features/25.priority-queues.mdx b/apps/docs/content/features/25.priority-queues.mdx new file mode 100644 index 0000000..c18a9d9 --- /dev/null +++ b/apps/docs/content/features/25.priority-queues.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Priority Queues +icon: ArrowUp +--- + +Process high-priority jobs first with numeric priority system (lower number = higher priority). diff --git a/apps/docs/content/features/26.job-results.mdx b/apps/docs/content/features/26.job-results.mdx new file mode 100644 index 0000000..a51238b --- /dev/null +++ b/apps/docs/content/features/26.job-results.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Job Results +icon: CheckCircle +--- + +Store and access job results returned by handlers. Results are automatically persisted and available through events. diff --git a/apps/docs/content/features/27.progress-tracking.mdx b/apps/docs/content/features/27.progress-tracking.mdx new file mode 100644 index 0000000..5cfeab1 --- /dev/null +++ b/apps/docs/content/features/27.progress-tracking.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Progress Tracking +icon: TrendingUp +--- + +Real-time job progress updates with percentage tracking. Monitor long-running tasks with built-in progress reporting. diff --git a/apps/docs/content/features/28.job-cleanup.mdx b/apps/docs/content/features/28.job-cleanup.mdx new file mode 100644 index 0000000..3070358 --- /dev/null +++ b/apps/docs/content/features/28.job-cleanup.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Job Cleanup +icon: Trash2 +--- + +Automatic cleanup of completed and failed jobs with configurable retention policies to manage database size. diff --git a/apps/docs/content/features/29.job-timeouts.mdx b/apps/docs/content/features/29.job-timeouts.mdx new file mode 100644 index 0000000..e7d3a37 --- /dev/null +++ b/apps/docs/content/features/29.job-timeouts.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Job Timeouts +icon: Clock +--- + +Configurable job timeouts to prevent long-running jobs from blocking the queue. Set per-job or global timeout limits. diff --git a/apps/docs/content/features/30.queue-stats.mdx b/apps/docs/content/features/30.queue-stats.mdx new file mode 100644 index 0000000..44bdbb4 --- /dev/null +++ b/apps/docs/content/features/30.queue-stats.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Queue Statistics +icon: BarChart3 +--- + +Get real-time queue statistics including pending, processing, completed, failed, and delayed job counts. diff --git a/apps/docs/content/features/31.graceful-shutdown.mdx b/apps/docs/content/features/31.graceful-shutdown.mdx new file mode 100644 index 0000000..6b9d905 --- /dev/null +++ b/apps/docs/content/features/31.graceful-shutdown.mdx @@ -0,0 +1,7 @@ +--- +type: feature +title: Graceful Shutdown +icon: Power +--- + +Clean job processing termination that waits for active jobs to complete before stopping the queue. diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 1957f68..96d1f5d 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -1,5 +1,5 @@ import createMDXPlugin from "@next/mdx" -import rehypeRenoun from "@renoun/mdx/rehype" +import { rehypePlugins } from "@renoun/mdx" import remarkRenounAddHeadings from "@renoun/mdx/remark/add-headings" import remarkRenounRemoveParagraphs from "@renoun/mdx/remark/remove-immediate-paragraphs" import remarkRenounRelativeLinks from "@renoun/mdx/remark/transform-relative-links" @@ -22,7 +22,7 @@ const withMDX = createMDXPlugin({ remarkRenounRelativeLinks, remarkGfm, ], - rehypePlugins: [rehypeRenoun, rehypeMdxImportMedia], + rehypePlugins: [...rehypePlugins, rehypeMdxImportMedia], }, }) diff --git a/apps/docs/package.json b/apps/docs/package.json index f258ac1..0a6e4ea 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -8,62 +8,80 @@ "clean": "git clean -xdf .cache .turbo dist node_modules .next", "clean:cache": "git clean -xdf .cache", "dev": "renoun next dev", - "format": "prettier --check . --ignore-path ../../.gitignore", + "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path ../../.prettierignore", "generate-pagefind": "pagefind --site out --output-path out/pagefind", "lint": "eslint .", "lint:links": "node --import ./src/register.js --import tsx/esm src/link-check.ts", - "start": "next start", + "matchtest": "tsx src/match.ts", + "preview": "tsx src/localserver.ts", "typecheck": "tsc --noEmit" }, "prettier": "@vorsteh-queue/prettier-config", "dependencies": { - "@getcanary/web": "1.0.12", - "@giscus/react": "3.1.0", - "@icons-pack/react-simple-icons": "13.5.0", + "@icons-pack/react-simple-icons": "13.7.0", "@mdx-js/loader": "3.1.0", "@mdx-js/node-loader": "3.1.0", "@mdx-js/react": "3.1.0", - "@next/mdx": "15.4.2", + "@next/mdx": "15.4.5", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "2.1.15", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "1.1.12", + "@radix-ui/react-tooltip": "^1.2.7", + "@vercel/og": "0.8.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", - "cmdk": "1.1.1", "date-fns": "^4.1.0", - "lucide-react": "0.525.0", - "next": "15.4.2", + "interweave": "13.1.1", + "lucide-react": "0.534.0", + "multimatch": "7.0.0", + "next": "15.4.5", "next-themes": "latest", - "react": "19.1.0", - "react-dom": "19.1.0", + "p-map": "7.0.3", + "react": "19.1.1", + "react-dom": "19.1.1", "rehype-mdx-import-media": "1.2.0", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.1", "remark-mdx-frontmatter": "5.2.0", "remark-squeeze-paragraphs": "6.0.0", "remark-strip-badges": "7.0.0", - "renoun": "8.14.0", + "renoun": "9.0.0", "tm-themes": "1.10.7", "ts-morph": "26.0.0", - "zod": "4.0.5" + "tw-animate-css": "^1.3.6", + "use-debounce": "10.0.5", + "zod": "4.0.14" }, "devDependencies": { "@tailwindcss/postcss": "4.1.11", + "@tailwindcss/typography": "0.5.16", "@types/mdx": "2.0.13", "@types/node": "22.16.5", - "@types/react": "19.1.8", - "@types/react-dom": "19.1.6", + "@types/react": "19.1.9", + "@types/react-dom": "19.1.7", + "@types/serve-handler": "6.1.4", "@vorsteh-queue/adapter-drizzle": "workspace:*", + "@vorsteh-queue/adapter-prisma": "workspace:*", "@vorsteh-queue/core": "workspace:*", "@vorsteh-queue/eslint-config": "workspace:*", "@vorsteh-queue/prettier-config": "workspace:*", "@vorsteh-queue/tsconfig": "workspace:*", - "eslint": "^9.31.0", + "eslint": "^9.32.0", "next-validate-link": "1.5.2", "pagefind": "1.3.0", "postcss": "8.5.6", "prettier": "^3.6.2", + "serve-handler": "6.1.6", "tailwind-merge": "3.3.1", "tailwindcss": "4.1.11", "tailwindcss-animate": "1.0.7", + "tsx": "4.20.3", "typescript": "^5.8.3" } } diff --git a/apps/docs/public/icons/icon-128x128.png b/apps/docs/public/icons/icon-128x128.png new file mode 100644 index 0000000..d36d2e6 Binary files /dev/null and b/apps/docs/public/icons/icon-128x128.png differ diff --git a/apps/docs/public/icons/icon-144x144.png b/apps/docs/public/icons/icon-144x144.png new file mode 100644 index 0000000..22e65d0 Binary files /dev/null and b/apps/docs/public/icons/icon-144x144.png differ diff --git a/apps/docs/public/icons/icon-152x152.png b/apps/docs/public/icons/icon-152x152.png new file mode 100644 index 0000000..6e09fbf Binary files /dev/null and b/apps/docs/public/icons/icon-152x152.png differ diff --git a/apps/docs/public/icons/icon-192x192.png b/apps/docs/public/icons/icon-192x192.png new file mode 100644 index 0000000..b949686 Binary files /dev/null and b/apps/docs/public/icons/icon-192x192.png differ diff --git a/apps/docs/public/icons/icon-256x256.png b/apps/docs/public/icons/icon-256x256.png new file mode 100644 index 0000000..c5728b4 Binary files /dev/null and b/apps/docs/public/icons/icon-256x256.png differ diff --git a/apps/docs/public/icons/icon-384x384.png b/apps/docs/public/icons/icon-384x384.png new file mode 100644 index 0000000..d7dbc32 Binary files /dev/null and b/apps/docs/public/icons/icon-384x384.png differ diff --git a/apps/docs/public/icons/icon-48x48.png b/apps/docs/public/icons/icon-48x48.png new file mode 100644 index 0000000..0b1dc07 Binary files /dev/null and b/apps/docs/public/icons/icon-48x48.png differ diff --git a/apps/docs/public/icons/icon-512x512.png b/apps/docs/public/icons/icon-512x512.png new file mode 100644 index 0000000..8ca4f44 Binary files /dev/null and b/apps/docs/public/icons/icon-512x512.png differ diff --git a/apps/docs/public/icons/icon-72x72.png b/apps/docs/public/icons/icon-72x72.png new file mode 100644 index 0000000..9f5eb1d Binary files /dev/null and b/apps/docs/public/icons/icon-72x72.png differ diff --git a/apps/docs/public/icons/icon-96x96.png b/apps/docs/public/icons/icon-96x96.png new file mode 100644 index 0000000..633e418 Binary files /dev/null and b/apps/docs/public/icons/icon-96x96.png differ diff --git a/apps/docs/public/vorsteh-queue-logo-nobg.png b/apps/docs/public/vorsteh-queue-logo-nobg.png new file mode 100644 index 0000000..54c1d56 Binary files /dev/null and b/apps/docs/public/vorsteh-queue-logo-nobg.png differ diff --git a/apps/docs/renoun.json b/apps/docs/renoun.json index 323a2b8..d042522 100644 --- a/apps/docs/renoun.json +++ b/apps/docs/renoun.json @@ -8,8 +8,8 @@ "siteUrl": "https://vorsteh-queue.dev", "theme": { - "dark": "monokai", - "light": "monokai" + "dark": "one-dark-pro", + "light": "one-dark-pro" }, "languages": [ "css", @@ -27,6 +27,7 @@ "yaml", "sql", "xml", - "docker" + "docker", + "prisma" ] } diff --git a/apps/docs/src/app/(site)/(docs)/[...slug]/page.tsx b/apps/docs/src/app/(site)/(docs)/[...slug]/page.tsx new file mode 100644 index 0000000..16a52c3 --- /dev/null +++ b/apps/docs/src/app/(site)/(docs)/[...slug]/page.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next" +import { notFound } from "next/navigation" + +import { getBreadcrumbItems, transformedEntries } from "~/collections" +import { DirectoryContent } from "~/components/directory-content" +import { FileContent } from "~/components/file-content" + +export async function generateStaticParams() { + const slugs = (await transformedEntries()).map((entry) => ({ + slug: entry.segments, + })) + + return slugs +} + +interface PageProps { + params: Promise<{ slug?: string[] }> +} + +export async function generateMetadata(props: PageProps): Promise { + const params = await props.params + const breadcrumbItems = await getBreadcrumbItems(params.slug) + + const titles = breadcrumbItems.map((ele) => ele.title) + + return { + title: `${titles.join(" - ")}`, + } +} + +export default async function DocsPage(props: PageProps) { + const params = await props.params + + const searchParam = `/${params.slug?.join("/") ?? ""}` + + const transformedEntry = (await transformedEntries()).find( + (ele) => ele.raw_pathname == searchParam, + ) + + if (!transformedEntry) { + return notFound() + } + + // if we can't find an index file, but we have a valid directory + // use the directory component for rendering + if (!transformedEntry.file && transformedEntry.isDirectory) { + return ( + <> + + + ) + } + + // if we have a valid file ( including the index file ) + // use the file component for rendering + if (transformedEntry.file) { + return ( + <> + + + ) + } + + // seems to be an invalid path + return notFound() +} diff --git a/apps/docs/src/app/(site)/(docs)/layout.tsx b/apps/docs/src/app/(site)/(docs)/layout.tsx new file mode 100644 index 0000000..3c10980 --- /dev/null +++ b/apps/docs/src/app/(site)/(docs)/layout.tsx @@ -0,0 +1,51 @@ +import type { TreeItem } from "~/lib/navigation" +import { AllDocumentation } from "~/collections" +import { DocsSidebar } from "~/components/docs-sidebar" +import { Sidebar, SidebarContent, SidebarInset, SidebarProvider } from "~/components/ui/sidebar" +import { getTree } from "~/lib/navigation" + +export function AppSidebar({ items }: { items: TreeItem[] }) { + return ( + + + + + + ) +} + +export default async function DocsLayout( + props: Readonly<{ + params: Promise<{ + slug?: string[] + }> + children: React.ReactNode + }>, +) { + const recursiveCollections = await AllDocumentation.getEntries({ + recursive: true, + }) + + // here we're generating the items for the dropdown menu in the sidebar + // it's used to provide a short link for the user to switch easily between the different collections + // it expects an `index.mdx` file in each collection at the root level ( e.g. `aria-docs/index.mdx`) + + const tree = recursiveCollections.filter((ele) => ele.getDepth() === 0) + + const sidebarItems = await getTree(tree) + + return ( +
+ + + +
+
+ {props.children} +
+
+
+
+
+ ) +} diff --git a/apps/docs/src/app/(site)/(home)/page.tsx b/apps/docs/src/app/(site)/(home)/page.tsx index 9d7d634..4c69936 100644 --- a/apps/docs/src/app/(site)/(home)/page.tsx +++ b/apps/docs/src/app/(site)/(home)/page.tsx @@ -1,25 +1,16 @@ -import { SiGithub as GithubIcon } from "@icons-pack/react-simple-icons" -import { - Calendar, - CheckCircle, - Clock, - Code, - Database, - Heart, - Info, - Play, - RotateCcw, - Settings, - Shield, - Users, - Zap, -} from "lucide-react" -import { CodeBlock } from "renoun/components" +import Link from "next/link" +import { CheckCircle, Code, Heart, Info } from "lucide-react" +import pMap from "p-map" +import { CodeBlock, GitProviderLink, GitProviderLogo } from "renoun/components" +import type { AllowedIcon } from "~/lib/icon" +import { features } from "~/collections" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" +import { asyncFilter } from "~/lib/array-helper" +import { getIcon } from "~/lib/icon" -const example_snippet = ` +const example_snippet = /* typescript */ ` import { MemoryQueueAdapter, Queue } from "@vorsteh-queue/core" interface TEmailPayload { @@ -31,7 +22,8 @@ interface TEmailResult { sent: boolean } -const queue = new Queue(new MemoryQueueAdapter(), { name: "email-queue" }) +const adapter = new MemoryQueueAdapter() +const queue = new Queue(adapter, { name: "email-queue" }) queue.register("send-email", async ({ payload }) => { // Send email logic here @@ -42,346 +34,247 @@ await queue.add("send-email", { to: "user@example.com", subject: "Welcome!" }) queue.start() ` -export default function Home() { +export default async function Home() { + const featuresCollection = await features.getEntries() + + const filterKeyFeatures = await asyncFilter(featuresCollection, async (ele) => { + const frontmatter = await ele.getExportValue("frontmatter") + + return frontmatter.type === "key_feature" + }) + + const filterOtherFeatures = await asyncFilter(featuresCollection, async (ele) => { + const frontmatter = await ele.getExportValue("frontmatter") + + return frontmatter.type === "feature" + }) + + const keyFeatures = await pMap(filterKeyFeatures, async (ele) => { + const frontmatter = await ele.getExportValue("frontmatter") + const description = await ele.getExportValue("default") + return { + title: frontmatter.title, + description, + icon: frontmatter.icon as AllowedIcon, + } + }) + + const otherFeatures = await pMap(filterOtherFeatures, async (ele) => { + const frontmatter = await ele.getExportValue("frontmatter") + const description = await ele.getExportValue("default") + return { + title: frontmatter.title, + description, + icon: frontmatter.icon as AllowedIcon, + } + }) + return ( <> {/* Hero Section */} -
+
-

+

Reliable Job Queue for Modern Applications

-

- A powerful, ORM-agnostic queue engine for PostgreSQL 12+, MariaDB, and MySQL. Handle - background jobs, scheduled tasks, and recurring processes with ease. +

+ A powerful, ORM-agnostic queue engine for PostgreSQL 12+. Handle background jobs, + scheduled tasks, and recurring processes with ease.

+
- - {/* Code Example */} - {example_snippet} -
-
-
-
-
- queue-example.ts -
+
+ {/* Code Example */} + + {example_snippet} +
{/* Why Section */} -
+
-

+

Why Choose Vorsteh Queue?

-

+

Built for developers who need reliability, flexibility, and excellent developer experience

- - -
- -
- Excellent DX -
- - - Intuitive API design with TypeScript support, comprehensive documentation, and - helpful error messages that make development a breeze. - - -
- - - -
- -
- ORM Agnostic -
- - - Works seamlessly with Prisma, Drizzle, TypeORM, or any database adapter. No vendor - lock-in, use what you already know and love. - - -
- - - -
- -
- Production Ready -
- - - Battle-tested with built-in retry logic, dead letter queues, monitoring, and - graceful shutdown handling for mission-critical applications. - - -
- - - -
- -
- - Highly Configurable - -
- - - Fine-tune every aspect from concurrency limits to retry strategies. Adapts to your - specific needs without compromising simplicity. - - -
- - - -
- -
- Scalable -
- - - Horizontal scaling support with distributed processing, load balancing, and - cluster-aware job distribution. - - -
- - - -
- -
- - Zero Dependencies - -
- - - Lightweight core with minimal dependencies. Only bring in what you need, keeping - your bundle size small and security surface minimal. - - -
+ {keyFeatures.map((ele, eleIdx) => ( + + +
+ {getIcon(ele.icon, { className: "text-orange-primary h-6 w-6" })} +
+ {ele.title} +
+ + + + + +
+ ))}
{/* Features Section */} -
+
-

+

Powerful Features

-

+

Everything you need to handle background processing in your applications

-
-
-
- -
-

One-time Jobs

-

- Execute tasks once with optional delays and priority levels -

-
- -
-
- -
-

- Recurring Jobs -

-

- Set up repeating tasks with flexible intervals and cron expressions -

-
- -
-
- -
-

- Scheduled Jobs -

-

- Schedule jobs for specific dates and times with timezone support -

-
- -
-
- -
-

Retry Logic

-

- Configurable retry strategies with exponential backoff and limits -

-
- -
-
- -
-

Job Delays

-

- Delay job execution with precise timing control -

-
- -
-
- -
-

- Priority Queues -

-

- Process high-priority jobs first with customizable priority levels -

-
- -
-
- -
-

- Dead Letter Queue -

-

- Handle failed jobs with dedicated error queues and analysis -

-
- -
-
- +
+ {otherFeatures.map((ele, eleIdx) => ( +
+
+ {getIcon(ele.icon, { className: "text-orange-primary h-6 w-6" })} +
+

{ele.title}

+
+ +
-

- Real-time Monitoring -

-

- Monitor job status, performance metrics, and queue health -

-
+ ))}
{/* About Section */} -
+
-
- +
+
-

+

About the Name: Vorsteh Queue

-

+

The name "Vorsteh Queue" is a tribute to our beloved German Spaniel. This breed is closely related to the Münsterländer, a type of pointing dog, known in German as a "Vorstehhund". Just as a pointing dog steadfastly indicates its target, Vorsteh Queue aims to reliably point your application towards efficient and robust background job processing.

-

+

The inspiration for naming a tech project after a dog comes from the delightful story - of Bruno, the API client. It's a nod to the personal touch and passion that drives - open-source development, much like the loyalty and dedication of our canine - companions. + of{" "} + + Bruno + + , the API client. It's a nod to the personal touch and passion that drives open-source + development, much like the loyalty and dedication of our canine companions.

{/* Open Source Section */} -
+
-
- +
+
-

+

Free & Open Source

-

+

Vorsteh Queue is completely free and open source. Built by developers, for developers. No hidden costs, no vendor lock-in, no limitations. Use it in your personal projects, startups, or enterprise applications.

-
+
MIT License
-
+
Community Driven
-
+
No Vendor Lock-in
- +
- - {/* Footer */} ) } diff --git a/apps/docs/src/app/(site)/layout.tsx b/apps/docs/src/app/(site)/layout.tsx index cfd912c..d468a58 100644 --- a/apps/docs/src/app/(site)/layout.tsx +++ b/apps/docs/src/app/(site)/layout.tsx @@ -3,7 +3,7 @@ import MainHeader from "~/components/main-header" export default function SiteLayout({ children }: { children: React.ReactNode }) { return ( -
+
{/* Header */} {children} diff --git a/apps/docs/src/app/_opengraph-image.js b/apps/docs/src/app/_opengraph-image.js new file mode 100644 index 0000000..8c518b3 --- /dev/null +++ b/apps/docs/src/app/_opengraph-image.js @@ -0,0 +1,101 @@ +import { ImageResponse } from "next/og" + +export const dynamic = "force-static" +export const contentType = "image/png" + +// eslint-disable-next-line @typescript-eslint/require-await +export default async function Image() { + return new ImageResponse( + ( +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ Vorsteh Queue +
+
+
+ A powerful, ORM-agnostic queue engine for PostgreSQL 12+. +
+
+
+
+ ), + { + width: 1200, + height: 630, + }, + ) +} diff --git a/apps/docs/src/app/globals.css b/apps/docs/src/app/globals.css index 6b410eb..ad5e2b1 100644 --- a/apps/docs/src/app/globals.css +++ b/apps/docs/src/app/globals.css @@ -1,4 +1,6 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:is(.dark *)); @@ -112,6 +114,40 @@ ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--border, currentColor); + } + * { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + html { + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + font-variation-settings: normal; + scroll-behavior: smooth; + height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-tap-highlight-color: transparent; + } + body { + background-color: var(--bg); + color: var(--fg); + } + ::-webkit-scrollbar { + width: 4px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } } @layer base { @@ -156,7 +192,7 @@ --card: 28 48% 7%; /* dark-200 */ --card-foreground: 40 75% 98%; /* dark-900 */ - --popover: 28 48% 7%; /* dark-200 */ + --popover: 28 48% 17%; /* dark-200 */ --popover-foreground: 40 75% 98%; /* dark-900 */ --primary: 12 75% 36%; /* orange-accessible */ @@ -179,3 +215,77 @@ --ring: 12 75% 36%; /* orange-accessible */ } } + +pre > code { + @apply !w-fit !pb-3; +} + +@utility prose { + blockquote { + /* Remove extra quotes */ + p { + &:first-of-type::before, + &:last-of-type::after { + display: none; + } + } + } +} + +/* + ---break--- +*/ + +:root { + --sidebar: 40 75% 98%; /* cream-50 */ + --sidebar-foreground: 28 55% 19%; /* fur-500 */ + --sidebar-primary: 12 75% 31%; /* orange-darker */ + --sidebar-primary-foreground: 40 75% 98%; /* cream-50 */ + --sidebar-accent: 40 38% 85%; /* cream-200 */ + --sidebar-accent-foreground: 28 55% 19%; /* fur-500 */ + --sidebar-border: 40 38% 85%; /* cream-200 */ + --sidebar-ring: 12 75% 31%; /* orange-darker */ +} + +/* + ---break--- +*/ + +.dark { + --sidebar: 28 48% 7%; /* dark-200 */ + --sidebar-foreground: 40 75% 98%; /* dark-900 */ + --sidebar-primary: 12 75% 36%; /* orange-accessible */ + --sidebar-primary-foreground: 40 75% 98%; /* dark-900 */ + --sidebar-accent: 28 48% 12%; /* dark-100 */ + --sidebar-accent-foreground: 40 75% 98%; /* dark-900 */ + --sidebar-border: 28 48% 12%; /* dark-100 */ + --sidebar-ring: 12 75% 36%; /* orange-accessible */ +} + +/* + ---break--- +*/ + +@theme inline { + --color-sidebar: var(--muted); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--primary); + --color-sidebar-primary-foreground: var(--primary-foreground); + --color-sidebar-accent: var(--accent); + --color-sidebar-accent-foreground: var(--accent-foreground); + --color-sidebar-border: var(--border); + --color-sidebar-ring: var(--ring); +} + +/* + ---break--- +*/ + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/docs/src/app/layout.tsx b/apps/docs/src/app/layout.tsx index 510fd6d..0a7f63d 100644 --- a/apps/docs/src/app/layout.tsx +++ b/apps/docs/src/app/layout.tsx @@ -20,7 +20,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) diff --git a/apps/docs/src/app/manifest.ts b/apps/docs/src/app/manifest.ts new file mode 100644 index 0000000..4c2f877 --- /dev/null +++ b/apps/docs/src/app/manifest.ts @@ -0,0 +1,73 @@ +import type { MetadataRoute } from "next" + +export const dynamic = "force-static" + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Vorsteh Queue - Reliable Job Queue for Modern Applications", + short_name: "Vorsteh Queue", + description: + "A powerful, ORM-agnostic queue engine for PostgreSQL 12+, MariaDB, and MySQL. Handle background jobs, scheduled tasks, and recurring processes with ease.", + start_url: "/", + display: "standalone", + background_color: "#f8f4e6", + theme_color: "#f8f4e6", + icons: [ + { + src: "/favicon.ico", + sizes: "any", + type: "image/x-icon", + }, + { + src: "icons/icon-48x48.png", + sizes: "48x48", + type: "image/png", + }, + { + src: "icons/icon-72x72.png", + sizes: "72x72", + type: "image/png", + }, + { + src: "icons/icon-96x96.png", + sizes: "96x96", + type: "image/png", + }, + { + src: "icons/icon-128x128.png", + sizes: "128x128", + type: "image/png", + }, + { + src: "icons/icon-144x144.png", + sizes: "144x144", + type: "image/png", + }, + { + src: "icons/icon-152x152.png", + sizes: "152x152", + type: "image/png", + }, + { + src: "icons/icon-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "icons/icon-256x256.png", + sizes: "256x256", + type: "image/png", + }, + { + src: "icons/icon-384x384.png", + sizes: "384x384", + type: "image/png", + }, + { + src: "icons/icon-512x512.png", + sizes: "512x512", + type: "image/png", + }, + ], + } +} diff --git a/apps/docs/src/app/opengraph-image.png b/apps/docs/src/app/opengraph-image.png new file mode 100644 index 0000000..30d7c96 Binary files /dev/null and b/apps/docs/src/app/opengraph-image.png differ diff --git a/apps/docs/src/app/robots.ts b/apps/docs/src/app/robots.ts new file mode 100644 index 0000000..827f19e --- /dev/null +++ b/apps/docs/src/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next" + +export const dynamic = "force-static" + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: "https://vorsteh-queue.dev/sitemap.xml", + } +} diff --git a/apps/docs/src/app/sitemap.ts b/apps/docs/src/app/sitemap.ts new file mode 100644 index 0000000..66920cf --- /dev/null +++ b/apps/docs/src/app/sitemap.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from "next" + +import { transformedEntries } from "~/collections" + +export const dynamic = "force-static" + +export default async function sitemap(): Promise { + const links = await transformedEntries() + + return links.map((link) => { + return { + url: `https://vorsteh-queue.dev${link.raw_pathname}`, + lastModified: new Date(), + } + }) +} diff --git a/apps/docs/src/collections.ts b/apps/docs/src/collections.ts index b2b9215..34afecb 100644 --- a/apps/docs/src/collections.ts +++ b/apps/docs/src/collections.ts @@ -1,133 +1,273 @@ -// import type { z } from "zod" -// import { EntryGroup, isDirectory, isFile } from "renoun/file-system" - -// import type { frontmatterSchema } from "./validations" -// import { removeFromArray } from "./lib/utils" -// import { generateDirectories } from "./sources" - -// export const DocumentationGroup = new EntryGroup({ -// entries: [...generateDirectories()], -// }) - -// export type EntryType = Awaited> -// export type DirectoryType = Awaited> - -// /** -// * Helper function to get the title for an element in the sidebar/navigation -// * @param collection {EntryType} the collection to get the title for -// * @param frontmatter {z.infer} the frontmatter to get the title from -// * @param includeTitle? {boolean} whether to include the title in the returned string -// * @returns {string} the title to be displayed in the sidebar/navigation -// */ -// export function getTitle( -// collection: EntryType, -// frontmatter: z.infer, -// includeTitle = false, -// ): string { -// return includeTitle -// ? (frontmatter.navTitle ?? frontmatter.title ?? collection.getTitle()) -// : (frontmatter.navTitle ?? collection.getTitle()) -// } - -// /** -// * Helper function to get the file content for a given source entry -// * This function will try to get the file based on the given path and the "mdx" extension -// * If the file is not found, it will try to get the index file based on the given path and the "mdx" extension -// * If there is also no index file, it will return null -// * -// * @param source {EntryType} the source entry to get the file content for -// */ -// export const getFileContent = async (source: EntryType) => { -// // first, try to get the file based on the given path - -// return await DocumentationGroup.getFile(source.getPathSegments(), "mdx").catch(async () => { -// return await DocumentationGroup.getFile([...source.getPathSegments(), "index"], "mdx").catch( -// () => null, -// ) -// }) -// } - -// /** -// * Helper function to get the sections for a given source entry -// * This function will try to get the sections based on the given path -// * -// * If there there are no entries/children for the current path, it will return an empty array -// * -// * @param source {EntryType} the source entry to get the sections for -// * @returns -// */ -// export async function getSections(source: EntryType) { -// if (source.getDepth() > -1) { -// if (isDirectory(source)) { -// return ( -// await (await DocumentationGroup.getDirectory(source.getPathSegments())).getEntries() -// ).filter((ele) => ele.getPath() !== source.getPath()) -// } - -// if (isFile(source) && source.getBaseName() === "index") { -// return await source.getParent().getEntries() -// } -// return [] -// } else { -// return ( -// await (await DocumentationGroup.getDirectory(source.getPathSegments())).getEntries() -// ).filter((ele) => ele.getPath() !== source.getPath()) -// } -// } - -// /** -// * Helper function to get the breadcrumb items for a given slug -// * -// * @param slug {string[]} the slug to get the breadcrumb items for -// */ -// export const getBreadcrumbItems = async (slug: string[]) => { -// // we do not want to have "index" as breadcrumb element -// const cleanedSlug = removeFromArray(slug, ["index"]) - -// const combinations = cleanedSlug.map((_, index) => cleanedSlug.slice(0, index + 1)) - -// const items = [] - -// for (const currentPageSegement of combinations) { -// let collection: EntryType -// let file: Awaited> -// let frontmatter: z.infer | undefined -// try { -// collection = await DocumentationGroup.getEntry(currentPageSegement) -// if (collection.getPathSegments().includes("index")) { -// file = await getFileContent(collection.getParent()) -// } else { -// file = await getFileContent(collection) -// } - -// frontmatter = await file?.getExportValue("frontmatter") -// // eslint-disable-next-line @typescript-eslint/no-unused-vars -// } catch (e: unknown) { -// continue -// } - -// if (!frontmatter) { -// items.push({ -// title: collection.getTitle(), -// path: ["docs", ...collection.getPathSegments()], -// }) -// } else { -// const title = getTitle(collection, frontmatter, true) -// items.push({ -// title, -// path: ["docs", ...removeFromArray(collection.getPathSegments(), ["index"])], -// }) -// } -// } - -// return items -// } - -// /** -// * Checks if an entry is hidden (starts with an underscore) -// * -// * @param entry {EntryType} the entry to check for visibility -// */ -// export function isHidden(entry: EntryType) { -// return entry.getBaseName().startsWith("_") -// } +// import type { FileSystemEntry } from "renoun/file-system" +import type { z } from "zod" +import { cache } from "react" +import { Collection, Directory, isDirectory, isFile, withSchema } from "renoun/file-system" + +import type { frontmatterSchema } from "./validations" +import { removeFromArray } from "./lib/utils" +import { docSchema, featuresSchema } from "./validations" + +export const features = new Directory({ + path: "content/features", + include: "*.mdx", + + loader: { + mdx: withSchema( + { frontmatter: featuresSchema }, + (path) => import(`../content/features/${path}.mdx`), + ), + }, +}) + +export const DocumentationDirectory = new Directory({ + path: `content/docs`, + basePathname: "docs", + // hide hidden files ( starts with `_` ) and all asset directories ( `_assets` ) + include: (entry) => + !entry.getBaseName().startsWith("_") && !entry.getAbsolutePath().includes("_assets"), + loader: { + mdx: withSchema(docSchema, (path) => import(`../content/docs/${path}.mdx`)), + }, +}) + +export const ExampleDirectory = new Directory({ + path: `../../examples`, + basePathname: "docs/examples", + include: (entry) => + !entry.getBaseName().startsWith("_") && + !entry.getAbsolutePath().includes("_assets") && + // do not fetch all files in the example + // `depth == 0` - include the root `examples/readme.mdx` + // `depth == 1` - include only the `examples//readme.mdx + (entry.getDepth() == 1 || entry.getDepth() == 0) && + (isDirectory(entry) || isFile(entry, "mdx")), + + loader: { + mdx: withSchema(docSchema, (path) => import(`../../../examples/${path}.mdx`)), + }, +}) + +export const AllDocumentation = new Collection({ + entries: [DocumentationDirectory, ExampleDirectory], +}) + +export type EntryType = Awaited> +export type DirectoryType = Awaited> + +/** + * Helper function to get the title for an element in the sidebar/navigation + * @param entry {EntryType} the entry to get the title for + * @param frontmatter {z.infer} the frontmatter to get the title from + * @param includeTitle? {boolean} whether to include the title in the returned string + * @returns {string} the title to be displayed in the sidebar/navigation + */ +export function getTitle( + entry: EntryType, + frontmatter: z.infer, + includeTitle = false, +): string { + return includeTitle + ? (frontmatter.navTitle ?? frontmatter.title ?? entry.getTitle()) + : (frontmatter.navTitle ?? entry.getTitle()) +} + +/** + * Helper function to get the sections for a given source entry + * This function will try to get the sections based on the given path + * + * If there there are no entries/children for the current path, it will return an empty array + * + * @param source {EntryType} the source entry to get the sections for + * @returns + */ +export async function getSections(source: EntryType) { + if (source.getDepth() > -1) { + if (isDirectory(source)) { + const parent = await (await getDirectory(source)).getEntries() + return await Promise.all(parent.map(async (ele) => await getTransformedEntry(ele))) + } + + if (isFile(source) && source.getBaseName() === "index") { + const parent = await (await getDirectory(source.getParent())).getEntries() + return await Promise.all(parent.map(async (ele) => await getTransformedEntry(ele))) + } + return [] + } else { + const parent = await (await getDirectory(source)).getEntries() + return await Promise.all(parent.map(async (ele) => await getTransformedEntry(ele))) + } +} + +/** + * Helper function to get the breadcrumb items for a given slug + * + * @param slug {string[]} the slug to get the breadcrumb items for + */ +export const getBreadcrumbItems = async (slug: string[] = []) => { + // we do not want to have "index" as breadcrumb element + const cleanedSlug = removeFromArray(slug, ["index"]) + + const combinations = cleanedSlug.map((_, index) => cleanedSlug.slice(0, index + 1)) + + const items = [] + + for (const currentPageSegement of combinations) { + const entry = (await transformedEntries()).find( + (ele) => ele.raw_pathname === `/${currentPageSegement.join("/")}`, + ) + + if (!entry) { + continue + } + + items.push({ + title: entry.title, + path: entry.segments, + }) + } + + return items +} + +/** + * Checks if an entry is hidden (starts with an underscore) + * + * @param entry {EntryType} the entry to check for visibility + */ +export function isHidden(entry: EntryType) { + return entry.getBaseName().startsWith("_") +} + +/** + * Gets a file from the documentation collection based on the source entry + * Attempts to find the file in the following order: + * 1. Direct segment file + * 2. Index file in the segment directory + * 3. Readme file in the segment directory + * + * Handles special case for examples by excluding "docs" segment from the path + * + * @param source {EntryType} The source entry to get the file for + * @returns The found file or null if no file exists + */ + +export async function getFile(source: EntryType) { + const segments = source.getPathnameSegments({ + includeBasePathname: true, + includeDirectoryNamedSegment: true, + }) + + const excludeSegments = segments[1] === "examples" ? ["docs"] : [] + + const [segmentFile, indexFile, readmeFile] = await Promise.all([ + AllDocumentation.getFile(removeFromArray(segments, excludeSegments), "mdx").catch(() => null), + AllDocumentation.getFile(removeFromArray([...segments, "index"], excludeSegments), "mdx").catch( + () => null, + ), + AllDocumentation.getFile( + removeFromArray([...segments, "readme"], excludeSegments), + "mdx", + ).catch(() => null), + ]) + + return segmentFile ?? indexFile ?? readmeFile ?? null +} + +/** + * Gets a directory from the documentation collection based on the source entry + * Handles special case for examples by excluding "docs" segment from the path + * + * @param source {EntryType} The source entry to get the directory for + * @returns The directory corresponding to the source entry + */ +export async function getDirectory(source: EntryType) { + const segments = source.getPathnameSegments({ + includeBasePathname: true, + includeDirectoryNamedSegment: true, + }) + + const excludeSegments = segments[1] === "examples" ? ["docs"] : [] + + const currentDirectory = await AllDocumentation.getDirectory( + removeFromArray(segments, excludeSegments), + ) + + return currentDirectory +} + +/** + * Retrieves the frontmatter metadata from a documentation file + * @param source {Awaited>} The file to get metadata from + * @returns The frontmatter metadata if it exists + */ +export async function getMetadata(source: Awaited>) { + return await source?.getExportValue("frontmatter") +} + +/** + * Gets the previous and next entries relative to the current entry in the documentation + * Returns a tuple containing [previousEntry, nextEntry] where either can be undefined + * if at the start/end of the documentation + * + * @param source {Awaited>} The current entry to get siblings for + * @returns Tuple of previous and next entries + */ +export async function getSiblings( + source: Awaited>, +): Promise< + [ + Awaited> | undefined, + Awaited> | undefined, + ] +> { + const entries = await transformedEntries() + + const arrayUniqueByKey = [...new Map(entries.map((item) => [item.raw_pathname, item])).values()] + + const currentIndex = arrayUniqueByKey.findIndex((ele) => ele.raw_pathname === source.raw_pathname) + + const previousElement = currentIndex > 0 ? arrayUniqueByKey[currentIndex - 1] : undefined + + const nextElement = + currentIndex < arrayUniqueByKey.length - 1 ? arrayUniqueByKey[currentIndex + 1] : undefined + + return [previousElement, nextElement] +} + +/** + * Transforms a FileSystemEntry into a standardized object containing key information + * + * @param source {EntryType} The file system entry to transform + */ +export async function getTransformedEntry(source: EntryType) { + const file = await getFile(source) + const metadata = file ? await getMetadata(file) : null + + return { + raw_pathname: source.getPathname({ includeBasePathname: true }), + pathname: source.getPathname({ includeBasePathname: false }), + segments: source.getPathnameSegments({ includeBasePathname: true }), + title: metadata ? getTitle(source, metadata, true) : source.getTitle(), + path: source.getAbsolutePath(), + entry: source, + file, + isDirectory: isDirectory(source), + } +} + +/** + * Caches and returns an array of transformed entries from the AllDocumentation collection + * Recursively gets all entries including index and readme files and transforms them + */ +export const transformedEntries = cache(async () => { + const entries = await AllDocumentation.getEntries({ + recursive: true, + includeIndexAndReadmeFiles: true, + }) + + return await Promise.all( + entries.map(async (doc) => { + return await getTransformedEntry(doc) + }), + ) +}) diff --git a/apps/docs/src/components/breadcrumb.tsx b/apps/docs/src/components/breadcrumb.tsx new file mode 100644 index 0000000..6534596 --- /dev/null +++ b/apps/docs/src/components/breadcrumb.tsx @@ -0,0 +1,111 @@ +import { Fragment } from "react" +import Link from "next/link" + +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { removeFromArray } from "~/lib/utils" + +interface Item { + title: string + path: string[] +} + +type ElementItem = { type: "element" } & Item + +interface GroupItem { + type: "group" + items: Item[] +} + +function groupBreadcrumb(input: Item[]): (ElementItem | GroupItem)[] { + if (input.length <= 3) { + return input.map((item) => ({ + type: "element", + title: item.title, + path: ["docs", ...removeFromArray(item.path, ["docs"])], + })) + } + + const groupItems = input.slice(1, -2) + const restItems = input + .slice(input.length - 2) + .map((item) => ({ type: "element", ...item })) as ElementItem[] + return [ + { type: "element", ...input[0] } as ElementItem, + { type: "group", items: groupItems.reverse() } as GroupItem, + ...restItems, + ] +} + +export function SiteBreadcrumb({ items }: { items: { title: string; path: string[] }[] }) { + const breadcrumbItems = groupBreadcrumb(items) + const searchBreadcrumb = breadcrumbItems + .slice(1) + .map((item) => { + if (item.type === "group") { + return item.items.map((ele) => ele.title) + } + + return item.title + }) + .flat() + .join(" / ") + + return ( + + + {breadcrumbItems.map((item, idx) => { + return ( + + {idx > 0 && } + {item.type == "element" && ( + + + + {item.title} + + + + )} + {item.type == "group" && ( + + + + + Toggle menu + + + {item.items.map((subItem, idy) => ( + + + {subItem.title} + + + ))} + + + + )} + + ) + })} + + + ) +} diff --git a/apps/docs/src/components/client-only.tsx b/apps/docs/src/components/client-only.tsx new file mode 100644 index 0000000..50d8fea --- /dev/null +++ b/apps/docs/src/components/client-only.tsx @@ -0,0 +1,25 @@ +"use client" + +import type { ReactNode } from "react" +import { createElement, Fragment, useEffect, useState } from "react" + +export const ClientOnly = ({ children }: { children: ReactNode }) => { + const hasMounted = useClientOnly() + + if (!hasMounted) { + return null + } + + return createElement(Fragment, { children }) +} + +/** React hook that returns true if the component has mounted client-side */ +export const useClientOnly = () => { + const [hasMounted, setHasMounted] = useState(false) + + useEffect(() => { + setHasMounted(true) + }, []) + + return hasMounted +} diff --git a/apps/docs/src/components/cmdk/command-score.ts b/apps/docs/src/components/cmdk/command-score.ts new file mode 100644 index 0000000..bc47400 --- /dev/null +++ b/apps/docs/src/components/cmdk/command-score.ts @@ -0,0 +1,172 @@ +// The scores are arranged so that a continuous match of characters will +// result in a total score of 1. +// +// The best case, this character is a match, and either this is the start +// of the string, or the previous character was also a match. +const SCORE_CONTINUE_MATCH = 1, + // A new match at the start of a word scores better than a new match + // elsewhere as it's more likely that the user will type the starts + // of fragments. + // NOTE: We score word jumps between spaces slightly higher than slashes, brackets + // hyphens, etc. + SCORE_SPACE_WORD_JUMP = 0.9, + SCORE_NON_SPACE_WORD_JUMP = 0.8, + // Any other match isn't ideal, but we include it for completeness. + SCORE_CHARACTER_JUMP = 0.17, + // If the user transposed two letters, it should be significantly penalized. + // + // i.e. "ouch" is more likely than "curtain" when "uc" is typed. + SCORE_TRANSPOSITION = 0.1, + // The goodness of a match should decay slightly with each missing + // character. + // + // i.e. "bad" is more likely than "bard" when "bd" is typed. + // + // This will not change the order of suggestions based on SCORE_* until + // 100 characters are inserted between matches. + PENALTY_SKIPPED = 0.999, + // The goodness of an exact-case match should be higher than a + // case-insensitive match by a small amount. + // + // i.e. "HTML" is more likely than "haml" when "HM" is typed. + // + // This will not change the order of suggestions based on SCORE_* until + // 1000 characters are inserted between matches. + PENALTY_CASE_MISMATCH = 0.9999, + // If the word has more characters than the user typed, it should + // be penalised slightly. + // + // i.e. "html" is more likely than "html5" if I type "html". + // + // However, it may well be the case that there's a sensible secondary + // ordering (like alphabetical) that it makes sense to rely on when + // there are many prefix matches, so we don't make the penalty increase + // with the number of tokens. + PENALTY_NOT_COMPLETE = 0.99 + +// eslint-disable-next-line no-useless-escape +const IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/, + // eslint-disable-next-line no-useless-escape + COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g, + IS_SPACE_REGEXP = /[\s-]/, + COUNT_SPACE_REGEXP = /[\s-]/g + +function commandScoreInner( + string: string, + abbreviation: string, + lowerString: string, + lowerAbbreviation: string, + stringIndex: number, + abbreviationIndex: number, + memoizedResults: Record, +) { + if (abbreviationIndex === abbreviation.length) { + if (stringIndex === string.length) { + return SCORE_CONTINUE_MATCH + } + return PENALTY_NOT_COMPLETE + } + + const memoizeKey = `${stringIndex},${abbreviationIndex}` + if (memoizedResults[memoizeKey] !== undefined) { + return memoizedResults[memoizeKey] + } + + const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex) + let index = lowerString.indexOf(abbreviationChar, stringIndex) + let highScore = 0 + + let score, transposedScore, wordBreaks, spaceBreaks + + while (index >= 0) { + score = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 1, + memoizedResults, + ) + if (score > highScore) { + if (index === stringIndex) { + score *= SCORE_CONTINUE_MATCH + } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_NON_SPACE_WORD_JUMP + wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP) + if (wordBreaks && stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length) + } + } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_SPACE_WORD_JUMP + spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP) + if (spaceBreaks && stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length) + } + } else { + score *= SCORE_CHARACTER_JUMP + if (stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, index - stringIndex) + } + } + + if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { + score *= PENALTY_CASE_MISMATCH + } + } + + if ( + (score < SCORE_TRANSPOSITION && + lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) || + (lowerAbbreviation.charAt(abbreviationIndex + 1) === + lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 + lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) + ) { + transposedScore = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 2, + memoizedResults, + ) + + if (transposedScore * SCORE_TRANSPOSITION > score) { + score = transposedScore * SCORE_TRANSPOSITION + } + } + + if (score > highScore) { + highScore = score + } + + index = lowerString.indexOf(abbreviationChar, index + 1) + } + + memoizedResults[memoizeKey] = highScore + return highScore +} + +function formatInput(string: string) { + // convert all valid space characters to space so they match each other + return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " ") +} + +export function commandScore(string: string, abbreviation: string, aliases: string[]): number { + /* NOTE: + * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() + * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. + */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + string = aliases && aliases.length > 0 ? `${string + " " + aliases.join(" ")}` : string + return commandScoreInner( + string, + abbreviation, + formatInput(string), + formatInput(abbreviation), + 0, + 0, + {}, + ) +} diff --git a/apps/docs/src/components/cmdk/index.tsx b/apps/docs/src/components/cmdk/index.tsx new file mode 100644 index 0000000..68c6019 --- /dev/null +++ b/apps/docs/src/components/cmdk/index.tsx @@ -0,0 +1,1192 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* eslint-disable react-hooks/exhaustive-deps */ +"use client" + +import * as React from "react" +import { composeRefs } from "@radix-ui/react-compose-refs" +import * as RadixDialog from "@radix-ui/react-dialog" +import { useId } from "@radix-ui/react-id" +import { Primitive } from "@radix-ui/react-primitive" + +import { commandScore } from "./command-score" + +interface Children { + children?: React.ReactNode +} +type DivProps = React.ComponentPropsWithoutRef + +type LoadingProps = Children & + DivProps & { + /** Estimated progress of loading asynchronous options. */ + progress?: number + /** + * Accessible label for this loading progressbar. Not shown visibly. + */ + label?: string + } + +type EmptyProps = Children & DivProps & {} +type SeparatorProps = DivProps & { + /** Whether this separator should always be rendered. Useful if you disable automatic filtering. */ + alwaysRender?: boolean +} +type DialogProps = RadixDialog.DialogProps & + CommandProps & { + /** Provide a className to the Dialog overlay. */ + overlayClassName?: string + /** Provide a className to the Dialog content. */ + contentClassName?: string + /** Provide a custom element the Dialog should portal into. */ + container?: HTMLElement + } +type ListProps = Children & + DivProps & { + /** + * Accessible label for this List of suggestions. Not shown visibly. + */ + label?: string + } +type ItemProps = Children & + Omit & { + /** Whether this item is currently disabled. */ + disabled?: boolean + /** Event handler for when this item is selected, either via click or keyboard selection. */ + onSelect?: (value: string) => void + /** + * A unique value for this item. + * If no value is provided, it will be inferred from `children` or the rendered `textContent`. If your `textContent` changes between renders, you _must_ provide a stable, unique `value`. + */ + value?: string + /** Optional keywords to match against when filtering. */ + keywords?: string[] + /** Whether this item is forcibly rendered regardless of filtering. */ + forceMount?: boolean + } +type GroupProps = Children & + Omit & { + /** Optional heading to render for this group. */ + heading?: React.ReactNode + /** If no heading is provided, you must provide a value that is unique for this group. */ + value?: string + /** Whether this group is forcibly rendered regardless of filtering. */ + forceMount?: boolean + } +type InputProps = Omit< + React.ComponentPropsWithoutRef, + "value" | "onChange" | "type" +> & { + /** + * Optional controlled state for the value of the search input. + */ + value?: string + /** + * Event handler called when the search value changes. + */ + onValueChange?: (search: string) => void +} +type CommandFilter = (value: string, search: string, keywords?: string[]) => number +type CommandProps = Children & + DivProps & { + /** + * Accessible label for this command menu. Not shown visibly. + */ + label?: string + /** + * Optionally set to `false` to turn off the automatic filtering and sorting. + * If `false`, you must conditionally render valid items based on the search query yourself. + */ + shouldFilter?: boolean + /** + * Custom filter function for whether each command menu item should matches the given search query. + * It should return a number between 0 and 1, with 1 being the best match and 0 being hidden entirely. + * By default, uses the `command-score` library. + */ + filter?: CommandFilter + /** + * Optional default item value when it is initially rendered. + */ + defaultValue?: string + /** + * Optional controlled state of the selected command menu item. + */ + value?: string + /** + * Event handler called when the selected item of the menu changes. + */ + onValueChange?: (value: string) => void + /** + * Optionally set to `true` to turn on looping around when using the arrow keys. + */ + loop?: boolean + /** + * Optionally set to `true` to disable selection via pointer events. + */ + disablePointerSelection?: boolean + /** + * Set to `false` to disable ctrl+n/j/p/k shortcuts. Defaults to `true`. + */ + vimBindings?: boolean + + /** + * Set to `true` to enable async mode. + */ + async?: boolean + /** + * Set to `true` to indicate that the command menu is currently fetching options. + */ + fetchInProgress?: boolean + } + +interface Context { + value: (id: string, value: string, keywords?: string[]) => void + item: (id: string, groupId: string) => () => void + group: (id: string) => () => void + filter: () => boolean + label: string + getDisablePointerSelection: () => boolean + // Ids + listId: string + labelId: string + inputId: string + // Refs + listInnerRef: React.RefObject +} +interface State { + search: string + value: string + selectedItemId?: string + filtered: { count: number; items: Map; groups: Set } +} +interface Store { + subscribe: (callback: () => void) => () => void + snapshot: () => State + + setState: (key: K, value: State[K], opts?: any) => void + emit: () => void +} +interface Group { + id: string + forceMount?: boolean +} + +const GROUP_SELECTOR = `[cmdk-group=""]` +const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]` +const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]` +const ITEM_SELECTOR = `[cmdk-item=""]` +const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])` +const SELECT_EVENT = `cmdk-item-select` +const VALUE_ATTR = `data-value` +const defaultFilter: CommandFilter = (value, search, keywords) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + commandScore(value, search, keywords!) + +// @ts-expect-error use default from cmdk +const CommandContext = React.createContext(undefined) +const useCommand = () => React.useContext(CommandContext) +// @ts-expect-error use default from cmdk +const StoreContext = React.createContext(undefined) +const useStore = () => React.useContext(StoreContext) +// @ts-expect-error use default from cmdk +const GroupContext = React.createContext(undefined) + +const Command = React.forwardRef((props, forwardedRef) => { + const state = useLazyRef(() => ({ + /** Value of the search query. */ + search: "", + /** Currently selected item value. */ + value: props.value ?? props.defaultValue ?? "", + /** Currently selected item id. */ + selectedItemId: undefined, + filtered: { + /** The count of all visible items. */ + count: 0, + /** Map from visible item id to its search score. */ + items: new Map(), + /** Set of groups with at least one visible item. */ + groups: new Set(), + }, + })) + const allItems = useLazyRef>(() => new Set()) // [...itemIds] + const allGroups = useLazyRef>>(() => new Map()) // groupId → [...itemIds] + const ids = useLazyRef>(() => new Map()) // id → { value, keywords } + const listeners = useLazyRef void>>(() => new Set()) // [...rerenders] + const propsRef = useAsRef({ ...props, shouldFilter: props.shouldFilter ?? false }) + const { label, value, vimBindings = true, async = false, fetchInProgress = false, ...etc } = props + + const listId = useId() + const labelId = useId() + const inputId = useId() + + const listInnerRef = React.useRef(null) + + const schedule = useScheduleLayoutEffect() + + /** Controlled mode `value` handling. */ + useLayoutEffect(() => { + if (value !== undefined) { + const v = value.trim() + state.current.value = v + store.emit() + } + }, [value]) + + useLayoutEffect(() => { + if (async && !fetchInProgress) { + schedule(1, selectFirstItem) + } + }, [async, fetchInProgress]) + + useLayoutEffect(() => { + schedule(6, scrollSelectedIntoView) + }, []) + + const store: Store = React.useMemo(() => { + return { + subscribe: (cb) => { + listeners.current.add(cb) + return () => listeners.current.delete(cb) + }, + snapshot: () => { + return state.current + }, + setState: (key, value, opts) => { + if (Object.is(state.current[key], value)) return + state.current[key] = value + + if (key === "search") { + // Filter synchronously before emitting back to children + filterItems() + sort() + if (!async) { + schedule(1, selectFirstItem) + } + } else if (key === "value") { + // Force focus input or root so accessibility works + if ( + // @ts-expect-error use default from cmdk + document.activeElement.hasAttribute("cmdk-input") || + // @ts-expect-error use default from cmdk + document.activeElement.hasAttribute("cmdk-root") + ) { + const input = document.getElementById(inputId) + if (input) input.focus() + else document.getElementById(listId)?.focus() + } + + schedule(7, () => { + state.current.selectedItemId = getSelectedItem()?.id + store.emit() + }) + + // opts is a boolean referring to whether it should NOT be scrolled into view + if (!opts) { + // Scroll the selected item into view + schedule(5, scrollSelectedIntoView) + } + if (propsRef.current?.value !== undefined) { + // If controlled, just call the callback instead of updating state internally + const newValue = (value ?? "") as string + propsRef.current.onValueChange?.(newValue) + return + } + } + + // Notify subscribers that state has changed + store.emit() + }, + emit: () => { + listeners.current.forEach((l) => l()) + }, + } + }, [async]) + + // @ts-expect-error use default from cmdk + const context: Context = React.useMemo( + () => ({ + // Keep id → {value, keywords} mapping up-to-date + value: (id, value, keywords) => { + if (value !== ids.current.get(id)?.value) { + ids.current.set(id, { value, keywords }) + state.current.filtered.items.set(id, score(value, keywords)) + schedule(2, () => { + sort() + store.emit() + }) + } + }, + // Track item lifecycle (mount, unmount) + item: (id, groupId) => { + allItems.current.add(id) + + // Track this item within the group + if (groupId) { + if (!allGroups.current.has(groupId)) { + allGroups.current.set(groupId, new Set([id])) + } else { + // @ts-expect-error use default from cmdk + allGroups.current.get(groupId).add(id) + } + } + + // Batch this, multiple items can mount in one pass + // and we should not be filtering/sorting/emitting each time + schedule(3, () => { + filterItems() + sort() + + // Could be initial mount, select the first item if none already selected + if (!state.current.value) { + selectFirstItem() + } + + store.emit() + }) + + return () => { + ids.current.delete(id) + allItems.current.delete(id) + state.current.filtered.items.delete(id) + const selectedItem = getSelectedItem() + + // Batch this, multiple items could be removed in one pass + schedule(4, () => { + filterItems() + + // The item removed have been the selected one, + // so selection should be moved to the first + if (selectedItem?.getAttribute("id") === id) selectFirstItem() + + store.emit() + }) + } + }, + // Track group lifecycle (mount, unmount) + group: (id) => { + if (!allGroups.current.has(id)) { + allGroups.current.set(id, new Set()) + } + + return () => { + ids.current.delete(id) + allGroups.current.delete(id) + } + }, + filter: () => { + return propsRef.current.shouldFilter + }, + label: label || props["aria-label"], + getDisablePointerSelection: () => { + return propsRef.current.disablePointerSelection + }, + listId, + inputId, + labelId, + listInnerRef, + }), + [], + ) + + function score(value: string, keywords?: string[]) { + const filter = propsRef.current?.filter ?? defaultFilter + return value ? filter(value, state.current.search, keywords) : 0 + } + + /** Sorts items by score, and groups by highest item score. */ + function sort() { + if ( + !state.current.search || + // Explicitly false, because true | undefined is the default + propsRef.current.shouldFilter === false + ) { + return + } + + const scores = state.current.filtered.items + + // Sort the groups + const groups: [string, number][] = [] + state.current.filtered.groups.forEach((value) => { + const items = allGroups.current.get(value) + + // Get the maximum score of the group's items + let max = 0 + // @ts-expect-error use default from cmdk + items.forEach((item) => { + const score = scores.get(item) + // @ts-expect-error use default from cmdk + max = Math.max(score, max) + }) + + groups.push([value, max]) + }) + + // Sort items within groups to bottom + // Sort items outside of groups + // Sort groups to bottom (pushes all non-grouped items to the top) + const listInsertionElement = listInnerRef.current + + // Sort the items + getValidItems() + .sort((a, b) => { + const valueA = a.getAttribute("id") + const valueB = b.getAttribute("id") + // @ts-expect-error use default from cmdk + return (scores.get(valueB) ?? 0) - (scores.get(valueA) ?? 0) + }) + .forEach((item) => { + const group = item.closest(GROUP_ITEMS_SELECTOR) + + if (group) { + group.appendChild( + // @ts-expect-error use default from cmdk + item.parentElement === group ? item : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), + ) + } else { + // @ts-expect-error use default from cmdk + listInsertionElement.appendChild( + // @ts-expect-error use default from cmdk + item.parentElement === listInsertionElement + ? item + : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), + ) + } + }) + + groups + .sort((a, b) => b[1] - a[1]) + .forEach((group) => { + const element = listInnerRef.current?.querySelector( + `${GROUP_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(group[0])}"]`, + ) + // @ts-expect-error use default from cmdk + element?.parentElement.appendChild(element) + }) + } + + function selectFirstItem() { + const item = getValidItems().find((item) => item.getAttribute("aria-disabled") !== "true") + const value = item?.getAttribute(VALUE_ATTR) + // @ts-expect-error use default from cmdk + store.setState("value", value ?? undefined) + } + + /** Filters the current items. */ + function filterItems() { + if ( + !state.current.search || + // Explicitly false, because true | undefined is the default + propsRef.current.shouldFilter === false + ) { + state.current.filtered.count = allItems.current.size + // Do nothing, each item will know to show itself because search is empty + return + } + + // Reset the groups + state.current.filtered.groups = new Set() + let itemCount = 0 + + // Check which items should be included + for (const id of allItems.current) { + const value = ids.current.get(id)?.value ?? "" + const keywords = ids.current.get(id)?.keywords ?? [] + const rank = score(value, keywords) + state.current.filtered.items.set(id, rank) + if (rank > 0) itemCount++ + } + + // Check which groups have at least 1 item shown + for (const [groupId, group] of allGroups.current) { + for (const itemId of group) { + // @ts-expect-error use default from cmdk + if (state.current.filtered.items.get(itemId) > 0) { + state.current.filtered.groups.add(groupId) + break + } + } + } + + state.current.filtered.count = itemCount + } + + function scrollSelectedIntoView() { + const item = getSelectedItem() + + if (item) { + if (item.parentElement?.firstChild === item) { + // First item in Group, ensure heading is in view + item + .closest(GROUP_SELECTOR) + ?.querySelector(GROUP_HEADING_SELECTOR) + ?.scrollIntoView({ block: "nearest" }) + } + + // Ensure the item is always in view + item.scrollIntoView({ block: "nearest" }) + } + } + + /** Getters */ + + function getSelectedItem() { + return listInnerRef.current?.querySelector(`${ITEM_SELECTOR}[aria-selected="true"]`) + } + + function getValidItems() { + return Array.from(listInnerRef.current?.querySelectorAll(VALID_ITEM_SELECTOR) || []) + } + + /** Setters */ + + function updateSelectedToIndex(index: number) { + const items = getValidItems() + const item = items[index] + // @ts-expect-error use default from cmdk + if (item) store.setState("value", item.getAttribute(VALUE_ATTR)) + } + + function updateSelectedByItem(change: 1 | -1) { + const selected = getSelectedItem() + const items = getValidItems() + const index = items.findIndex((item) => item === selected) + + // Get item at this index + let newSelected = items[index + change] + + if (propsRef.current?.loop) { + newSelected = + index + change < 0 + ? items[items.length - 1] + : index + change === items.length + ? items[0] + : items[index + change] + } + + // @ts-expect-error use default from cmdk + if (newSelected) store.setState("value", newSelected.getAttribute(VALUE_ATTR)) + } + + function updateSelectedByGroup(change: 1 | -1) { + const selected = getSelectedItem() + let group = selected?.closest(GROUP_SELECTOR) + let item: HTMLElement + + // @ts-expect-error use default from cmdk + + while (group && !item) { + group = + change > 0 + ? findNextSibling(group, GROUP_SELECTOR) + : findPreviousSibling(group, GROUP_SELECTOR) + // @ts-expect-error use default from cmdk + item = group?.querySelector(VALID_ITEM_SELECTOR) + } + + // @ts-expect-error use default from cmdk + + if (item) { + // @ts-expect-error use default from cmdk + store.setState("value", item.getAttribute(VALUE_ATTR)) + } else { + updateSelectedByItem(change) + } + } + + const last = () => updateSelectedToIndex(getValidItems().length - 1) + + const next = (e: React.KeyboardEvent) => { + e.preventDefault() + + if (e.metaKey) { + // Last item + last() + } else if (e.altKey) { + // Next group + updateSelectedByGroup(1) + } else { + // Next item + updateSelectedByItem(1) + } + } + + const prev = (e: React.KeyboardEvent) => { + e.preventDefault() + + if (e.metaKey) { + // First item + updateSelectedToIndex(0) + } else if (e.altKey) { + // Previous group + updateSelectedByGroup(-1) + } else { + // Previous item + updateSelectedByItem(-1) + } + } + + return ( + { + etc.onKeyDown?.(e) + + // Check if IME composition is finished before triggering key binds + // This prevents unwanted triggering while user is still inputting text with IME + // e.keyCode === 229 is for the CJK IME with Legacy Browser [https://w3c.github.io/uievents/#determine-keydown-keyup-keyCode] + // isComposing is for the CJK IME with Modern Browser [https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent/isComposing] + const isComposing = e.nativeEvent.isComposing || e.keyCode === 229 + + if (e.defaultPrevented || isComposing) { + return + } + + switch (e.key) { + case "n": + case "j": { + // vim keybind down + if (vimBindings && e.ctrlKey) { + next(e) + } + break + } + case "ArrowDown": { + next(e) + break + } + case "p": + case "k": { + // vim keybind up + if (vimBindings && e.ctrlKey) { + prev(e) + } + break + } + case "ArrowUp": { + prev(e) + break + } + case "Home": { + // First item + e.preventDefault() + updateSelectedToIndex(0) + break + } + case "End": { + // Last item + e.preventDefault() + last() + break + } + case "Enter": { + // Trigger item onSelect + e.preventDefault() + const item = getSelectedItem() + if (item) { + const event = new Event(SELECT_EVENT) + item.dispatchEvent(event) + } + } + } + }} + > + + {SlottableWithNestedChildren(props, (child) => ( + + {child} + + ))} + + ) +}) + +/** + * Command menu item. Becomes active on pointer enter or through keyboard navigation. + * Preferably pass a `value`, otherwise the value will be inferred from `children` or + * the rendered item's `textContent`. + */ +const Item = React.forwardRef((props, forwardedRef) => { + const id = useId() + const ref = React.useRef(null) + const groupContext = React.useContext(GroupContext) + const context = useCommand() + const propsRef = useAsRef(props) + const forceMount = propsRef.current?.forceMount ?? groupContext?.forceMount + + useLayoutEffect(() => { + if (!forceMount) { + return context.item(id, groupContext?.id) + } + }, [forceMount]) + + // @ts-expect-error use default from cmdk + const value = useValue(id, ref, [props.value, props.children, ref], props.keywords) + + const store = useStore() + const selected = useCmdk((state) => state.value && state.value === value.current) + const render = useCmdk((state) => + forceMount + ? true + : context.filter() === false + ? true + : !state.search + ? true + : // @ts-expect-error use default from cmdk + state.filtered.items.get(id) > 0, + ) + + React.useEffect(() => { + const element = ref.current + if (!element || props.disabled) return + element.addEventListener(SELECT_EVENT, onSelect) + return () => element.removeEventListener(SELECT_EVENT, onSelect) + }, [render, props.onSelect, props.disabled]) + + function onSelect() { + select() + propsRef.current.onSelect?.(value.current) + } + + function select() { + store.setState("value", value.current, true) + } + + if (!render) return null + + const { disabled, value: _, onSelect: __, forceMount: ___, keywords: ____, ...etc } = props + + return ( + + {props.children} + + ) +}) + +/** + * Group command menu items together with a heading. + * Grouped items are always shown together. + */ +const Group = React.forwardRef((props, forwardedRef) => { + const { heading, forceMount, ...etc } = props + const id = useId() + const ref = React.useRef(null) + const headingRef = React.useRef(null) + const headingId = useId() + const context = useCommand() + const render = useCmdk((state) => + forceMount + ? true + : context.filter() === false + ? true + : !state.search + ? true + : state.filtered.groups.has(id), + ) + + useLayoutEffect(() => { + return context.group(id) + }, []) + + // @ts-expect-error use default from cmdk + useValue(id, ref, [props.value, props.heading, headingRef]) + + const contextValue = React.useMemo(() => ({ id, forceMount }), [forceMount]) + + return ( + + ) +}) + +/** + * A visual and semantic separator between items or groups. + * Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. + */ +const Separator = React.forwardRef((props, forwardedRef) => { + const { alwaysRender, ...etc } = props + const ref = React.useRef(null) + const render = useCmdk((state) => !state.search) + + if (!alwaysRender && !render) return null + return ( + + ) +}) + +/** + * Command menu input. + * All props are forwarded to the underyling `input` element. + */ +const Input = React.forwardRef((props, forwardedRef) => { + const { onValueChange, ...etc } = props + const isControlled = props.value != null + const store = useStore() + const search = useCmdk((state) => state.search) + const selectedItemId = useCmdk((state) => state.selectedItemId) + const context = useCommand() + + React.useEffect(() => { + if (props.value != null) { + store.setState("search", props.value) + } + }, [props.value]) + + return ( + { + if (!isControlled) { + store.setState("search", e.target.value) + } + + onValueChange?.(e.target.value) + }} + /> + ) +}) + +/** + * Contains `Item`, `Group`, and `Separator`. + * Use the `--cmdk-list-height` CSS variable to animate height based on the number of results. + */ +const List = React.forwardRef((props, forwardedRef) => { + const { label = "Suggestions", ...etc } = props + const ref = React.useRef(null) + const height = React.useRef(null) + const selectedItemId = useCmdk((state) => state.selectedItemId) + const context = useCommand() + + React.useEffect(() => { + if (height.current && ref.current) { + const el = height.current + const wrapper = ref.current + // @ts-expect-error use default from cmdk + let animationFrame + const observer = new ResizeObserver(() => { + animationFrame = requestAnimationFrame(() => { + const height = el.offsetHeight + wrapper.style.setProperty(`--cmdk-list-height`, height.toFixed(1) + "px") + }) + }) + observer.observe(el) + return () => { + // @ts-expect-error use default from cmdk + + cancelAnimationFrame(animationFrame) + observer.unobserve(el) + } + } + }, []) + + return ( + + {SlottableWithNestedChildren(props, (child) => ( +
+ {child} +
+ ))} +
+ ) +}) + +/** + * Renders the command menu in a Radix Dialog. + */ +const Dialog = React.forwardRef((props, forwardedRef) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const { open, onOpenChange, overlayClassName, contentClassName, container, ...etc } = props + return ( + + + + + + + + + ) +}) + +/** + * Automatically renders when there are no results for the search query. + */ +const Empty = React.forwardRef((props, forwardedRef) => { + const render = useCmdk((state) => state.filtered.count === 0) + + if (!render) return null + return +}) + +/** + * You should conditionally render this with `progress` while loading asynchronous items. + */ +const Loading = React.forwardRef((props, forwardedRef) => { + const { progress, label = "Loading...", ...etc } = props + + return ( + + {SlottableWithNestedChildren(props, (child) => ( +
{child}
+ ))} +
+ ) +}) + +const pkg = Object.assign(Command, { + List, + Item, + Input, + Group, + Separator, + Dialog, + Empty, + Loading, +}) + +export { useCmdk as useCommandState } +export { pkg as Command } +export { defaultFilter } + +export { Command as CommandRoot } +export { List as CommandList } +export { Item as CommandItem } +export { Input as CommandInput } +export { Group as CommandGroup } +export { Separator as CommandSeparator } +export { Dialog as CommandDialog } +export { Empty as CommandEmpty } +export { Loading as CommandLoading } + +/** + * + * + * Helpers + * + * + */ + +function findNextSibling(el: Element, selector: string) { + let sibling = el.nextElementSibling + + while (sibling) { + if (sibling.matches(selector)) return sibling + sibling = sibling.nextElementSibling + } +} + +function findPreviousSibling(el: Element, selector: string) { + let sibling = el.previousElementSibling + + while (sibling) { + if (sibling.matches(selector)) return sibling + sibling = sibling.previousElementSibling + } +} + +function useAsRef(data: T) { + const ref = React.useRef(data) + + useLayoutEffect(() => { + ref.current = data + }) + + return ref +} + +const useLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect + +function useLazyRef(fn: () => T) { + // @ts-expect-error use default from cmdk + const ref = React.useRef() + + if (ref.current === undefined) { + ref.current = fn() + } + + return ref as React.MutableRefObject +} + +/** Run a selector against the store state. */ + +function useCmdk(selector: (state: State) => T): T { + const store = useStore() + const cb = () => selector(store.snapshot()) + return React.useSyncExternalStore(store.subscribe, cb, cb) +} + +function useValue( + id: string, + ref: React.RefObject, + deps: (string | React.ReactNode | React.RefObject)[], + aliases: string[] = [], +) { + // @ts-expect-error use default from cmdk + const valueRef = React.useRef() + const context = useCommand() + + useLayoutEffect(() => { + const value = (() => { + for (const part of deps) { + if (typeof part === "string") { + return part.trim() + } + + // @ts-expect-error use default from cmdk + if (typeof part === "object" && "current" in part) { + if (part.current) { + return part.current.textContent?.trim() + } + return valueRef.current + } + } + })() + + const keywords = aliases.map((alias) => alias.trim()) + + // @ts-expect-error use default from cmdk + context.value(id, value, keywords) + // @ts-expect-error use default from cmdk + + ref.current?.setAttribute(VALUE_ATTR, value) + // @ts-expect-error use default from cmdk + valueRef.current = value + }) + + return valueRef +} + +/** Imperatively run a function on the next layout effect cycle. */ +const useScheduleLayoutEffect = () => { + const [s, ss] = React.useState() + const fns = useLazyRef(() => new Map void>()) + + useLayoutEffect(() => { + fns.current.forEach((f) => f()) + fns.current = new Map() + }, [s]) + + return (id: string | number, cb: () => void) => { + fns.current.set(id, cb) + ss({}) + } +} + +function renderChildren(children: React.ReactElement) { + const childrenType = children.type as any + // The children is a component + if (typeof childrenType === "function") return childrenType(children.props) + // The children is a component with `forwardRef` + else if ("render" in childrenType) return childrenType.render(children.props) + // It's a string, boolean, etc. + else return children +} + +function SlottableWithNestedChildren( + { asChild, children }: { asChild?: boolean; children?: React.ReactNode }, + render: (child: React.ReactNode) => React.JSX.Element, +) { + if (asChild && React.isValidElement(children)) { + return React.cloneElement( + renderChildren(children), + + { ref: (children as any).ref }, + // @ts-expect-error use default from cmdk + render(children.props.children), + ) + } + return render(children) +} + +const srOnlyStyles = { + position: "absolute", + width: "1px", + height: "1px", + padding: "0", + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + borderWidth: "0", +} as const diff --git a/apps/docs/src/components/directory-content.tsx b/apps/docs/src/components/directory-content.tsx new file mode 100644 index 0000000..2f2aa22 --- /dev/null +++ b/apps/docs/src/components/directory-content.tsx @@ -0,0 +1,56 @@ +import type { transformedEntries } from "~/collections" +import { getBreadcrumbItems, getSections } from "~/collections" +import { SiteBreadcrumb } from "~/components/breadcrumb" +import { cn } from "~/lib/utils" +import SectionGrid from "./section-grid" +import Siblings from "./siblings" +import { MobileTableOfContents } from "./table-of-contents" + +export async function DirectoryContent({ + transformedEntry, +}: { + transformedEntry: Awaited>[number] +}) { + const [breadcrumbItems, sections] = await Promise.all([ + getBreadcrumbItems(transformedEntry.segments), + getSections(transformedEntry.entry), + ]) + + return ( + <> +
+ + +
+
+ + +
+
+

+ {transformedEntry.title} +

+
+ + +
+ + +
+
+
+ + ) +} diff --git a/apps/docs/src/components/docs-sidebar.tsx b/apps/docs/src/components/docs-sidebar.tsx new file mode 100644 index 0000000..321169a --- /dev/null +++ b/apps/docs/src/components/docs-sidebar.tsx @@ -0,0 +1,143 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { ChevronRight } from "lucide-react" + +import type { TreeItem } from "~/lib/navigation" +import { Button } from "~/components/ui/button" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible" +import { useSidebar } from "~/components/ui/sidebar" +import { useIsMobile } from "~/hooks/use-mobile" +import { current } from "~/lib/helpers" +import { cn } from "~/lib/utils" + +export function DocsSidebar({ + className, + items, + highlightActive = true, +}: { + items: TreeItem[] + highlightActive?: boolean +} & React.ComponentProps<"ul">) { + const pathname = usePathname() + const { toggleSidebar } = useSidebar() + + const isMobile = useIsMobile() + + if (items.length === 0) return <> + return ( +
    + {items.map((item) => + (item.children ?? []).length > 0 ? ( + + ) : ( +
  • +
    + toggleSidebar() : undefined} + href={item.path} + className={cn( + "flex h-8 min-w-8 flex-1 items-center p-1.5 text-sm text-muted-foreground ring-ring outline-hidden transition-all hover:text-accent-foreground focus-visible:ring-2", + highlightActive && current({ pathname, item }) + ? "text-orange-primary" + : "hover:text-orange-primary", + )} + > +
    {item.title}
    + +
    +
  • + ), + )} +
+ ) +} + +function CollapsibleItem({ pathname, item }: { pathname: string; item: TreeItem }) { + const isMobile = useIsMobile() + const isCurrent = current({ pathname, item }) + const [open, setOpen] = useState(isCurrent) + const { toggleSidebar } = useSidebar() + + useEffect(() => { + setOpen(isCurrent) + }, [isCurrent]) + + return ( + +
  • +
    + toggleSidebar() : undefined} + className={cn( + "flex h-8 min-w-8 flex-1 items-center gap-2 p-1.5 text-sm text-muted-foreground ring-ring outline-hidden transition-all hover:text-accent-foreground focus-visible:ring-2", + current({ pathname, item }) + ? "font-bold text-orange-primary hover:text-black dark:hover:text-white" + : "font-bold hover:text-orange-primary", + )} + > + {current({ pathname, item }) && item.depth > 1 && ( + + )} +
    {item.title}
    + + + + + +
    + +
      + {item.children?.map((subItem) => { + if ((subItem.children ?? []).length > 0) { + return ( +
    • + +
    • + ) + } + + return ( +
    • + toggleSidebar() : undefined} + className={cn( + "flex h-8 min-w-8 flex-1 items-center gap-2 p-1.5 text-sm text-muted-foreground ring-ring outline-hidden transition-all hover:text-muted-foreground focus-visible:ring-2", + current({ pathname, item: subItem }) + ? "font-bold text-orange-primary hover:text-black dark:hover:text-white" + : "hover:text-orange-primary", + )} + > + {current({ pathname, item: subItem }) && ( + + )} +
      {subItem.title}
      + +
    • + ) + })} +
    +
    +
  • +
    + ) +} diff --git a/apps/docs/src/components/file-content.tsx b/apps/docs/src/components/file-content.tsx new file mode 100644 index 0000000..a09f236 --- /dev/null +++ b/apps/docs/src/components/file-content.tsx @@ -0,0 +1,154 @@ +import { notFound } from "next/navigation" +import { ExternalLinkIcon } from "lucide-react" +import { Markdown } from "renoun/components" + +import type { transformedEntries } from "~/collections" +import { getBreadcrumbItems, getMetadata, getSections } from "~/collections" +import { SiteBreadcrumb } from "~/components/breadcrumb" +import SectionGrid from "~/components/section-grid" +import Siblings from "~/components/siblings" +import { MobileTableOfContents, TableOfContents } from "~/components/table-of-contents" +import { cn } from "~/lib/utils" + +export async function FileContent({ + transformedEntry, +}: { + transformedEntry: Awaited>[number] +}) { + if (!transformedEntry.file) return notFound() + + const [Content, frontmatter, headings, breadcrumbItems, sections] = await Promise.all([ + transformedEntry.file.getExportValue("default"), + getMetadata(transformedEntry.file), + transformedEntry.file.getExportValue("headings"), + getBreadcrumbItems(transformedEntry.segments), + getSections(transformedEntry.entry), + ]) + + const pagefindProps = !frontmatter?.ignoreSearch + ? { + "data-pagefind-body": "", + } + : {} + + return ( + <> +
    + 0 && frontmatter?.toc ? headings : []} /> + +
    +
    + + +
    + ( +

    + ), + }} + > + {`# ${frontmatter?.title ?? transformedEntry.title}`} + + + ( +

    + ), + code: (props) => {props.children ?? ""}, + }} + > + {frontmatter?.description ?? " "} + + +

    +
    li]:prose-ul:mt-2 [&>ul]:prose-ul:my-2 [&>ul]:prose-ul:ml-0", + )} + > + +
    + + +
    +

    + +
    + {frontmatter?.toc ? ( +
    + + +
    0, + })} + > +
    + {/* eslint-disable-next-line no-restricted-properties */} + {process.env.NODE_ENV === "development" ? ( + + View source + + ) : ( + + View source + + )} +
    +
    +
    + ) : null} +
    +
    + + ) +} diff --git a/apps/docs/src/components/footer.tsx b/apps/docs/src/components/footer.tsx index 5f8f4fb..b46d1db 100644 --- a/apps/docs/src/components/footer.tsx +++ b/apps/docs/src/components/footer.tsx @@ -1,92 +1,82 @@ import Image from "next/image" import Link from "next/link" +const links = [ + { + name: "Home", + target: "/", + }, + { + name: "Docs", + target: "/docs/getting-started/introduction", + }, + { + name: "Examples", + target: "/docs/examples", + }, + { + name: "Packages", + target: "/docs/packages", + }, +] + export default function Footer() { return ( -