diff --git a/README.md b/README.md index c7afd2a..2beb3c4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The Parallel Cookbook is a curated set of recipes that show how to build with Pa - [Recipes by Category](#recipes-by-category) - [Templates & Starters](#templates--starters) - [Agents & Search](#agents--search) + - [Discovery & Recommendations](#discovery--recommendations) - [Data Enrichment](#data-enrichment) - [Realtime Streaming (SSE)](#realtime-streaming-sse) - [Scheduled Research & Webhooks](#scheduled-research--webhooks) @@ -91,6 +92,14 @@ LLM agents that use Parallel's Search API as a tool with the Vercel AI SDK. | [**Search Agent (Cerebras)**](typescript-recipes/parallel-search-agent-cerebras) | Multi-turn web research agent backed by Cerebras (GPT-OSS / Qwen). Iterative multi-angle searches, full-stack with vanilla JS frontend. | `Search` | Cloudflare Workers · Cerebras · AI SDK | [Live](https://oss.parallel.ai/agent/) | | [**Search Agent (Groq)**](typescript-recipes/parallel-search-agent-groq) | Same agent shape, Llama 4 Maverick on Groq with 128k context for long sessions. | `Search` | Cloudflare Workers · Groq · AI SDK | [Live](https://oss.parallel.ai/agent/) | +### Discovery & Recommendations + +Find and rank real-world entities from natural-language criteria. + +| Recipe | Description | APIs | Stack | Demo | +| --- | --- | --- | --- | --- | +| [**Apartment Finder**](typescript-recipes/parallel-apartment-finder) | Discovers real Bay Area apartment listings with FindAll, enriches each match, and uses Task to verify scam signals. | `FindAll` `Task` | Next.js · Vercel | [Live](https://apartment-finder-web.vercel.app) | + ### Data Enrichment Take a thin input (a name, a domain) and return structured, cited fields. diff --git a/typescript-recipes/parallel-apartment-finder/.env.example b/typescript-recipes/parallel-apartment-finder/.env.example new file mode 100644 index 0000000..a977bc3 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/.env.example @@ -0,0 +1,16 @@ +# Required: your Parallel API key (https://platform.parallel.ai). +# Copy this file to .env.local and fill it in. .env.local is gitignored — +# never commit a real key. The key is used server-side only and is never +# exposed to the browser. +PARALLEL_API_KEY=your-parallel-api-key-here + +# Optional. Absolute base URL for the app's own /api calls. Leave empty for +# local dev and normal single-origin deployments (the app calls its own +# same-origin API routes). +NEXT_PUBLIC_API_BASE= + +# Optional overrides (see src/lib/server/config.ts for the full set): +# CITY=San Francisco, CA +# SEARCH_BUDGET=6000 +# FINDALL_GENERATOR=base +# FINDALL_ENRICH_PROCESSOR=base diff --git a/typescript-recipes/parallel-apartment-finder/.gitignore b/typescript-recipes/parallel-apartment-finder/.gitignore new file mode 100644 index 0000000..b0727da --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/.gitignore @@ -0,0 +1,5 @@ +.vercel +.env +.env.local +.env*.local +!.env.example diff --git a/typescript-recipes/parallel-apartment-finder/.vercelignore b/typescript-recipes/parallel-apartment-finder/.vercelignore new file mode 100644 index 0000000..398a65e --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/.vercelignore @@ -0,0 +1,6 @@ +node_modules +.next +.env +.env.* +Dockerfile.dev +package-lock.json diff --git a/typescript-recipes/parallel-apartment-finder/Dockerfile.dev b/typescript-recipes/parallel-apartment-finder/Dockerfile.dev new file mode 100644 index 0000000..ac24f1b --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM node:20-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/typescript-recipes/parallel-apartment-finder/LICENSE b/typescript-recipes/parallel-apartment-finder/LICENSE new file mode 100644 index 0000000..352017c --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/LICENSE @@ -0,0 +1,12 @@ +MIT License + +Copyright (c) 2026 Parallel Web Systems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all diff --git a/typescript-recipes/parallel-apartment-finder/NOTICE b/typescript-recipes/parallel-apartment-finder/NOTICE new file mode 100644 index 0000000..0dff042 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/NOTICE @@ -0,0 +1,19 @@ +Apartment Finder Web +Copyright 2026 Parallel Web Systems, Inc. + +This project is licensed under the MIT License (see LICENSE). + +Trademarks +---------- +"Parallel" and the Parallel logo, wordmark, and symbol (the SVG brand marks in +frontend/public/) are trademarks of Parallel Web Systems, Inc. The MIT license +covers the source code only. It does not grant any right to use the Parallel +name or marks, whether to imply endorsement, in a derivative product's branding, +or otherwise. If you fork or redeploy this project, replace the Parallel brand +marks with your own. + +Third-Party Notices +------------------- +This project depends on open-source software distributed under its own license, +including Next.js, React, Tailwind CSS, shadcn/ui, and Leaflet. Their licenses +apply to those components. diff --git a/typescript-recipes/parallel-apartment-finder/README.md b/typescript-recipes/parallel-apartment-finder/README.md new file mode 100644 index 0000000..4f5dcbb --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/README.md @@ -0,0 +1,84 @@ +# Apartment Finder + +Discover and verify real Bay Area apartment listings from a natural-language request with Parallel FindAll and Task. Live demo: [apartment-finder-web.vercel.app](https://apartment-finder-web.vercel.app) + +## What it shows + +- Discover individual listing pages with FindAll, then enrich each match into a structured 18-field record. +- Verify listings from untrusted sources with a separate Task run that checks fact-based scam signals. +- Drive a multi-step search from a Next.js client while keeping every serverless API request short and stateless. +- Geocode results, score them for price and location fit, and save a shortlist in browser storage. + +## Architecture + +```mermaid +graph LR + A[Browser] <-->|create · poll · enrich · finalize| B[Next.js API routes] + B <-->|FindAll + Task APIs| C[Parallel API] + B <-->|geocoding| D[OSM Nominatim] + A -.->|saved shortlist| E[(Browser localStorage)] +``` + +The browser advances each search through short serverless calls. The application has no database or long-running server process; only the user's saved shortlist persists, in `localStorage`. + +## Quick Start + +### Prerequisites + +- Node.js 20 or newer +- A [Parallel API key](https://platform.parallel.ai) + +```bash +git clone https://github.com/parallel-web/parallel-cookbook.git +cd parallel-cookbook/typescript-recipes/parallel-apartment-finder +npm install +cp .env.example .env.local +``` + +Set `PARALLEL_API_KEY` in `.env.local`, then start the app: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Deploy + +Deploy this directory as a Vercel project and add `PARALLEL_API_KEY` to the project's environment variables. + +```bash +npx vercel deploy --prod +``` + +> [!IMPORTANT] +> The `/api/search` and `/api/verify` routes do not authenticate callers. Every request uses your Parallel quota. Before a public deployment, add authentication and rate limiting, and keep `PARALLEL_API_KEY` in a server-side environment variable—never a `NEXT_PUBLIC_` variable. + +## How it works + +1. `POST /api/search` turns the user's request into explicit FindAll match conditions. +2. The client polls the FindAll run and renders confirmed listings as they arrive. +3. When discovery finishes, the client starts structured enrichment and polls again for price, beds, address, and other listing fields. +4. The finalize route geocodes and scores matches. Listings from untrusted sources can then be checked by the Task-based fraud verifier. + +Configuration is environment-driven. See [`src/lib/server/config.ts`](src/lib/server/config.ts) for supported city, budget, generator, processor, and result-limit overrides. + +## Project Structure + +```text +src/ +├── app/ # Pages, layouts, and serverless API routes +├── components/ # Search, listings, map, and reasoning UI +├── hooks/ # Search state machine and saved targets +├── lib/server/ # Parallel client, parsing, scoring, and geocoding +├── providers/ # React context providers +└── types/ # TypeScript types +``` + +## Credits + +Created by [Elijah Jacob](https://github.com/elijahgjacob) and contributed from [apartment-finder-web](https://github.com/elijahgjacob/apartment-finder-web). + +## License + +MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/typescript-recipes/parallel-apartment-finder/components.json b/typescript-recipes/parallel-apartment-finder/components.json new file mode 100644 index 0000000..75410b4 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "src/components", + "utils": "src/lib/utils", + "ui": "src/components/ui", + "lib": "src/lib", + "hooks": "src/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/typescript-recipes/parallel-apartment-finder/eslint.config.mjs b/typescript-recipes/parallel-apartment-finder/eslint.config.mjs new file mode 100644 index 0000000..aa731df --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/eslint.config.mjs @@ -0,0 +1,20 @@ +import js from "@eslint/js" +import globals from "globals" +import reactHooks from "eslint-plugin-react-hooks" +import tseslint from "typescript-eslint" +import { defineConfig, globalIgnores } from "eslint/config" + +export default defineConfig([ + globalIgnores([".next"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/typescript-recipes/parallel-apartment-finder/next-env.d.ts b/typescript-recipes/parallel-apartment-finder/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/typescript-recipes/parallel-apartment-finder/next.config.ts b/typescript-recipes/parallel-apartment-finder/next.config.ts new file mode 100644 index 0000000..84cf994 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from "next" + +// All /api/* endpoints are native Next.js route handlers (src/app/api) that +// call the Parallel API directly — no separate backend, no proxy. +const nextConfig: NextConfig = { + // Keep build traces scoped to this recipe when it lives inside the cookbook + // monorepo (which contains several independent lockfiles). + outputFileTracingRoot: process.cwd(), +} + +export default nextConfig diff --git a/typescript-recipes/parallel-apartment-finder/package-lock.json b/typescript-recipes/parallel-apartment-finder/package-lock.json new file mode 100644 index 0000000..629e608 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/package-lock.json @@ -0,0 +1,6568 @@ +{ + "name": "parallel-apartment-finder", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parallel-apartment-finder", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist-mono": "^5.2.8", + "@tailwindcss/postcss": "^4.2.4", + "leaflet": "^1.9.4", + "next": "^15.3.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "shadcn": "^4.7.0", + "tailwindcss": "^4.2.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.75.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", + "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@dotenvx/primitives": "^0.8.0", + "commander": "^11.1.0", + "conf": "^10.2.0", + "dotenv": "^17.2.1", + "enquirer": "^2.4.1", + "env-paths": "^2.2.1", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "open": "^8.4.2", + "picomatch": "^4.0.4", + "systeminformation": "^5.22.11", + "undici": "^7.11.0", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@dotenvx/primitives": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-0.8.0.tgz", + "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.2.8.tgz", + "integrity": "sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@next/env": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.24.tgz", + "integrity": "sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.24.tgz", + "integrity": "sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.24.tgz", + "integrity": "sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.24.tgz", + "integrity": "sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.24.tgz", + "integrity": "sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.24.tgz", + "integrity": "sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.24.tgz", + "integrity": "sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.24.tgz", + "integrity": "sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.24.tgz", + "integrity": "sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", + "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "postcss": "^8.5.15", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "license": "MIT", + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/conf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", + "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "license": "MIT", + "dependencies": { + "ajv": "^8.6.3", + "ajv-formats": "^2.1.1", + "atomically": "^1.7.0", + "debounce-fn": "^4.0.0", + "dot-prop": "^6.0.1", + "env-paths": "^2.2.1", + "json-schema-typed": "^7.0.3", + "onetime": "^5.1.2", + "pkg-up": "^3.1.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/conf/node_modules/json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "license": "BSD-2-Clause" + }, + "node_modules/conf/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.388", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", + "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "15.5.24", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.24.tgz", + "integrity": "sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.24", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.24", + "@next/swc-darwin-x64": "15.5.24", + "@next/swc-linux-arm64-gnu": "15.5.24", + "@next/swc-linux-arm64-musl": "15.5.24", + "@next/swc-linux-x64-gnu": "15.5.24", + "@next/swc-linux-x64-musl": "15.5.24", + "@next/swc-win32-arm64-msvc": "15.5.24", + "@next/swc-win32-x64-msvc": "15.5.24", + "sharp": "^0.34.3 || ^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/recast": { + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz", + "integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.13.0.tgz", + "integrity": "sha512-5fuJ4jI/GcPeA/iTL4cJivCZuYQGXz/N3bIzyd+Gd/FM6xUCy2MxGG+LaDQuw2cjNy9zGPSFPTEmI048UwPTZA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "kleur": "^4.1.5", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "tailwind-merge": "^3.0.1", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "undici": "^7.27.2", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + }, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/systeminformation": { + "version": "5.31.14", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.14.tgz", + "integrity": "sha512-nefRpMCsAI4m71/6JHH//KPaP/d5nTuRVxEtQ7N7SlBrX18DAcC+5Z1JKZYeN9Iw49qMx95BTo/gBMk3Y2H6+g==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.1.tgz", + "integrity": "sha512-9cbFWLhbiZp+820O4pkHGNncI7+MrUGzBOjw8NMG+ewsY+aG0DdEXnr19Smxao32YOjLZRMdn1UtaxcrXOYOIg==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/typescript-recipes/parallel-apartment-finder/package.json b/typescript-recipes/parallel-apartment-finder/package.json new file mode 100644 index 0000000..c398c0b --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/package.json @@ -0,0 +1,38 @@ +{ + "name": "parallel-apartment-finder", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist-mono": "^5.2.8", + "@tailwindcss/postcss": "^4.2.4", + "leaflet": "^1.9.4", + "next": "^15.3.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "shadcn": "^4.7.0", + "tailwindcss": "^4.2.4" + }, + "overrides": { + "postcss": "^8.5.10" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2" + } +} diff --git a/typescript-recipes/parallel-apartment-finder/postcss.config.mjs b/typescript-recipes/parallel-apartment-finder/postcss.config.mjs new file mode 100644 index 0000000..2f8795a --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/typescript-recipes/parallel-apartment-finder/public/app-icon.svg b/typescript-recipes/parallel-apartment-finder/public/app-icon.svg new file mode 100644 index 0000000..313b996 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/public/app-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/typescript-recipes/parallel-apartment-finder/public/app-logo.svg b/typescript-recipes/parallel-apartment-finder/public/app-logo.svg new file mode 100644 index 0000000..d575f84 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/public/app-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + Apartment Finder + diff --git a/typescript-recipes/parallel-apartment-finder/public/icons.svg b/typescript-recipes/parallel-apartment-finder/public/icons.svg new file mode 100644 index 0000000..a508296 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/public/icons.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/typescript-recipes/parallel-apartment-finder/public/parallel-lockup.svg b/typescript-recipes/parallel-apartment-finder/public/parallel-lockup.svg new file mode 100644 index 0000000..ea76da3 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/public/parallel-lockup.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/typescript-recipes/parallel-apartment-finder/public/parallel-symbol.svg b/typescript-recipes/parallel-apartment-finder/public/parallel-symbol.svg new file mode 100644 index 0000000..4f40c1e --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/public/parallel-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/typescript-recipes/parallel-apartment-finder/public/parallel-wordmark.svg b/typescript-recipes/parallel-apartment-finder/public/parallel-wordmark.svg new file mode 100644 index 0000000..0680b60 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/public/parallel-wordmark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/config/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/config/route.ts new file mode 100644 index 0000000..1b7541b --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/config/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" +import { buildAppConfig } from "@/lib/server/app-config" + +// The app itself gets config inlined by the server layout; this route stays +// for the /docs live tab and any external consumers. +export async function GET() { + return NextResponse.json(buildAppConfig()) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/enrich/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/enrich/route.ts new file mode 100644 index 0000000..c4c2db7 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/enrich/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server" +import { findallEnrich } from "@/lib/server/parallel" + +// Kick the structured-enrichment pass (price, beds, address, …) once +// discovery completes. Client-driven; idempotent enough for our use — the +// client calls it exactly once when it sees discovery finish. +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + if (!/^findall_[a-f0-9]+$/.test(id)) { + return NextResponse.json({ detail: "invalid run id" }, { status: 422 }) + } + try { + await findallEnrich(id) + return NextResponse.json({ ok: true }) + } catch (e) { + return NextResponse.json( + { detail: e instanceof Error ? e.message : "enrich failed" }, { status: 502 }, + ) + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/finalize/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/finalize/route.ts new file mode 100644 index 0000000..ebc0df6 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/finalize/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server" +import { findallResult } from "@/lib/server/parallel" +import { parseCandidates, parseOptionsFrom, scoreListing, isFarFromReference } from "@/lib/server/listings" +import { geocodeAddress, geocodeNeighborhood } from "@/lib/server/geocode" +import { neighborhoodCentroid } from "@/lib/bay-area" +import { DEFAULT_BUDGET } from "@/lib/server/config" +import { TRUSTED_SOURCES } from "@/lib/server/verify" + +// Geocoding runs sequentially (~1/s per Nominatim policy) for up to +// FINDALL_MATCH_LIMIT listings, so allow more than the default duration. +export const maxDuration = 60 + +// Final step after enrichment completes: parse everything, geocode each +// address, and return the fully scored listings. +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + if (!/^findall_[a-f0-9]+$/.test(id)) { + return NextResponse.json({ detail: "invalid run id" }, { status: 422 }) + } + const sp = req.nextUrl.searchParams + const budget = Number(sp.get("budget")) || DEFAULT_BUDGET + const minBedsRaw = sp.get("minBeds") + const minBeds = minBedsRaw ? Number(minBedsRaw) || null : null + const city = sp.get("city") + + try { + const candidates = await findallResult(id) + const opts = parseOptionsFrom(sp) + const drops: Record = {} + const listings = parseCandidates(candidates, minBeds, budget, opts, drops) + // Funnel visibility (shows in Vercel runtime logs): how many matched + // candidates became listings, and why the rest were dropped. + console.log(`[funnel] run=${id} matched=${candidates.length} kept=${listings.length} drops=${JSON.stringify(drops)}`) + + // Geocode with a precision ladder: exact address via Nominatim, then the + // preloaded Bay Area neighborhood-centroid table (free), then Nominatim + // for neighborhoods the table doesn't know (memoized per run), then give + // up and let scoring treat the location as unknown-neutral. + const nominatimPause = () => new Promise((r) => setTimeout(r, 1050)) + const hoodCache = new Map() + // Slow geocodes (Nominatim timeouts) must not blow the route's + // maxDuration — past the deadline, remaining listings score as + // unknown-location instead of failing the whole finalize. + const deadline = Date.now() + 45_000 + for (const l of listings) { + let coords: { lat: number; lng: number } | null = null + const localCentroid = neighborhoodCentroid(city, l.neighborhood) + if (l.address && Date.now() < deadline) { + coords = await geocodeAddress(l.address, city) + await nominatimPause() + } + if (coords) { + l.geo_precision = "address" + } else if (localCentroid) { + coords = localCentroid + l.geo_precision = "neighborhood" + } else if (l.neighborhood && Date.now() < deadline) { + const key = l.neighborhood.toLowerCase().trim() + if (!hoodCache.has(key)) { + hoodCache.set(key, await geocodeNeighborhood(l.neighborhood, city)) + await nominatimPause() + } + coords = hoodCache.get(key) ?? null + if (coords) l.geo_precision = "neighborhood" + } + if (coords) { + l.lat = coords.lat + l.lng = coords.lng + } + l.score = scoreListing(l, budget, opts) // re-score with geo precision known + } + + // Drop listings that geocoded well outside the search's region (a same-state + // unit that shares a street name, so the address/URL guard didn't catch it). + // Failed-geocode listings have null coords and are kept. + const inRegion = listings.filter((l) => !isFarFromReference(l.lat, l.lng, opts)) + if (inRegion.length !== listings.length) { + console.log(`[funnel] run=${id} dropped ${listings.length - inRegion.length} out-of-region`) + } + + // Flag untrusted-source listings for the client-driven Task API + // secondary verification (same flags as the original spam check). + const withVerify = inRegion.map((l) => ({ + ...l, + needs_verification: !TRUSTED_SOURCES.has(l.source), + })) + return NextResponse.json({ listings: withVerify }) + } catch (e) { + return NextResponse.json( + { detail: e instanceof Error ? e.message : "finalize failed" }, { status: 502 }, + ) + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/route.ts new file mode 100644 index 0000000..55e5220 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/search/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server" +import { findallResult, findallStatus } from "@/lib/server/parallel" +import { parseCandidates, parseOptionsFrom } from "@/lib/server/listings" +import { DEFAULT_BUDGET } from "@/lib/server/config" + +// One short poll: run state + metrics + the listings parsed so far. +// budget/minBeds come from the client (the server keeps no state). +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + if (!/^findall_[a-f0-9]+$/.test(id)) { + return NextResponse.json({ detail: "invalid run id" }, { status: 422 }) + } + const sp = req.nextUrl.searchParams + const budget = Number(sp.get("budget")) || DEFAULT_BUDGET + const minBedsRaw = sp.get("minBeds") + const minBeds = minBedsRaw ? Number(minBedsRaw) || null : null + + try { + const [status, candidates] = await Promise.all([ + findallStatus(id), + findallResult(id).catch(() => []), + ]) + const listings = parseCandidates(candidates, minBeds, budget, parseOptionsFrom(sp)) + // How many candidates have enriched rent values — lets the client tell + // "discovery done" apart from "enrichment done" (both report completed). + const rentPopulated = candidates.filter((c) => { + const rent = c.output?.monthly_rent_usd + return rent != null && String(rent.value ?? "").trim() !== "" + }).length + return NextResponse.json({ + state: status.state, + generated: status.generated, + matched: status.matched, + rentPopulated, + candidateCount: candidates.length, + listings, + }) + } catch (e) { + return NextResponse.json( + { detail: e instanceof Error ? e.message : "poll failed" }, { status: 502 }, + ) + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/search/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/search/route.ts new file mode 100644 index 0000000..0f5b2db --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/search/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server" +import { findallCreate } from "@/lib/server/parallel" +import { bedroomBounds } from "@/lib/server/listings" +import { DEFAULT_BUDGET, BLOCKED_DOMAINS } from "@/lib/server/config" +import { sanitizeDomain } from "@/lib/sources" + +// Create a FindAll run. The server holds no state — the client keeps the +// returned runId and drives the poll/enrich steps. +export async function POST(req: NextRequest) { + let body: { + query?: string; budget?: number; city?: string; requirements?: string + neighborhoods?: string[]; sources?: string[] + } + try { + body = await req.json() + } catch { + return NextResponse.json({ detail: "invalid JSON body" }, { status: 422 }) + } + const query = (body.query ?? "").trim() + if (!query) return NextResponse.json({ detail: "query is required" }, { status: 422 }) + + if (!process.env.PARALLEL_API_KEY) { + return NextResponse.json( + { detail: "PARALLEL_API_KEY is not set on the server" }, { status: 500 }, + ) + } + + const budget = body.budget ?? DEFAULT_BUDGET + const { min: minBeds, max: maxBeds } = bedroomBounds(query) + // Custom domains get echoed into the FindAll prompt — accept only clean + // hostnames, drop anything on the block list, cap the count. + const sources = Array.isArray(body.sources) + ? body.sources + .map((s) => (typeof s === "string" ? sanitizeDomain(s) : null)) + .filter((d): d is string => !!d && !BLOCKED_DOMAINS.includes(d)) + .slice(0, 20) + : null + try { + const { findallId, objective } = await findallCreate({ + query, budget, + city: body.city ?? null, + requirements: body.requirements ?? null, + neighborhoods: Array.isArray(body.neighborhoods) + ? body.neighborhoods.filter((n) => typeof n === "string" && n.trim()).slice(0, 8) + : null, + sources: sources?.length ? sources : null, + minBeds, + }) + return NextResponse.json({ runId: findallId, objective, minBeds, maxBeds, budget, sources }) + } catch (e) { + return NextResponse.json( + { detail: e instanceof Error ? e.message : "FindAll create failed" }, { status: 502 }, + ) + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/verify/[id]/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/verify/[id]/route.ts new file mode 100644 index 0000000..98029ff --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/verify/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server" +import { taskStatus, taskResult } from "@/lib/server/parallel" +import { computeSpamScore } from "@/lib/server/verify" + +// Poll a verification task. When it completes, compute the weighted spam +// score in code from the verified boolean facts. +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + if (!/^trun_[a-f0-9]+$/.test(id)) { + return NextResponse.json({ detail: "invalid run id" }, { status: 422 }) + } + try { + const state = await taskStatus(id) + if (["failed", "error", "cancelled"].includes(state)) { + return NextResponse.json({ done: true, spamScore: 0, flags: [`task_${state}`] }) + } + if (!["completed", "succeeded"].includes(state)) { + return NextResponse.json({ done: false }) + } + const content = await taskResult(id) + const { score, flags } = computeSpamScore(content) + return NextResponse.json({ done: true, spamScore: score, flags }) + } catch (e) { + return NextResponse.json( + { detail: e instanceof Error ? e.message : "verify poll failed" }, { status: 502 }, + ) + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/api/verify/route.ts b/typescript-recipes/parallel-apartment-finder/src/app/api/verify/route.ts new file mode 100644 index 0000000..ace0fc1 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/api/verify/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server" +import { taskCreate } from "@/lib/server/parallel" +import { SPAM_SCHEMA, TASK_SPAM_PROCESSOR } from "@/lib/server/verify" + +// Start a Task-API secondary verification of one listing (fact-based spam +// flags). Returns the run id; the client polls GET /api/verify/[id]. +export async function POST(req: NextRequest) { + let body: { title?: string; body?: string; price?: number | null; address?: string | null; source?: string } + try { + body = await req.json() + } catch { + return NextResponse.json({ detail: "invalid JSON body" }, { status: 422 }) + } + try { + const runId = await taskCreate( + { + title: body.title ?? "", + body: (body.body ?? "").slice(0, 4000), + price: body.price ?? null, + address: body.address ?? null, + source: body.source ?? "", + }, + SPAM_SCHEMA, + TASK_SPAM_PROCESSOR, + ) + return NextResponse.json({ runId }) + } catch (e) { + return NextResponse.json( + { detail: e instanceof Error ? e.message : "verify create failed" }, { status: 502 }, + ) + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/apple-icon.tsx b/typescript-recipes/parallel-apartment-finder/src/app/apple-icon.tsx new file mode 100644 index 0000000..02a7d0d --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/apple-icon.tsx @@ -0,0 +1,17 @@ +import { ImageResponse } from "next/og" +import { MARK_DATA_URI } from "./og-mark" + +export const runtime = "edge" +export const size = { width: 180, height: 180 } +export const contentType = "image/png" + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ +
+ ), + size, + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/docs/page.tsx b/typescript-recipes/parallel-apartment-finder/src/app/docs/page.tsx new file mode 100644 index 0000000..522cd1e --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/docs/page.tsx @@ -0,0 +1,225 @@ +"use client" + +import { useEffect, useState } from "react" +import { api } from "@/lib/api" +import { Z, FONT_HEADING, FONT_BODY, FONT_MONO } from "@/lib/palette" +import type { AppConfig } from "@/types" + +// Plain building blocks. Deliberately minimal styling: this page is meant to +// read like a short engineering write-up, not a landing page. + +function H2({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function P({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function Code({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function Pre({ children }: { children: React.ReactNode }) { + return ( +
+      {children}
+    
+ ) +} + +const FINDALL_BODY = `POST /v1beta/findall/runs + +{ + "objective": "Find 2 bedroom apartments for rent under + 4600 dollars per month in San Francisco, CA", + "entity_type": "apartment rental listings", + "match_conditions": [ + { "name": "is_rental_listing", + "description": "One individual unit's listing page with + its own street address. Not a search or + category page." }, + { "name": "fits_budget", + "description": "Asking rent <= $4600. If no rent shown, + treat as matched." } + ], + "enrichments": [ + { "name": "monthly_rent_usd", "description": "..." }, + { "name": "bedrooms", "description": "..." }, + { "name": "street_address", "description": "..." } + // 15 more: bathrooms, sqft, pet_policy, parking, ... + ], + "generator": "base", + "match_limit": 10 +}` + +const PIPELINE = `1. POST /api/search create the FindAll run +2. GET /api/search/{id} poll: status + matched candidates +3. POST /api/search/{id}/enrich run the 18-field enrichment +4. GET /api/search/{id} poll until enrichment settles +5. GET /api/search/{id}/finalize geocode + score, return listings` + +const TASK_BODY = `POST /v1/tasks/runs + +{ + "input": { "title": "...", "body": "...", "price": 2200, + "address": "...", "source": "craigslist.org" }, + "task_spec": { "output_schema": { "type": "json", + "json_schema": { /* 5 fact-based scam signals */ } } }, + "processor": "base" +}` + +function ConfigTable() { + const [config, setConfig] = useState(null) + + useEffect(() => { + let alive = true + fetch(api("/api/config")) + .then((r) => (r.ok ? r.json() : null)) + .then((c) => { if (alive) setConfig(c) }) + .catch(() => {}) + return () => { alive = false } + }, []) + + if (!config) return

Loading live configuration...

+ + const rows: [string, string, string][] = [ + ["Default city", config.cityShort, "CITY_SHORT"], + ["Default budget", `$${config.defaultBudget.toLocaleString()}/mo`, "SEARCH_BUDGET"], + ["FindAll generator", "base", "FINDALL_GENERATOR"], + ["Match limit", "10", "FINDALL_MATCH_LIMIT"], + ["Reference point", config.referencePoint.name, "REFERENCE_POINT_*"], + ["Aggregator stale after", `${config.staleness.aggregatorDays} days`, "STALE_AGGREGATOR_DAYS"], + ["Direct source stale after", `${config.staleness.directDays} days`, "STALE_DIRECT_DAYS"], + ] + + return ( +
+ + + {rows.map(([label, value, env]) => ( + + + + + + ))} + +
{label}{value}{env}
+
+ ) +} + +export default function DocsPage() { + return ( +
+
+
+
+ Parallel + How this was built +
+ back to search +
+
+ +
+

+ How this was built +

+

+ This is a Bay Area apartment finder built on Parallel's FindAll and Task APIs. You describe a place + in plain language and get back individual listings you can actually open, each checked against your + criteria. The whole thing is one Next.js app on Vercel. There is no backend server and no database. +

+ +

One FindAll call does the hard part

+

+ A search does not send a keyword query. It sends an objective plus a few match conditions, and FindAll + discovers candidates across the web and checks each one. The parts that matter: + {" "}match conditions decide whether a candidate is kept (booleans like + "is this one real listing?" and "is it under budget?"), and{" "} + enrichments are the 18 structured fields pulled back for each match + (rent, beds, address, and so on). Enrichments run on the Task API, one task per match. +

+
{FINDALL_BODY}
+

+ Bedroom count is intentionally not a match condition. Strict conditions cause zero-match runs when the + API cannot verify them from page text, so beds come back as an enrichment and get filtered in code. +

+ +

The app is a stateless client driving the run

+

+ FindAll runs are asynchronous, so the browser drives each search through short serverless calls. The + server holds no state between them. The only thing that persists is the user's saved shortlist, kept + in their own browser via localStorage. +

+
{PIPELINE}
+ +

Verified does not mean openable

+

+ This was the part that took the most work. FindAll verifying a candidate means the page matched the + conditions. It does not guarantee a link a person can click and rent from. The gap between those two is + most of the application: +

+

+ Category and search pages match "describes rentals" but you cannot rent them, so the match condition + rejects index pages and a URL guard filters search or category paths. A few aggregators + ({" "}zillow.com, yelp.com, loopnet.com, crexi.com) + bot-wall the real listing, so they are blocked rather than sending someone to a dead link. And when a + candidate carries several URLs, the app picks the most specific individual one instead of a browse page. +

+ +

Discovery is variable, so plan for thin runs

+

+ A thin query sometimes comes back mostly category pages and finalizes near-empty, while the same query a + minute later returns six. Two guards handle it. If discovery never fills its match limit (a rare or + over-constrained query), the app proceeds to enrichment once it has run long enough with at least one + match, rather than waiting forever. And if a run that actually completed still finalizes with almost + nothing, it runs one more fresh pass and keeps whichever found more. +

+

+ Discovery plus per-listing enrichment is inherently a multi-minute operation, so the UI shows a timer and + streams results in as they verify. generator: base is the right tier for a broad + city-wide query; core and pro search harder for rarer, more specific ones. +

+ +

A second, targeted check with the Task API

+

+ For listings from untrusted sources, a fraud check runs on the Task API directly (the same API that + powers the enrichments). The schema asks for concrete signals rather than a vague "is this a scam" + score, and the signals are weighted in code. +

+
{TASK_BODY}
+ +

Scoring

+

+ Each listing gets a 0 to 100 score from price fit and proximity to a reference point (with a bedroom-fit + penalty used only for ranking). Because results are fetched fresh on every search, staleness is handled + separately: listings the API reports inactive, or past a freshness window, are flagged and hidden by + default. +

+ +

Live configuration

+

Everything is env-driven. These are the values this instance is running with right now:

+ + + +
+
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/error.tsx b/typescript-recipes/parallel-apartment-finder/src/app/error.tsx new file mode 100644 index 0000000..73ccde4 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/error.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useEffect } from "react" +import { Z, FONT_HEADING, FONT_BODY } from "@/lib/palette" + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error("Uncaught error:", error) + }, [error]) + + return ( +
+
+
+ Something went wrong +
+

+ {error.message || "An unexpected error occurred. Please try again."} +

+ +
+
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/favicon.ico b/typescript-recipes/parallel-apartment-finder/src/app/favicon.ico new file mode 100644 index 0000000..48bf1a1 Binary files /dev/null and b/typescript-recipes/parallel-apartment-finder/src/app/favicon.ico differ diff --git a/typescript-recipes/parallel-apartment-finder/src/app/globals.css b/typescript-recipes/parallel-apartment-finder/src/app/globals.css new file mode 100644 index 0000000..50bec0b --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/globals.css @@ -0,0 +1,199 @@ +@import "tailwindcss"; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; + +/* Discovery-running visual: the app mark's unit grid with an orange pulse + sweeping unit to unit (FindAll checking apartments). Pure CSS on the GPU + compositor: no re-renders, no bundle cost. Stagger via animation-delay. */ +@keyframes af-unit-scan { + 0%, 60%, 100% { background-color: #E8C4AC; } + 15%, 35% { background-color: #FB631B; } +} +.af-scan-cell { + width: 4px; + height: 4px; + border-radius: 1px; + background-color: #E8C4AC; + animation: af-unit-scan 1.35s cubic-bezier(0.8, 0.45, 0.2, 1) infinite; +} +@media (prefers-reduced-motion: reduce) { + .af-scan-cell { animation: none; background-color: #FB631B; } +} + +/* Discovery field: a block of unit cells swept by a scan pulse while FindAll + evaluates candidates. Idle cells breathe grey -> neural; verified cells lock + to signal orange with a pop. All compositor-only CSS. */ +@keyframes af-field-scan { + 0%, 70%, 100% { background-color: #EEEEEE; } + 20%, 45% { background-color: #D8D0BF; } +} +.af-field-cell { + width: 100%; + aspect-ratio: 1; + border-radius: 2px; + background-color: #EEEEEE; + animation: af-field-scan 2.2s cubic-bezier(0.8, 0.45, 0.2, 1) infinite; +} +@keyframes af-cell-pop { + 0% { transform: scale(0.4); } + 60% { transform: scale(1.15); } + 100% { transform: scale(1); } +} +.af-field-cell.af-verified { + background-color: #FB631B; + animation: af-cell-pop 0.5s cubic-bezier(0.8, 0.45, 0.2, 1) both; + display: flex; + align-items: center; + justify-content: center; +} +/* Verified cells carry the app's house mark (off-white on the signal-orange + tile) so a match reads as "a home found", echoing the logo. */ +.af-field-cell.af-verified svg { + width: 64%; + height: 64%; + display: block; +} +@media (prefers-reduced-motion: reduce) { + .af-field-cell { animation: none; } + .af-field-cell.af-verified { animation: none; background-color: #FB631B; } +} + +.dark-popup .leaflet-popup-content-wrapper { + background: oklch(0.205 0 0); + border: 1px solid oklch(1 0 0 / 10%); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} +.dark-popup .leaflet-popup-tip { + background: oklch(0.205 0 0); +} +.dark-popup .leaflet-popup-close-button { + color: oklch(0.708 0 0); +} + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/icon.svg b/typescript-recipes/parallel-apartment-finder/src/app/icon.svg new file mode 100644 index 0000000..a8ab5f1 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/typescript-recipes/parallel-apartment-finder/src/app/layout.tsx b/typescript-recipes/parallel-apartment-finder/src/app/layout.tsx new file mode 100644 index 0000000..c0fb1d2 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next" +import "./globals.css" +import "leaflet/dist/leaflet.css" +import { ConfigProvider } from "@/providers/config-provider" +import { buildAppConfig } from "@/lib/server/app-config" + +const TITLE = "Bay Area Apartment Finder" +const DESCRIPTION = "Find your Bay Area rental in your own words. AI-powered apartment search with cited sources." + +export const metadata: Metadata = { + metadataBase: new URL("https://apartment-finder-web.vercel.app"), + title: TITLE, + description: DESCRIPTION, + openGraph: { + title: TITLE, + description: DESCRIPTION, + siteName: "Apartment Finder", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: TITLE, + description: DESCRIPTION, + }, +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {/* Map tiles come from cartocdn's a/b/c shards; warm the connections + so the first map open skips DNS+TLS setup. */} + + + + + {children} + + + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/og-mark.ts b/typescript-recipes/parallel-apartment-finder/src/app/og-mark.ts new file mode 100644 index 0000000..6af81dc --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/og-mark.ts @@ -0,0 +1,18 @@ +// The app mark inlined as a data URI so the OG/apple icon routes can draw it +// without filesystem or network access. Keep in sync with public/app-icon.svg. +const MARK_SVG = ` + + + + + + + + + + + + +` + +export const MARK_DATA_URI = `data:image/svg+xml,${encodeURIComponent(MARK_SVG)}` diff --git a/typescript-recipes/parallel-apartment-finder/src/app/opengraph-image.tsx b/typescript-recipes/parallel-apartment-finder/src/app/opengraph-image.tsx new file mode 100644 index 0000000..03fc502 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/opengraph-image.tsx @@ -0,0 +1,115 @@ +import { ImageResponse } from "next/og" +import { MARK_DATA_URI } from "./og-mark" + +export const runtime = "edge" +export const alt = "Apartment Finder: find your Bay Area rental in your own words" +export const size = { width: 1200, height: 630 } +export const contentType = "image/png" + +// Social share card ("m3"): the search shown inside an app window with a live +// result chip, over a warm spotlight and a faint wall of candidate units (one +// lit orange). Built in next/og (Satori) primitives: flexbox + gradients + +// box-shadow only, no CSS grid. +export default function OpenGraphImage() { + const OFF = "#FCFCFA", INK = "#1D1B16", ORANGE = "#FB631B", GREY = "#858483", BORDER = "#E5E5E5" + + // Faint wall of unit cells in the top-right, one lit orange. + const CW = 8, CR = 5, CS = 46, CSZ = 34 + const wall: React.ReactNode[] = [] + for (let r = 0; r < CR; r++) { + for (let c = 0; c < CW; c++) { + const lit = c === 5 && r === 1 + wall.push( +
, + ) + } + } + + return new ImageResponse( + ( +
+ {/* warm spotlight */} +
+ {/* faint unit wall */} +
{wall}
+ + {/* lockup */} +
+ +
Apartment Finder
+
+ + {/* headline */} +
+ Find your Bay Area rental in your own words. +
+ + {/* app window */} +
+ {/* window chrome */} +
+
+
+
+
+
+ {/* search box */} +
+
2 bedroom in the Mission…
+
SEARCH
+
+ {/* result chip */} +
+
+
3330 20th St
+
$4,400
+
+
+
+
2 BD · MISSION · VERIFIED
+
+
+
+
+ + {/* credit */} +
+
+
AI SEARCH WITH CITED SOURCES · POWERED BY PARALLEL
+
+ + {/* orange bar */} +
+
+ ), + size, + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/app/page.tsx b/typescript-recipes/parallel-apartment-finder/src/app/page.tsx new file mode 100644 index 0000000..40aad24 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/app/page.tsx @@ -0,0 +1,5 @@ +import DemoApp from "@/components/demo-app" + +export default function HomePage() { + return +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/demo-app.tsx b/typescript-recipes/parallel-apartment-finder/src/components/demo-app.tsx new file mode 100644 index 0000000..30bca6a --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/demo-app.tsx @@ -0,0 +1,670 @@ +"use client" + +import { useState, useCallback, useMemo, useRef, useEffect } from "react" +import dynamic from "next/dynamic" +import { useConfigState } from "@/providers/config-provider" +import { useSearch } from "@/hooks/use-search" +import { useSavedTargets } from "@/hooks/use-saved-targets" +import { Header } from "@/components/layout/header" +import { Footer } from "@/components/layout/footer" +import { SearchBar } from "@/components/search/search-bar" +import { SearchStatus } from "@/components/search/search-status" +import { SearchSuggestions } from "@/components/search/search-suggestions" +import { DiscoveryField } from "@/components/search/discovery-field" +import { searchAudio } from "@/lib/search-audio" +import { StatsBar } from "@/components/stats/stats-bar" +import { ReasoningPanel } from "@/components/reasoning/reasoning-panel" +import { ListingGrid } from "@/components/listings/listing-grid" +import { Z, FONT_HEADING, FONT_BODY } from "@/lib/palette" +import { extractNeighborhoodsFromQuery } from "@/lib/neighborhoods" +import { cityByName, cityInQuery } from "@/lib/bay-area" +import { useSources } from "@/hooks/use-sources" +import type { AppConfig, Listing, ViewMode } from "@/types" + +const ApartmentMap = dynamic( + () => import("@/components/map/apartment-map").then((m) => m.ApartmentMap), + { ssr: false } +) + +const WORD_TO_NUM: Record = { + studio: 0, zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, +} + +function extractBedsFromQuery(query: string): number | null { + if (/\bstudio\b/i.test(query)) return 0 + const m = query.match(/\b(\d+|one|two|three|four|five|six)\s*[-+]?\s*(?:br|bed|bedroom|bedrooms)\b/i) + if (!m) return null + const w = m[1].toLowerCase() + if (WORD_TO_NUM[w] != null) return WORD_TO_NUM[w] + const n = parseInt(w) + return Number.isFinite(n) ? n : null +} + +function realisticFloor(beds: number | null, floors: Record): number | null { + if (beds == null) return null + return floors[String(beds)] ?? floors[String(Math.min(beds, 5))] ?? null +} + +function makeIsStale( + staleness: { aggregatorSources: string[]; aggregatorDays: number; directDays: number }, +) { + const aggregators = new Set(staleness.aggregatorSources) + return (l: Listing): boolean => { + const det = l.details ?? {} + if (det.is_currently_active === false) return true + + const dom = (det as { days_on_market?: number }).days_on_market + if (typeof dom === "number" && Number.isFinite(dom)) { + const limit = aggregators.has(l.source) ? staleness.aggregatorDays : staleness.directDays + return dom > limit + } + + const referenceTs = + (det as { monitor_event_date?: string }).monitor_event_date ?? l.fetched_at ?? null + if (!referenceTs) return false + const ageDays = (Date.now() - new Date(referenceTs).getTime()) / 86_400_000 + if (!Number.isFinite(ageDays)) return false + const limit = aggregators.has(l.source) ? staleness.aggregatorDays : staleness.directDays + return ageDays > limit + } +} + +function extractBudgetFromQuery(query: string): number | null { + const dollar = query.match(/\$\s*([\d,]+(?:\.\d+)?)\s*(k)?/i) + if (dollar) { + let n = parseFloat(dollar[1].replace(/,/g, "")) + if (dollar[2]) n *= 1000 + if (n >= 500 && n <= 30000) return Math.round(n) + } + const ctx = query.match( + /\b(?:under|below|max(?:imum)?|less\s+than|up\s+to|cheaper\s+than|<=?)\s+\$?([\d,]+(?:\.\d+)?)\s*(k)?\b/i, + ) + if (ctx) { + let n = parseFloat(ctx[1].replace(/,/g, "")) + if (ctx[2]) n *= 1000 + if (n >= 500 && n <= 30000) return Math.round(n) + } + const kBare = query.match(/(?= 500 && n <= 30000) return Math.round(n) + } + return null +} + +// Minimum square footage from phrases like "1000 sq ft", "1,200 sqft", +// "900+ square feet". Bare "sf" is intentionally not matched — it collides +// with "SF" (San Francisco). +function extractSqftFromQuery(query: string): number | null { + const m = query.match(/(\d[\d,]{2,})\s*\+?\s*(?:sq\s*\.?\s*ft\.?|sqft|square\s+f(?:ee|oo)t)\b/i) + if (!m) return null + const n = parseInt(m[1].replace(/,/g, ""), 10) + return Number.isFinite(n) && n >= 100 && n <= 20000 ? n : null +} + +// Fold a parsed min-sqft into the free-text requirements handed to FindAll so +// the objective actually asks for it (there's no dedicated sqft filter). +function withSqft(requirements: string, sqft: number | null): string | undefined { + const parts = [requirements.trim(), sqft ? `at least ${sqft} square feet` : ""].filter(Boolean) + return parts.length ? parts.join(". ") : undefined +} + +function defaultBudgetForBeds(beds: number | null, floors: Record, fallback: number): number { + const floor = realisticFloor(beds, floors) + // Default budgets err on the HIGH side (1.5x the entry-level floor): a + // too-low default filters out most real inventory, while a generous one + // still ranks cheaper units first (price fit is a scoring signal). + return floor != null ? Math.round(floor * 1.5 / 250) * 250 : fallback +} + +function CenteredScreen({ title, body, color }: { title: string; body: string; color?: string }) { + return ( +
+
+
+ {title} +
+

+ {body} +

+
+
+ ) +} + +function SparkleIcon({ size = 14, color = "white" }: { size?: number; color?: string }) { + return ( + + + + ) +} + +export default function DemoApp() { + const { config, loading, error: configError } = useConfigState() + + if (loading) { + return + } + if (configError) { + return ( + + ) + } + if (!config) { + return + } + return +} + +function DemoAppInner({ config }: { config: AppConfig }) { + const [city, setCity] = useState(config.cityShort) + const [requirements, setRequirements] = useState("") + + const [view, setView] = useState("list") + const [hoveredId, setHoveredId] = useState(null) + const cardListRef = useRef(null) + + const { + query, setQuery, + reasoning, streaming, listings, error, done, phase, startedAt, progress, + fraudChecking, runFraudCheck, + startSearch, + } = useSearch() + + // Procedural "AI searching" sound. Default on; preference persists. + const [soundOn, setSoundOn] = useState(true) + useEffect(() => { + if (localStorage.getItem("apartment-finder-sound") === "off") { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSoundOn(false) + searchAudio.setMuted(true) + } + }, []) + const toggleSound = useCallback(() => { + setSoundOn((on) => { + const next = !on + searchAudio.setMuted(!next) + localStorage.setItem("apartment-finder-sound", next ? "on" : "off") + return next + }) + }, []) + // Blip each time a new candidate verifies; stop (with a chime on success) + // when the run ends. start() is called from the click handlers so the + // AudioContext resumes within a user gesture. + const prevMatchedRef = useRef(0) + useEffect(() => { + if (streaming && progress.matched > prevMatchedRef.current) { + for (let n = prevMatchedRef.current; n < progress.matched; n++) searchAudio.tick(n) + } + prevMatchedRef.current = progress.matched + }, [progress.matched, streaming]) + const wasStreamingRef = useRef(false) + useEffect(() => { + if (wasStreamingRef.current && !streaming) searchAudio.stop(done) + wasStreamingRef.current = streaming + }, [streaming, done]) + + const { saved, isSaved, toggleSave, clearSaved } = useSavedTargets() + const sources = useSources() + + // Warm the Leaflet map chunk while the user is idle so toggling to the map + // view doesn't pay the dynamic-import cost. + useEffect(() => { + const warm = () => { void import("@/components/map/apartment-map") } + if ("requestIdleCallback" in window) { + const id = window.requestIdleCallback(warm) + return () => window.cancelIdleCallback(id) + } + const t = setTimeout(warm, 2500) + return () => clearTimeout(t) + }, []) + + const bayCity = useMemo(() => cityByName(city), [city]) + const cityFloors = bayCity?.rentFloors ?? config.rentFloors + // Center the map (and its reference-point marker) on the selected city. + const mapConfig = useMemo( + () => bayCity + ? { ...config, mapCenter: bayCity.center, mapZoom: bayCity.zoom, referencePoint: bayCity.referencePoint } + : config, + [bayCity, config], + ) + + const parsedBeds = useMemo(() => extractBedsFromQuery(query), [query]) + const parsedBudget = useMemo(() => extractBudgetFromQuery(query), [query]) + const parsedNeighborhoods = useMemo(() => extractNeighborhoodsFromQuery(query, city), [query, city]) + const parsedSqft = useMemo(() => extractSqftFromQuery(query), [query]) + + // If the query names a Bay Area city (case-insensitive, e.g. "Palo Alto + // homes in the bubble"), switch the dropdown to it. Reacts to the query text + // only, so a manual city pick sticks until the query changes again. + useEffect(() => { + const detected = cityInQuery(query) + // Deriving the selected city from the query text is the intended effect, + // not a cascading-render bug. + // eslint-disable-next-line react-hooks/set-state-in-effect + if (detected && detected.label !== city) setCity(detected.label) + // React to query only, so a manual city pick sticks until the query changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]) + const effectiveBudget = useMemo( + () => parsedBudget ?? defaultBudgetForBeds(parsedBeds, cityFloors, config.defaultBudget), + [parsedBudget, parsedBeds, cityFloors, config.defaultBudget], + ) + + const isStale = useMemo(() => makeIsStale(config.staleness), [config.staleness]) + + const handleMarkerClick = useCallback((id: string) => { + setHoveredId(id) + const el = cardListRef.current?.querySelector(`[data-listing-id="${id}"]`) + if (el) (el as HTMLElement).scrollIntoView({ behavior: "smooth", block: "center" }) + }, []) + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + setView("list") + searchAudio.start() // within the click gesture, so the audio context resumes + // Resolve the city from the query text itself, not the (possibly stale) + // dropdown state, so the objective always matches what the user typed. + const searchCity = cityInQuery(query) ?? bayCity + if (searchCity && searchCity.label !== city) setCity(searchCity.label) + const hoods = extractNeighborhoodsFromQuery(query, searchCity?.label ?? city) + startSearch(query, effectiveBudget, { + city: searchCity?.full ?? city, + requirements: withSqft(requirements, parsedSqft), + neighborhoods: hoods.length ? hoods : undefined, + sources: sources.hasIncludes ? sources.includeSources : undefined, + }) + } + + const STRONG_FIT_THRESHOLD = 70 + // Same threshold the original backend used (SPAM_HIDE_THRESHOLD): one + // canonical scam signal from the Task API secondary check trips it. + const SPAM_HIDE_THRESHOLD = 50 + + const sortedListings = useMemo( + () => [...listings].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)), + [listings], + ) + + const [showAllScores, setShowAllScores] = useState(false) + const [showStale, setShowStale] = useState(false) + const [showSpam, setShowSpam] = useState(false) + + const isSpam = useCallback( + (l: Listing) => (l.spam_score ?? 0) >= SPAM_HIDE_THRESHOLD, + [SPAM_HIDE_THRESHOLD], + ) + + // How many listings clear the strong-fit bar (and aren't hidden as + // stale/spam). If none do, we don't want to render an empty page while + // weaker-but-real matches sit hidden — so auto-drop the bar in that case. + const strongFitCount = useMemo( + () => streaming ? 0 : sortedListings.filter((l) => (l.score ?? 0) >= STRONG_FIT_THRESHOLD && (showStale || !isStale(l)) && (showSpam || !isSpam(l))).length, + [sortedListings, streaming, showStale, showSpam, isStale, isSpam], + ) + const autoShowAll = !streaming && strongFitCount === 0 + const effectiveShowAll = showAllScores || autoShowAll + // When nothing clears the strong-fit bar, revealing stale listings too keeps + // us from rendering an empty page while real (if older) aggregator listings + // sit hidden. They still carry the "stale?" badge. Spam stays hidden: it's a + // safety signal, not a freshness one. + const effectiveShowStale = showStale || autoShowAll + + const filteredListings = useMemo(() => { + return sortedListings.filter((l) => { + // While a search is streaming, show every verified card in live time — + // provisional scores lack proximity/price points until finalize, so the + // strong-fit bar only applies once the run completes. + if (!streaming && !effectiveShowAll && (l.score ?? 0) < STRONG_FIT_THRESHOLD) return false + if (!effectiveShowStale && isStale(l)) return false + if (!showSpam && isSpam(l)) return false + return true + }) + }, [sortedListings, streaming, effectiveShowAll, effectiveShowStale, showSpam, isStale, isSpam]) + + const hiddenLowScoreCount = useMemo( + () => (streaming || effectiveShowAll) ? 0 : sortedListings.filter((l) => (l.score ?? 0) < STRONG_FIT_THRESHOLD && (showStale || !isStale(l)) && (showSpam || !isSpam(l))).length, + [sortedListings, streaming, effectiveShowAll, showStale, showSpam, isStale, isSpam], + ) + const hiddenStaleCount = useMemo( + () => effectiveShowStale ? 0 : sortedListings.filter((l) => isStale(l) && (effectiveShowAll || (l.score ?? 0) >= STRONG_FIT_THRESHOLD)).length, + [sortedListings, effectiveShowStale, effectiveShowAll, isStale], + ) + const hiddenSpamCount = useMemo( + () => sortedListings.filter((l) => isSpam(l)).length, + [sortedListings, isSpam], + ) + + const savedSorted = useMemo( + () => [...saved].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)), + [saved], + ) + + const showingSaved = view === "saved" + const visibleListings = showingSaved ? savedSorted : filteredListings + + // On the map, always fold in saved targets alongside the current results + // (deduped) so the shortlist stays pinned for reference; saved markers are + // rendered distinctly (orange, starred) in ApartmentMap. + const savedIdSet = useMemo(() => new Set(saved.map((s) => s.id)), [saved]) + const mapListings = useMemo(() => { + const byId = new Map() + for (const l of visibleListings) byId.set(l.id, l) + for (const s of saved) if (!byId.has(s.id)) byId.set(s.id, s) + return [...byId.values()] + }, [visibleListings, saved]) + + const floor = useMemo(() => realisticFloor(parsedBeds, cityFloors), [parsedBeds, cityFloors]) + const budgetLikelyTooLow = floor != null && parsedBudget != null && parsedBudget < floor + + const hasActivity = streaming || !!reasoning || listings.length > 0 || saved.length > 0 + + return ( +
+
+ + {/* Hero / search */} +
+ {/* Once a search is active, collapse the hero copy on mobile so results + aren't pushed two screens down; desktop keeps the full hero. */} +
+ {/* Brand credit: a real link to parallel.ai, visible on mobile and + desktop, including mid-search. */} + + + + Powered by Parallel + + +

+ Find your Bay Area rental in your own words. +

+

+ Describe what you want like you'd tell a friend. The assistant searches the web + across San Francisco, the East Bay, and the Peninsula, + verifies every match against your criteria, and returns each result with cited sources. + No more couch-surfing. +

+ + + +
+ { + setQuery(s) + searchAudio.start() // within the click gesture + // Parse everything from the clicked suggestion itself. State + // (city, effectiveBudget) still reflects the previous query + // during this event, so resolve the city from `s` directly — + // otherwise the search fires against the old city. + const selCity = cityInQuery(s) ?? bayCity + if (selCity && selCity.label !== city) setCity(selCity.label) + const beds = extractBedsFromQuery(s) + const budget = extractBudgetFromQuery(s) + ?? defaultBudgetForBeds(beds, selCity?.rentFloors ?? cityFloors, config.defaultBudget) + const hoods = extractNeighborhoodsFromQuery(s, selCity?.label ?? city) + startSearch(s, budget, { + city: selCity?.full ?? city, + requirements: withSqft(requirements, extractSqftFromQuery(s)), + neighborhoods: hoods.length ? hoods : undefined, + sources: sources.hasIncludes ? sources.includeSources : undefined, + }) + }} + parsedBeds={parsedBeds} + parsedBudget={parsedBudget} + parsedNeighborhoods={parsedNeighborhoods} + parsedSqft={parsedSqft} + effectiveBudget={effectiveBudget} + budgetLikelyTooLow={budgetLikelyTooLow} + floor={floor} + city={city} + query={query} + onQueryChange={setQuery} + /> +
+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {hasActivity ? ( + <> + {!showingSaved && ( + + )} +
+
+ +
+ {done && listings.some((l) => l.needs_verification) && ( + + )} + +
+ + {showingSaved && ( +
+ {savedSorted.length === 0 ? ( +
+

No saved targets yet

+

+ Run a search and hit Save on the apartments you want to keep. They're stored only in this browser. +

+
+ ) : ( + <> +
+ + {savedSorted.length} saved {savedSorted.length === 1 ? "target" : "targets"} · kept in this browser only + + +
+ setHoveredId(id)} + onLeave={() => setHoveredId(null)} + // While a run is in flight, saved cards stay inert too: a + // candidate saved mid-run must not be clickable until the + // FindAll run completes. + streaming={streaming} + hiddenLowScoreCount={0} + hiddenStaleCount={0} + hiddenSpamCount={0} + showAllScores + showStale + showSpam + onToggleScores={() => {}} + onToggleStale={() => {}} + onToggleSpam={() => {}} + /> + + )} +
+ )} + + {view === "list" && ( +
+ {/* On mobile, results come first; the process log follows. */} +
+ +
+
+ {/* While discovery runs with nothing to show, the field IS the + loading state; the grid's skeletons would double it up. */} + {streaming && visibleListings.length === 0 ? ( + + ) : ( + setHoveredId(id)} + onLeave={() => setHoveredId(null)} + streaming={streaming} + fraudChecking={fraudChecking} + hiddenLowScoreCount={hiddenLowScoreCount} + hiddenStaleCount={hiddenStaleCount} + hiddenSpamCount={hiddenSpamCount} + showAllScores={effectiveShowAll} + autoShowAll={autoShowAll} + showStale={showStale} + showSpam={showSpam} + onToggleScores={() => setShowAllScores((v) => !v)} + onToggleStale={() => setShowStale((v) => !v)} + onToggleSpam={() => setShowSpam((v) => !v)} + /> + )} +
+
+ )} + + {view === "map" && ( + + )} + + ) : null} +
+ +
+
+ ) +} + +function ViewToggle({ view, onChange, savedCount }: { view: ViewMode; onChange: (v: ViewMode) => void; savedCount: number }) { + const opts: { value: ViewMode; label: string; icon: React.ReactNode }[] = [ + { value: "list", label: "List", icon: ( + + )}, + { value: "map", label: "Map", icon: ( + + )}, + { value: "saved", label: savedCount > 0 ? `Saved ${savedCount}` : "Saved", icon: ( + + )}, + ] + return ( +
+ {opts.map((o) => { + const active = view === o.value + return ( + + ) + })} +
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/layout/footer.tsx b/typescript-recipes/parallel-apartment-finder/src/components/layout/footer.tsx new file mode 100644 index 0000000..93160d2 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/layout/footer.tsx @@ -0,0 +1,22 @@ +"use client" + +import { Z, FONT_MONO } from "@/lib/palette" + +// The brand credit is a real link to parallel.ai (official symbol + mono +// label), shown on every view. +export function Footer() { + return ( + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/layout/header.tsx b/typescript-recipes/parallel-apartment-finder/src/components/layout/header.tsx new file mode 100644 index 0000000..409fd0a --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/layout/header.tsx @@ -0,0 +1,49 @@ +"use client" + +import { Z, FONT_HEADING } from "@/lib/palette" +import type { AppConfig } from "@/types" + +function SparkleIcon({ size = 14, color = "white" }: { size?: number; color?: string }) { + return ( + + + + ) +} + +interface HeaderProps { + config: AppConfig +} + +export function Header({ config }: HeaderProps) { + return ( +
+
+
+ {config.brand.logoUrl ? ( + {config.brand.name} + ) : ( + + {config.brand.name} + + )} +
+ + + How this was built → + How it's built → + +
+
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/listings/citations.tsx b/typescript-recipes/parallel-apartment-finder/src/components/listings/citations.tsx new file mode 100644 index 0000000..f9ac627 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/listings/citations.tsx @@ -0,0 +1,58 @@ +"use client" + +import { Z } from "@/lib/palette" +import { safeUrl } from "@/lib/utils" +import { isIndividualListingUrl } from "@/lib/listing-url" +import type { Listing } from "@/types" + +export function Citations({ listing, disabled = false }: { listing: Listing; disabled?: boolean }) { + // Only cite pages we can link to directly — skip bare domains and + // search/category pages so a source click always lands on a real listing. + const cites = listing.citations?.filter((c) => isIndividualListingUrl(c.url)) ?? [] + if (!cites.length) return null + return ( +
+ + Sources + + {cites.map((c, i) => { + let host = c.url + try { host = new URL(c.url).hostname.replace(/^www\./, "") } catch { /* keep */ } + const badge = ( + + {i + 1} + + ) + // While the listing is an unverified candidate, sources are shown but + // not clickable — the URLs may change or drop out of the run. + if (disabled) { + return ( + + {badge} + {host} + + ) + } + return ( + + {badge} + {host} + + ) + })} +
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/listings/listing-card.tsx b/typescript-recipes/parallel-apartment-finder/src/components/listings/listing-card.tsx new file mode 100644 index 0000000..fabd5c8 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/listings/listing-card.tsx @@ -0,0 +1,235 @@ +"use client" + +import { Z, FONT_HEADING } from "@/lib/palette" +import { pickSourceUrl } from "@/lib/utils" +import { MatchPills } from "./match-pills" +import { Citations } from "./citations" +import type { Listing } from "@/types" + +function scorePalette(score: number | null | undefined) { + if (score == null) return { fg: Z.textFaint, bg: Z.bgSubtle, border: Z.border } + if (score >= 70) return { fg: Z.green, bg: Z.greenSoft, border: "#BAE0C2" } + if (score >= 45) return { fg: Z.amber, bg: "#FFF4E0", border: "#F7D9A8" } + return { fg: Z.red, bg: Z.redSoft, border: "#F4B5B5" } +} + +interface ListingCardProps { + l: Listing + idx: number + city: string + stale: boolean + saved: boolean + candidate?: boolean + fraudChecking?: boolean + onToggleSave: () => void + isHovered?: boolean + onHover?: () => void + onLeave?: () => void +} + +export function ListingCard({ + l, idx, city, stale, saved, candidate, fraudChecking, onToggleSave, isHovered, onHover, onLeave, +}: ListingCardProps) { + // Always link to the specific source page, never a bare domain/homepage: + // prefer the listing URL, then a deep citation, then an address search. + const searchFallback = `https://www.google.com/search?q=${encodeURIComponent(`${l.address ?? l.title ?? ""} rent ${city}`)}` + const href = pickSourceUrl(l.url, l.citations, searchFallback) + const scoreP = scorePalette(l.score) + return ( +
+
+ + {idx + 1} + + {l.neighborhood && ( + + + {l.neighborhood}{l.geo_precision === "neighborhood" ? " ≈" : ""} + + )} + + {l.source} + + {l.score != null && ( + + {l.score}/100 + + )} + {candidate && ( + + candidate + + )} + {stale && ( + + stale? + + )} + {l.needs_verification && fraudChecking && ( + + checking… + + )} + {l.spam_flags != null && ( + (l.spam_score ?? 0) > 0 ? ( + + ⚠ fraud: {l.spam_score} + + ) : ( + + ✓ fraud: clear + + ) + )} + +
+ +
+ {/* Candidates aren't clickable: their URLs haven't passed verification + yet and may change or drop out. The link activates once verified. */} + {candidate ? ( + + {l.address ?? l.title ?? "—"} + + ) : ( + + {l.address ?? l.title ?? "—"} + + )} +
+
+ {l.price ? `$${l.price.toLocaleString()}` : "—"} +
+ {l.price && ( +
+ per month +
+ )} +
+
+ +
+ {l.bedrooms ?? "?"} + bd + {l.bathrooms != null && (<>·{l.bathrooms}ba)} + {l.sqft != null && (<>·{l.sqft.toLocaleString()}sqft)} + {l.has_parking && (<>·parking)} + {l.has_laundry && (<>·laundry)} +
+ + {(l.details?.available_date || l.details?.lease_term || l.details?.pet_policy || l.details?.utilities_included || l.details?.is_furnished) && ( +
+ {l.details?.available_date && Available {l.details.available_date}} + {l.details?.lease_term && Lease {l.details.lease_term}} + {l.details?.pet_policy && Pets {l.details.pet_policy}} + {l.details?.utilities_included && Utilities {l.details.utilities_included}} + {l.details?.is_furnished === true && Furnished} +
+ )} + + + +
+ ) +} + +function StarIcon({ filled }: { filled: boolean }) { + return ( + + + + ) +} + +function PinIcon({ size = 12, color = Z.textFaint }: { size?: number; color?: string }) { + return ( + + + + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/listings/listing-grid.tsx b/typescript-recipes/parallel-apartment-finder/src/components/listings/listing-grid.tsx new file mode 100644 index 0000000..790f8a7 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/listings/listing-grid.tsx @@ -0,0 +1,183 @@ +"use client" + +import { Z } from "@/lib/palette" +import { ListingCard } from "./listing-card" +import type { Listing } from "@/types" + +interface ListingGridProps { + listings: Listing[] + city: string + isStale: (l: Listing) => boolean + isSaved: (l: Listing) => boolean + onToggleSave: (l: Listing) => void + hoveredId: string | null + onHover: (id: string) => void + onLeave: () => void + streaming: boolean + fraudChecking?: boolean + hiddenLowScoreCount: number + hiddenStaleCount: number + hiddenSpamCount: number + showAllScores: boolean + autoShowAll?: boolean + showStale: boolean + showSpam: boolean + onToggleScores: () => void + onToggleStale: () => void + onToggleSpam: () => void +} + +export function ListingGrid({ + listings, city, isStale, isSaved, onToggleSave, + hoveredId, onHover, onLeave, streaming, fraudChecking, + hiddenLowScoreCount, hiddenStaleCount, hiddenSpamCount, + showAllScores, autoShowAll, showStale, showSpam, + onToggleScores, onToggleStale, onToggleSpam, +}: ListingGridProps) { + return ( +
+ {autoShowAll && listings.length > 0 && ( +
+ None of these cleared the strong-fit bar, so we're showing every + match ranked by fit — rather than an empty list. +
+ )} + {listings.length === 0 && streaming && (<>)} + {listings.length === 0 && !streaming && ( +
+

+ No matching listings found +

+

+ Try a broader query, a higher budget, or a different area{city ? ` in ${city}` : ""}. +

+
+ )} + {listings.map((l, i) => ( + onToggleSave(l)} + isHovered={hoveredId === l.id} + onHover={() => onHover(l.id)} + onLeave={onLeave} + /> + ))} +
+ {!autoShowAll && ( + + )} + + +
+
+ ) +} + +function SpamToggle({ hiddenCount, showSpam, onToggle }: { hiddenCount: number; showSpam: boolean; onToggle: () => void }) { + if (hiddenCount === 0 && !showSpam) return null + return ( + + ) +} + +// Mirrors the real ListingCard layout — no photo block, since result cards +// never render images. +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +function HiddenScoresToggle({ hiddenCount, showAll, onToggle }: { hiddenCount: number; showAll: boolean; onToggle: () => void }) { + if (hiddenCount === 0 && !showAll) return null + return ( + + ) +} + +function StaleToggle({ hiddenCount, showStale, onToggle }: { hiddenCount: number; showStale: boolean; onToggle: () => void }) { + if (hiddenCount === 0 && !showStale) return null + return ( + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/listings/match-pills.tsx b/typescript-recipes/parallel-apartment-finder/src/components/listings/match-pills.tsx new file mode 100644 index 0000000..067a7ae --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/listings/match-pills.tsx @@ -0,0 +1,43 @@ +"use client" + +import { Z } from "@/lib/palette" +import type { Listing } from "@/types" + +export function MatchPills({ listing }: { listing: Listing }) { + if (!listing.match_basis?.length) return null + return ( +
+ {listing.match_basis.map((m) => { + const ok = m.matched + return ( + + {ok ? : ·} + {m.name.replaceAll("_", " ")} + {m.value && ( + + — {m.value.length > 28 ? m.value.slice(0, 28) + "…" : m.value} + + )} + + ) + })} +
+ ) +} + +function CheckIcon({ size = 11, color = Z.blue }: { size?: number; color?: string }) { + return ( + + + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/map/apartment-map.tsx b/typescript-recipes/parallel-apartment-finder/src/components/map/apartment-map.tsx new file mode 100644 index 0000000..4c0f332 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/map/apartment-map.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useEffect, useRef } from "react" +import L from "leaflet" +import { escapeHtml } from "@/lib/utils" +import type { AppConfig, Listing } from "@/types" + +const Z_BLUE = "#fb631b" +const Z_BLUE_DARK = "#1D4ED8" +const Z_GREEN = "#137333" +const Z_AMBER = "#C77700" +const Z_RED = "#C62828" + +function markerColor(score: number | null | undefined) { + if (score == null) return Z_BLUE_DARK + if (score >= 70) return Z_GREEN + if (score >= 45) return Z_AMBER + return Z_RED +} + +function priceLabel(l: Listing): string { + if (!l.price) return "—" + if (l.price >= 1000) return `$${Math.round(l.price / 100) / 10}k` + return `$${l.price}` +} + +function makePriceTag(l: Listing, hovered: boolean, saved = false) { + // Saved targets are pinned in brand orange with a star so the shortlist + // stands out from score-colored result tags; results keep score coloring. + const color = saved ? Z_BLUE : markerColor(l.score) + const label = saved ? `★ ${priceLabel(l)}` : priceLabel(l) + const filled = saved || hovered + const w = Math.max(56, Math.ceil(label.length * 7.5) + 20) + const h = 26 + return L.divIcon({ + className: "zillow-price-tag", + html: `
${label}
`, + iconSize: [w, h], + iconAnchor: [w / 2, h / 2], + }) +} + +function makeRefIcon() { + const size = 16 + return L.divIcon({ + className: "zillow-ref-pin", + html: `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }) +} + +// Neighborhood-precision listings all sit on the same centroid; spread them +// with a deterministic ~±150m jitter (hashed from the listing id) so their +// price tags don't stack into one unreadable pile. +function displayCoords(l: Listing): L.LatLngTuple { + if (l.geo_precision !== "neighborhood") return [l.lat!, l.lng!] + let h = 0 + for (let i = 0; i < l.id.length; i++) h = (h * 31 + l.id.charCodeAt(i)) | 0 + const dLat = (((h & 0xff) / 255) - 0.5) * 0.0028 + const dLng = ((((h >> 8) & 0xff) / 255) - 0.5) * 0.0028 + return [l.lat! + dLat, l.lng! + dLng] +} + +type MapProps = { + listings: Listing[] + config: AppConfig | null + hoveredId?: string | null + onMarkerClick?: (id: string) => void + savedIds?: Set + height?: number +} + +export function ApartmentMap({ listings, config, hoveredId, onMarkerClick, savedIds, height = 600 }: MapProps) { + const mapRef = useRef(null) + const containerRef = useRef(null) + const markersRef = useRef>(new Map()) + const layerRef = useRef(null) + + const centerLat = config?.mapCenter.lat ?? 39.8283 + const centerLng = config?.mapCenter.lng ?? -98.5795 + const zoom = config?.mapZoom ?? 4 + + useEffect(() => { + if (!containerRef.current || mapRef.current) return + + const markers = markersRef.current + + const map = L.map(containerRef.current, { + zoomControl: true, + attributionControl: false, + }).setView([centerLat, centerLng], zoom) + + L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", { + maxZoom: 19, + }).addTo(map) + + if (config?.referencePoint) { + L.marker([config.referencePoint.lat, config.referencePoint.lng], { icon: makeRefIcon() }) + .addTo(map) + .bindTooltip(config.referencePoint.name, { + direction: "top", + offset: [0, -8], + className: "zillow-map-tooltip", + }) + } + + layerRef.current = L.layerGroup().addTo(map) + mapRef.current = map + + return () => { + map.remove() + mapRef.current = null + markers.clear() + } + }, [centerLat, centerLng, zoom, config]) + + useEffect(() => { + if (!layerRef.current || !mapRef.current) return + layerRef.current.clearLayers() + markersRef.current.clear() + + const bounds: L.LatLngTuple[] = [] + for (const l of listings) { + if (l.lat == null || l.lng == null) continue + const pos = displayCoords(l) + bounds.push(pos) + const marker = L.marker(pos, { icon: makePriceTag(l, false, savedIds?.has(l.id)) }) + marker.addTo(layerRef.current!) + // Listing text is scraped/LLM-extracted (untrusted) and Leaflet injects + // this string as raw HTML — escape every interpolated field. + const name = escapeHtml(l.address || l.title || "—") + const price = l.price ? `$${l.price.toLocaleString()}/mo` : "—" + const beds = l.bedrooms != null ? `${l.bedrooms}bd` : "" + const approx = l.geo_precision === "neighborhood" ? "≈ neighborhood-level location" : "" + marker.bindTooltip( + `
+ ${name} + ${escapeHtml([beds, price, approx].filter(Boolean).join(" · "))} +
`, + { direction: "top", offset: [0, -8], className: "zillow-map-tooltip" }, + ) + if (onMarkerClick) { + marker.on("click", () => onMarkerClick(l.id)) + } + markersRef.current.set(l.id, marker) + } + + if (bounds.length >= 2) { + mapRef.current.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 }) + } else if (bounds.length === 1) { + mapRef.current.setView(bounds[0], 14) + } + }, [listings, onMarkerClick, savedIds]) + + useEffect(() => { + for (const [id, marker] of markersRef.current.entries()) { + const l = listings.find((x) => x.id === id) + if (!l) continue + marker.setIcon(makePriceTag(l, id === hoveredId, savedIds?.has(id))) + if (id === hoveredId) { + marker.setZIndexOffset(1000) + } else { + marker.setZIndexOffset(0) + } + } + }, [hoveredId, listings, savedIds]) + + return ( +
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/reasoning/process-timeline.tsx b/typescript-recipes/parallel-apartment-finder/src/components/reasoning/process-timeline.tsx new file mode 100644 index 0000000..8f26b9c --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/reasoning/process-timeline.tsx @@ -0,0 +1,115 @@ +"use client" + +import { Z, FONT_HEADING } from "@/lib/palette" +import type { ProcessStep } from "@/types" + +export function ProcessTimeline({ steps, streaming }: { steps: ProcessStep[]; streaming: boolean }) { + return ( +
    + {steps.map((s, i) => { + const isLast = i === steps.length - 1 + const titleColor = + s.status === "done" ? Z.text : + s.status === "active" ? Z.text : + s.status === "error" ? Z.red : Z.textFaint + const lineColor = s.status === "done" ? Z.blue : Z.border + return ( +
  1. +
    + + {!isLast && ( +
    + )} +
    +
    +
    + {s.title} +
    + {s.subtitle && ( +
    + {s.subtitle} +
    + )} + {s.detail && ( +
    + “{s.detail}” +
    + )} + {s.progress && s.status === "active" && ( + + )} +
    +
  2. + ) + })} +
+ ) +} + +function StepDot({ status, streaming }: { status: string; streaming: boolean }) { + if (status === "done") { + return ( +
+ + + +
+ ) + } + if (status === "active") { + return ( +
+ {streaming && ( + + )} + + + +
+ ) + } + if (status === "error") { + return ( +
+ + + +
+ ) + } + return ( +
+ ) +} + +function ProgressMeter({ matched, total }: { matched: number; total: number }) { + const pct = total > 0 ? Math.min(100, (matched / total) * 100) : 0 + return ( +
+
+
+
+
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/reasoning/reasoning-panel.tsx b/typescript-recipes/parallel-apartment-finder/src/components/reasoning/reasoning-panel.tsx new file mode 100644 index 0000000..d001dd5 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/reasoning/reasoning-panel.tsx @@ -0,0 +1,193 @@ +"use client" + +import { useState, useRef, useEffect, useMemo } from "react" +import { Z, FONT_HEADING, FONT_MONO } from "@/lib/palette" +import { ProcessTimeline } from "./process-timeline" +import type { ProcessStep } from "@/types" +import type { SearchPhase } from "@/hooks/use-search" + +// The timeline is driven by the run's real state (phase + live metrics), not by +// regex-scraping the log. Only the two phases that actually take time get their +// own step, each with a live subtitle so you can watch it advance: +// discover — FindAll searches the web and verifies candidates (the long one) +// extract — enrichment pulls structured fields per listing (also long) +// finalize — geocode + score (quick) +// The old "Verifying matches" (concurrent with discovery) and "Spam & quality +// check" (a separate, user-triggered fraud pass) were pass-through stages that +// only ever flashed green on completion, so they're gone. +const PHASE_STEP_INDEX: Record = { + discover: 1, + extract: 2, + finalize: 3, + done: 4, +} + +type Progress = { generated: number; matched: number; ready: number; total: number } + +function buildSteps( + phase: SearchPhase | null, + progress: Progress, + reasoning: string, + streaming: boolean, + done: boolean, +): ProcessStep[] { + const steps: ProcessStep[] = [ + { id: "understand", title: "Understanding your search", status: "pending" }, + { id: "discover", title: "Searching listings across the web", status: "pending" }, + { id: "extract", title: "Extracting price, beds & details", status: "pending" }, + { id: "finalize", title: "Mapping & scoring", status: "pending" }, + { id: "ready", title: "Ready", status: "pending" }, + ] + + // How far along the run is. Understanding is instant, so once a search has + // started (phase set) it's already at least at the discover step. + const activeIdx = done ? 4 : phase ? PHASE_STEP_INDEX[phase.key] : 1 + for (let i = 0; i < steps.length; i++) { + if (done || i < activeIdx) steps[i].status = "done" + else if (i === activeIdx) steps[i].status = "active" + else steps[i].status = "pending" + } + + // Understanding: surface the parsed objective + constraints from the log. + const objMatch = reasoning.match(/Objective:\s*([^\n]+)/) + if (objMatch) steps[0].subtitle = objMatch[1].trim() + const budgetMatch = reasoning.match(/Budget:\s*\$([\d,]+)/) + const bedsMatch = reasoning.match(/(\d+)\+\s*beds/) + if (budgetMatch || bedsMatch) { + const parts: string[] = [] + if (bedsMatch) parts.push(`${bedsMatch[1]}+ beds`) + if (budgetMatch) parts.push(`under $${budgetMatch[1]}`) + steps[0].detail = parts.join(" · ") + } + + // Discover: live candidate/verified counts as FindAll streams them. + steps[1].subtitle = progress.generated > 0 + ? `${progress.generated} listing${progress.generated === 1 ? "" : "s"} found · ${progress.matched} match your criteria` + : "Scanning listing sites across the web" + + // Extract: enrichment ripens gradually; show ready/total + a real fill bar. + const total = progress.total || 0 + steps[2].subtitle = total > 0 + ? `${progress.ready} of ${total} listing${total === 1 ? "" : "s"} ready` + : "Pulling structured fields from each listing page" + if (total > 0) steps[2].progress = { matched: progress.ready, total } + + steps[3].subtitle = "Placing results on the map and scoring by fit" + + // Ready: prefer the final count the log reports; fall back to the metric. + const doneMatch = reasoning.match(/Done\.\s*(\d+)\s*listings found/) + if (done) { + const n = doneMatch ? parseInt(doneMatch[1]) : progress.ready + steps[4].subtitle = `${n} listing${n === 1 ? "" : "s"} ready for review` + } + + // A hard failure leaves the active step mid-flight; mark it errored. + if (/error|failed/i.test(reasoning) && !done && !streaming) { + for (const s of steps) if (s.status === "active") s.status = "error" + } + + return steps +} + +interface ReasoningPanelProps { + reasoning: string + streaming: boolean + done: boolean + phase: SearchPhase | null + progress: Progress +} + +export function ReasoningPanel({ reasoning, streaming, done, phase, progress }: ReasoningPanelProps) { + const [showRaw, setShowRaw] = useState(false) + const rawRef = useRef(null) + useEffect(() => { + if (rawRef.current) rawRef.current.scrollTop = rawRef.current.scrollHeight + }, [reasoning, showRaw]) + + const steps = useMemo( + () => buildSteps(phase, progress, reasoning, streaming, done), + [phase, progress, reasoning, streaming, done], + ) + const hasActivity = streaming || reasoning.length > 0 || done + + return ( + + ) +} + +function SparkleIcon({ size = 14, color = "white" }: { size?: number; color?: string }) { + return ( + + + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/search/discovery-field.tsx b/typescript-recipes/parallel-apartment-finder/src/components/search/discovery-field.tsx new file mode 100644 index 0000000..76f78c4 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/search/discovery-field.tsx @@ -0,0 +1,114 @@ +"use client" + +import { Z, FONT_MONO } from "@/lib/palette" +import type { SearchPhase } from "@/hooks/use-search" + +// Discovery visualization shown in place of the results grid while FindAll +// runs: a city block of unit cells swept by a scan pulse, where cells lock to +// signal orange as real candidates verify. Counts are live run metrics, the +// animation is compositor-only CSS (see globals.css), so it costs nothing. + +const COLS = 12 +const ROWS = 5 +const CELLS = COLS * ROWS + +// Fixed scatter so verified cells light up across the block instead of +// filling row by row. Deterministic: same run state -> same picture. +const SCATTER = (() => { + const idx = Array.from({ length: CELLS }, (_, i) => i) + let seed = 47 + for (let i = CELLS - 1; i > 0; i--) { + seed = (seed * 1103515245 + 12345) % 2147483648 + const j = seed % (i + 1) + ;[idx[i], idx[j]] = [idx[j], idx[i]] + } + return idx +})() + +// The app's house mark (same silhouette as the icon), off-white so it reads +// cleanly on the signal-orange verified tile. +function HouseMark() { + return ( + + + + ) +} + +export function DiscoveryField({ + progress, phase, +}: { + progress: { generated: number; matched: number; ready: number; total: number } + phase: SearchPhase["key"] +}) { + const verified = new Set(SCATTER.slice(0, Math.min(progress.matched, CELLS))) + const label = phase === "extract" + ? "Extracting details" + : phase === "finalize" + ? "Mapping & scoring" + : "Scanning the web" + const counts = phase === "extract" && progress.total > 0 + ? `${progress.ready}/${progress.total} ready` + : `${progress.generated} found · ${progress.matched} verified` + + return ( +
+ {/* signature bracket lines */} + + + +
+ + {label} + + + {counts} + +
+ +
+ {Array.from({ length: CELLS }, (_, i) => { + const isVerified = verified.has(i) + return ( + + {isVerified && } + + ) + })} +
+ +
+ + Each cell is a candidate unit · orange = verified match + + + Orange = verified + + + + + FindAll + + +
+
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/search/search-bar.tsx b/typescript-recipes/parallel-apartment-finder/src/components/search/search-bar.tsx new file mode 100644 index 0000000..aabfd49 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/search/search-bar.tsx @@ -0,0 +1,248 @@ +"use client" + +import { useState } from "react" +import { Z, FONT_HEADING, FONT_BODY, FONT_MONO } from "@/lib/palette" +import { BAY_AREA_CITIES } from "@/lib/bay-area" +import { MAJOR_SOURCES } from "@/lib/sources" +import type { useSources } from "@/hooks/use-sources" + +interface SearchBarProps { + query: string + onQueryChange: (q: string) => void + city: string + onCityChange: (c: string) => void + requirements: string + onRequirementsChange: (r: string) => void + sources: ReturnType + onSubmit: (e: React.FormEvent) => void + streaming: boolean +} + +export function SearchBar({ + query, onQueryChange, + city, onCityChange, + requirements, onRequirementsChange, + sources, + onSubmit, streaming, +}: SearchBarProps) { + const [showReqs, setShowReqs] = useState(!!requirements) + const [showSources, setShowSources] = useState(false) + const [customInput, setCustomInput] = useState("") + const [customError, setCustomError] = useState(false) + + const cityKnown = BAY_AREA_CITIES.some((c) => c.label === city) + const includeCount = sources.includeSources.length + + const submitCustom = () => { + if (!customInput.trim()) return + if (sources.addCustom(customInput)) { + setCustomInput("") + setCustomError(false) + } else { + setCustomError(true) + } + } + + return ( +
+ {/* City + toggles row */} +
+ + +
+ + +
+
+ + {/* Sources row (collapsible) */} + {showSources && ( +
+
+ {MAJOR_SOURCES.map((s) => { + const on = sources.selected.includes(s.domain) + return ( + + ) + })} + {sources.custom.map((d) => ( + + ))} + + { setCustomInput(e.target.value); setCustomError(false) }} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); submitCustom() } + }} + className="text-[11px] px-2.5 py-1 rounded-full focus:outline-none w-44" + style={{ + color: Z.text, + backgroundColor: "transparent", + border: `1px dashed ${customError ? Z.red : Z.border}`, + fontFamily: FONT_BODY, + }} + /> + {customInput.trim() && ( + + )} + +
+
+ {customError + ? "That doesn't look like a website — try a plain domain like example.com" + : sources.hasIncludes + ? `Searching the whole web and making sure to include ${includeCount} ${includeCount === 1 ? "site" : "sites"}.` + : "Searches the whole web. Pick sites to make sure they're included, or add your own."} +
+
+ )} + + {/* Requirements row (collapsible) */} + {showReqs && ( +
+ onRequirementsChange(e.target.value)} + className="w-full bg-transparent text-sm focus:outline-none" + style={{ color: Z.text, fontFamily: FONT_BODY }} + /> +
+ )} + + {/* Main search bar */} +
+
+ + onQueryChange(e.target.value)} + className="flex-1 bg-transparent py-3 text-base focus:outline-none min-w-0" + style={{ color: Z.text }} + autoFocus + /> +
+ +
+
+ ) +} + +function SearchIcon() { + return ( + + + + ) +} + +function MapPinIcon() { + return ( + + + + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/search/search-status.tsx b/typescript-recipes/parallel-apartment-finder/src/components/search/search-status.tsx new file mode 100644 index 0000000..2edbca3 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/search/search-status.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useEffect, useState } from "react" +import { Z, FONT_HEADING, FONT_BODY } from "@/lib/palette" +import type { SearchPhase } from "@/hooks/use-search" + +const STEPS: { key: SearchPhase["key"]; label: string }[] = [ + { key: "discover", label: "Searching the web" }, + { key: "extract", label: "Extracting details" }, + { key: "finalize", label: "Mapping & scoring" }, +] + +function fmt(secs: number): string { + const m = Math.floor(secs / 60) + const s = secs % 60 + return `${m}:${String(s).padStart(2, "0")}` +} + +export function SearchStatus({ + phase, streaming, startedAt, soundOn, onToggleSound, +}: { + phase: SearchPhase | null + streaming: boolean + startedAt: number | null + soundOn: boolean + onToggleSound: () => void +}) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!streaming || !startedAt) return + const t = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(t) + }, [streaming, startedAt]) + + if (!phase) return null + + const elapsed = startedAt ? Math.max(0, Math.floor((now - startedAt) / 1000)) : 0 + + const isDone = phase.key === "done" || !streaming + const activeIdx = STEPS.findIndex((s) => s.key === phase.key) + + return ( +
+ {/* spinner / check */} + {isDone ? ( + + + + ) : ( + // The app mark's unit grid, scanning: an orange pulse sweeps unit to + // unit while FindAll checks candidates (see .af-scan-cell keyframes). + + {Array.from({ length: 9 }, (_, i) => ( + + ))} + + )} + +
+
+ {phase.detail} +
+ {!isDone && ( +
+ This can take a few minutes. Results appear as they're verified. +
+ )} +
+ + {/* step pips */} + {!isDone && ( +
+ {STEPS.map((s, i) => ( + + ))} +
+ )} + + + + + {fmt(elapsed)} + +
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/search/search-suggestions.tsx b/typescript-recipes/parallel-apartment-finder/src/components/search/search-suggestions.tsx new file mode 100644 index 0000000..1a2a878 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/search/search-suggestions.tsx @@ -0,0 +1,135 @@ +"use client" + +import { Z, FONT_HEADING } from "@/lib/palette" + +interface SearchSuggestionsProps { + suggestions: string[] + onSelect: (s: string) => void + parsedBeds: number | null + parsedBudget: number | null + parsedNeighborhoods: string[] + parsedSqft: number | null + effectiveBudget: number + budgetLikelyTooLow: boolean + floor: number | null + city: string + query: string + onQueryChange: (q: string) => void +} + +export function SearchSuggestions({ + suggestions, onSelect, + parsedBeds, parsedBudget, parsedNeighborhoods, parsedSqft, effectiveBudget, + budgetLikelyTooLow, floor, city, query, onQueryChange, +}: SearchSuggestionsProps) { + return ( + <> + {(parsedBeds != null || parsedBudget != null || parsedNeighborhoods.length > 0 || parsedSqft != null) && ( +
+ + Parsed + + {parsedNeighborhoods.map((n) => ( + + 📍 {n} + + ))} + {parsedBeds != null && ( + + {parsedBeds === 0 ? "studio" : `${parsedBeds} bd`} + + )} + {parsedSqft != null && ( + + ≥ {parsedSqft.toLocaleString()} sqft + + )} + {parsedBudget != null ? ( + + ≤ ${parsedBudget.toLocaleString()}/mo + + ) : ( + + default ${effectiveBudget.toLocaleString()}/mo + + )} +
+ )} + + {budgetLikelyTooLow && floor != null && parsedBeds != null && parsedBudget != null && ( +
+
+ ⚠️ +
+ Heads up:{" "} + Typical{" "} + {parsedBeds === 0 ? "studios" : `${parsedBeds}-bedroom rentals`} + {" "}in {city} start around{" "} + ${floor.toLocaleString()}/month.{" "} + Your query asks for under ${parsedBudget.toLocaleString()}{" "} + — likely zero matches. +
+
+ +
+ )} + +
+ + Try + + {suggestions.map((s) => ( + + ))} +
+ + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/components/stats/stats-bar.tsx b/typescript-recipes/parallel-apartment-finder/src/components/stats/stats-bar.tsx new file mode 100644 index 0000000..363bd4a --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/components/stats/stats-bar.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Z, FONT_HEADING } from "@/lib/palette" +import type { Listing } from "@/types" + +function avgPrice(listings: Listing[]): number | null { + const priced = listings.filter((l) => l.price) + if (priced.length === 0) return null + return Math.round(priced.reduce((sum, l) => sum + (l.price ?? 0), 0) / priced.length) +} + +interface StatsBarProps { + listings: Listing[] +} + +export function StatsBar({ listings }: StatsBarProps) { + const avg = avgPrice(listings) + const min = listings.reduce((m, l) => (l.price && (m == null || l.price < m) ? l.price : m), null as number | null) + const high = listings.filter((l) => (l.score ?? 0) >= 70).length + return ( +
+ + {high > 0 && } + {avg != null && } + {min != null && } +
+ ) +} + +function Stat({ label, value, accent, color }: { label: string; value: string; accent?: boolean; color?: string }) { + return ( +
+ + {label} + + + {value} + +
+ ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/hooks/use-saved-targets.ts b/typescript-recipes/parallel-apartment-finder/src/hooks/use-saved-targets.ts new file mode 100644 index 0000000..f367340 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/hooks/use-saved-targets.ts @@ -0,0 +1,73 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import type { Listing } from "@/types" + +const STORAGE_KEY = "apartment-finder-saved-targets" + +/** A listing's stable identity across searches. Per-search ids are random + * UUIDs, so we key saved targets by their listing URL (always present). */ +function keyOf(l: Listing): string { + return l.url || l.id +} + +function loadSaved(): Listing[] { + if (typeof window === "undefined") return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return parsed + } + } catch { + // Corrupt/unavailable storage — start empty. + } + return [] +} + +/** + * A shortlist of apartments the user has saved, persisted in *their own + * browser* (localStorage) — never sent to any server. This is the app's + * only persistence: the backend stays fully stateless. + */ +export function useSavedTargets() { + const [saved, setSaved] = useState([]) + + // The page is server-rendered, so reading localStorage during the first + // render would mismatch the server HTML — hydrate after mount instead. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot localStorage hydration, not a cascading sync + setSaved(loadSaved()) + }, []) + + const write = useCallback((next: Listing[]) => { + setSaved(next) + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } catch { + // Quota / disabled storage — in-memory state still updates. + } + }, []) + + const isSaved = useCallback( + (l: Listing) => saved.some((s) => keyOf(s) === keyOf(l)), + [saved], + ) + + const toggleSave = useCallback((l: Listing) => { + setSaved((prev) => { + const exists = prev.some((s) => keyOf(s) === keyOf(l)) + const next = exists ? prev.filter((s) => keyOf(s) !== keyOf(l)) : [...prev, l] + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } catch { + /* ignore */ + } + return next + }) + }, []) + + const clearSaved = useCallback(() => write([]), [write]) + + return { saved, isSaved, toggleSave, clearSaved } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/hooks/use-search.ts b/typescript-recipes/parallel-apartment-finder/src/hooks/use-search.ts new file mode 100644 index 0000000..9043f03 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/hooks/use-search.ts @@ -0,0 +1,412 @@ +"use client" + +import { useState, useRef, useCallback, useEffect } from "react" +import { api } from "@/lib/api" +import type { Listing } from "@/types" + +export type SearchPhase = { key: "discover" | "extract" | "finalize" | "done"; detail: string } + +type PollResponse = { + state: string + generated: number + matched: number + rentPopulated: number + candidateCount: number + listings: Listing[] +} + +const POLL_MS = 4000 +const MAX_POLLS = 90 // ~6 min hard safety cap +// Enrichment is the slow phase (a Task per listing) and ripens gradually. With +// a large match pool, waiting for EVERY candidate to enrich is what pushed a +// full search to ~5 min. We now finalize once ENRICH_ENOUGH listings have +// enriched rent (a full page's worth survives filtering), rather than waiting +// for the whole batch — the tail candidates rarely change the shown results. +// ENRICH_MAX_WAIT_MS remains a last-resort escape hatch for a hung straggler. +const ENRICH_ENOUGH = 8 +const ENRICH_MAX_WAIT_MS = 120_000 +// FindAll only reports `completed` once it fills match_limit OR exhausts the +// web. A rare/over-constrained query (e.g. "3BR penthouse under $2500") may +// never fill the limit, so it never completes — the client would then poll to +// MAX_POLLS and show a timeout error, discarding the candidates it DID find. +// Once discovery has run this long with at least one match, proceed to +// enrichment with what we have instead of waiting for `completed`. +const DISCOVER_MAX_WAIT_MS = 60_000 +// Start enrichment as soon as discovery has this many verified matches, rather +// than waiting for the full match_limit pool — the extra tail candidates mostly +// don't change the shown page and just add latency. +const DISCOVER_ENOUGH = 15 +// For a run that escaped discovery (never `completed`), we can't use the run +// state to tell that enrichment finished. Instead finalize once enrichment has +// settled — no newly-populated rent for this long — so we don't sit on the +// full ENRICH_MAX_WAIT_MS for a small candidate set that's already done. +const ENRICH_SETTLE_MS = 15_000 +// Discovery is run-to-run variable: a thin neighborhood occasionally returns a +// junk-heavy candidate set and finalizes near-empty. Rather than give up, run +// one fresh FindAll pass before showing the user (near-)nothing. +const LOW_YIELD_RETRY_THRESHOLD = 2 +const VERIFY_POLL_MS = 5000 +const VERIFY_MAX_POLLS = 24 // ~2 min per listing + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)) +} + +// Task-API fraud check: verify each flagged listing's fact-based scam +// signals and fold the verdict back into the rendered cards. +async function verifyListings( + all: Listing[], + live: () => boolean, + say: (text: string) => void, + setListings: React.Dispatch>, +) { + const targets = all.filter((l) => l.needs_verification) + if (!targets.length) return + say(`\nFraud check: verifying ${targets.length} untrusted-source listing${targets.length === 1 ? "" : "s"} via Task API…\n`) + + await Promise.all(targets.map(async (l) => { + try { + const { runId } = await fetchJson<{ runId: string }>(api("/api/verify"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: l.title, body: l.body, price: l.price, address: l.address, source: l.source, + }), + }) + for (let i = 0; i < VERIFY_MAX_POLLS; i++) { + await sleep(VERIFY_POLL_MS) + if (!live()) return + const v = await fetchJson<{ done: boolean; spamScore?: number; flags?: string[] }>( + api(`/api/verify/${runId}`), + ).catch(() => null) + if (!v) continue + if (!v.done) continue + const score = v.spamScore ?? 0 + const flags = v.flags ?? [] + if (!live()) return + setListings((prev) => prev.map((x) => + x.id === l.id ? { ...x, spam_score: score, spam_flags: flags, needs_verification: false } : x, + )) + if (score > 0) { + say(` ⚠ ${l.address ?? l.title ?? "listing"} · spam:${score} (${flags.join(", ")})\n`) + } + return + } + } catch { + // Verification is best-effort; the card simply keeps spam_score 0. + } + })) + if (live()) say("Fraud check complete.\n") +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init) + if (!res.ok) { + const j = await res.json().catch(() => ({} as { detail?: string })) + throw new Error((j as { detail?: string }).detail ?? `HTTP ${res.status}`) + } + return res.json() as Promise +} + +// Serverless-friendly search: create a FindAll run, then drive it from the +// client — poll discovery, kick enrichment, poll again, finalize (geocode + +// score). The server holds no state; this hook owns the whole lifecycle. +export function useSearch() { + const [query, setQuery] = useState("") + const [reasoning, setReasoning] = useState("") + const [streaming, setStreaming] = useState(false) + const [listings, setListings] = useState([]) + const [error, setError] = useState(null) + const [done, setDone] = useState(false) + const [phase, setPhase] = useState(null) + const [startedAt, setStartedAt] = useState(null) + // Live run metrics for the discovery visualization: candidates generated, + // verified matches, enriched-and-ready, and total candidates. + const [progress, setProgress] = useState({ generated: 0, matched: 0, ready: 0, total: 0 }) + const [fraudChecking, setFraudChecking] = useState(false) + // Bumped on every new search and on unmount so an abandoned loop exits. + const genRef = useRef(0) + // Mirror of `listings` so runFraudCheck can read the current set without + // re-creating its callback on every update. + const listingsRef = useRef([]) + useEffect(() => { listingsRef.current = listings }, [listings]) + + const startSearch = useCallback(async ( + q: string, + budget: number, + opts: { city?: string; requirements?: string; neighborhoods?: string[]; sources?: string[] } = {}, + ) => { + if (!q.trim()) return + const gen = ++genRef.current + const live = () => genRef.current === gen + + setReasoning("") + setListings([]) + setError(null) + setDone(false) + setStreaming(true) + setPhase({ key: "discover", detail: "Starting…" }) + setStartedAt(Date.now()) + setProgress({ generated: 0, matched: 0, ready: 0, total: 0 }) + + const say = (text: string) => { if (live()) setReasoning((p) => p + text) } + const fail = (msg: string) => { + if (!live()) return + setStreaming(false) + setError(msg) + } + + // One full discovery → enrich → finalize pass against a fresh FindAll run. + // Returns the finalized listings, or null if the run hard-failed or timed + // out (fail() has already surfaced the error). The terminal "done" state is + // set by the caller so a thin first pass can be retried transparently. + const driveRun = async (attempt: number): Promise<{ listings: Listing[]; completed: boolean } | null> => { + // 1) Create the run (each attempt is a brand-new FindAll run). + const body: Record = { query: q, budget } + if (opts.city) body.city = opts.city + if (opts.requirements) body.requirements = opts.requirements + if (opts.neighborhoods?.length) body.neighborhoods = opts.neighborhoods + if (opts.sources?.length) body.sources = opts.sources + const created = await fetchJson<{ runId: string; objective: string; minBeds: number | null; maxBeds: number | null }>( + api("/api/search"), + { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, + ) + if (!live()) return null + const { runId, objective, minBeds, maxBeds } = created + + if (attempt === 1) { + say(`Objective: ${objective}\n`) + say(`Budget: $${budget.toLocaleString()}/mo`) + if (minBeds) say(` · ${minBeds}+ beds`) + } + say(`\n\nStarting entity discovery…\nRun: ${runId}\nSearching and verifying candidates…\n\n`) + + // City rides along on every poll/finalize call so the stateless server + // can score with the right per-city floors and proximity anchor. + // (Sources are an include hint applied only at create time.) + const cityParam = opts.city ? `&city=${encodeURIComponent(opts.city)}` : "" + const maxBedsParam = maxBeds != null ? `&maxBeds=${maxBeds}` : "" + const pollUrl = api( + `/api/search/${runId}?budget=${budget}${minBeds ? `&minBeds=${minBeds}` : ""}${cityParam}${maxBedsParam}`, + ) + + // 2) Drive the run: discover → enrich → extract → finalize. + let enrichStarted = false + let enrichStartedAt = 0 + let prevGenerated = -1 + let prevMatched = -1 + let prevReady = -1 + let discoverCompleted = false + let lastReadyChangeAt = 0 + const discoverStartedAt = Date.now() + const seenIds = new Set() + + // Add newly verified listings to the UI in live time; enrichment + // updates existing cards in place (ids are stable listing URLs). + const mergeIncoming = (incoming: Listing[]) => { + for (const l of incoming) { + if (!seenIds.has(l.id)) { + seenIds.add(l.id) + say(` + verified: ${l.address ?? l.title ?? l.url}\n`) + } + } + setListings((prev) => { + const byId = new Map(prev.map((x) => [x.id, x])) + const merged = [...prev] + for (const l of incoming) { + const existing = byId.get(l.id) + if (!existing) { + merged.push(l) + } else { + const idx = merged.findIndex((x) => x.id === l.id) + // Keep client-side fields (coords, spam verdicts) if already set. + merged[idx] = { + ...existing, ...l, + lat: existing.lat ?? l.lat, + lng: existing.lng ?? l.lng, + spam_score: existing.spam_score || l.spam_score, + spam_flags: existing.spam_flags ?? l.spam_flags, + } + } + } + return merged + }) + } + + for (let i = 0; i < MAX_POLLS; i++) { + await sleep(POLL_MS) + if (!live()) return null + + let poll: PollResponse + try { + poll = await fetchJson(pollUrl) + } catch { + continue // transient poll failure — try again next tick + } + if (!live()) return null + + // Terminal failure from the search provider (e.g. FindAll run errored + // or was cancelled). Surface it immediately instead of polling until + // the timeout — otherwise the UI just spins for minutes. + if (poll.state === "failed" || poll.state === "cancelled" || poll.state === "error") { + fail("The search service hit an error on this run (it may be rate-limited or over quota). Please try again in a bit.") + return null + } + + if (poll.listings.length) mergeIncoming(poll.listings) + + setProgress({ + generated: poll.generated, + matched: poll.matched, + ready: poll.rentPopulated, + total: poll.candidateCount, + }) + + if (!enrichStarted) { + if (poll.generated !== prevGenerated || poll.matched !== prevMatched) { + say(`Progress: ${poll.generated} found, ${poll.matched} verified\n`) + setPhase({ + key: "discover", + detail: `Verifying candidates · ${poll.generated} found · ${poll.matched} match`, + }) + prevGenerated = poll.generated + prevMatched = poll.matched + } + // Proceed to enrichment when discovery completes, when it already has + // a healthy set of matches (no need to wait for the full pool to + // start extracting), OR when it has run long enough with at least one + // match (a rare query may never fill match_limit and never report + // `completed` — don't hang on it). + const discoverEnough = poll.matched >= DISCOVER_ENOUGH + const discoverTimedOut = + Date.now() - discoverStartedAt > DISCOVER_MAX_WAIT_MS && poll.matched >= 1 + if (poll.state === "completed" || discoverEnough || discoverTimedOut) { + if (poll.state !== "completed") { + say(`\nProceeding with ${poll.matched} verified so far…\n`) + } else { + say(`\nVerified ${poll.matched}. Extracting listing details…\n`) + } + setPhase({ key: "extract", detail: "Extracting price, beds & address…" }) + try { + await fetchJson(api(`/api/search/${runId}/enrich`), { method: "POST" }) + } catch (e) { + fail(e instanceof Error ? e.message : "enrichment failed") + return null + } + enrichStarted = true + enrichStartedAt = Date.now() + lastReadyChangeAt = Date.now() + // Did discovery reach `completed` (filled/exhausted), or did we bail + // out early? Drives whether a thin result is worth retrying. + discoverCompleted = poll.state === "completed" + await sleep(POLL_MS) // let the enrich job flip status to running + } + continue + } + + // Enrichment phase: narrate fill-in progress; wait for it to complete + // so enough listings survive filtering (finalizing at the first ready + // listing yielded zero results). The time-based escape hatch only + // fires if enrichment drags on with a hung straggler. + const total = poll.candidateCount || 1 + if (poll.rentPopulated !== prevReady) { + say(`Extracting details… ${poll.rentPopulated}/${total} ready\n`) + setPhase({ key: "extract", detail: `Extracting details · ${poll.rentPopulated}/${total} ready` }) + prevReady = poll.rentPopulated + lastReadyChangeAt = Date.now() + } + // Enough enriched to show a full page: finalize without waiting for the + // long tail of the batch to enrich. The target scales down for a small + // match set (don't wait for 12 when only 8 matched). + const enrichEnough = poll.rentPopulated >= Math.min(poll.matched || poll.candidateCount || 1, ENRICH_ENOUGH) + const enrichTimedOut = Date.now() - enrichStartedAt > ENRICH_MAX_WAIT_MS && poll.rentPopulated >= 1 + // A run that never `completed` won't ever report enrichment done via + // state, so finalize once every candidate is populated OR enrichment + // has settled (no new rents for ENRICH_SETTLE_MS). Only for escaped + // runs — a normally-completing run still waits for `completed`. + const enrichSettled = !discoverCompleted && poll.rentPopulated >= 1 && + (poll.rentPopulated >= poll.candidateCount || + Date.now() - lastReadyChangeAt > ENRICH_SETTLE_MS) + if (poll.state !== "completed" && !enrichEnough && !enrichTimedOut && !enrichSettled) continue + if (poll.state !== "completed") { + say(`\nFinalizing ${poll.rentPopulated} ready now.\n`) + } + + // 3) Finalize: geocode + score everything in one server call. + setPhase({ key: "finalize", detail: "Mapping & scoring listings…" }) + say("\nMapping & scoring…\n") + const fin = await fetchJson<{ listings: Listing[] }>( + api(`/api/search/${runId}/finalize?budget=${budget}${minBeds ? `&minBeds=${minBeds}` : ""}${cityParam}${maxBedsParam}`), + ) + if (!live()) return null + + for (const l of fin.listings) { + const price = l.price ? `$${l.price.toLocaleString()}/mo` : "n/a" + const bd = l.bedrooms != null ? `${l.bedrooms}bd` : "?bd" + say(` + ${l.address ?? "no address"} · ${bd} · ${price}\n`) + } + return { listings: fin.listings, completed: discoverCompleted } + } + + fail("Search timed out. Please try again") + return null + } + + try { + const first = await driveRun(1) + if (!live()) return + if (first === null) return // hard failure/timeout already surfaced + let result = first.listings + + // Thin first pass: retry once with a fresh run before giving up, keeping + // whichever pass surfaced more. Only when discovery actually COMPLETED — + // a run that bailed early (rare/over-constrained query that never fills + // match_limit) is legitimately near-empty, so a second pass just doubles + // latency for the same answer. + if (first.completed && result.length < LOW_YIELD_RETRY_THRESHOLD) { + say(`\nOnly ${result.length} listing${result.length === 1 ? "" : "s"} so far. Retrying discovery once for more…\n`) + setListings([]) + const retry = await driveRun(2) + if (!live()) return + if (retry && retry.listings.length > result.length) result = retry.listings + // The first pass already succeeded, so a failed retry must not surface + // an error over the usable results we do have — clear it and show them. + setError(null) + } + + say(`\nDone. ${result.length} listings found.\n`) + setListings(result) + setPhase({ key: "done", detail: `${result.length} listing${result.length === 1 ? "" : "s"} found` }) + setStreaming(false) + setDone(true) + } catch (err) { + fail(err instanceof Error ? err.message : "Search failed") + } + }, []) + + // User-triggered second run: fraud-check the current results via the + // Task API. Badges on the cards update live as verdicts land. + const runFraudCheck = useCallback(async () => { + const gen = genRef.current + const live = () => genRef.current === gen + const say = (text: string) => { if (live()) setReasoning((p) => p + text) } + const targets = listingsRef.current.filter((l) => l.needs_verification) + if (!targets.length || fraudChecking) return + setFraudChecking(true) + try { + await verifyListings(listingsRef.current, live, say, setListings) + } finally { + if (live()) setFraudChecking(false) + } + }, [fraudChecking]) + + // Abandon any in-flight loop when the component unmounts. + useEffect(() => () => { genRef.current++ }, []) + + return { + query, setQuery, + reasoning, streaming, listings, error, done, phase, startedAt, progress, + fraudChecking, runFraudCheck, + startSearch, setError, + } as const +} diff --git a/typescript-recipes/parallel-apartment-finder/src/hooks/use-sources.ts b/typescript-recipes/parallel-apartment-finder/src/hooks/use-sources.ts new file mode 100644 index 0000000..aa016f5 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/hooks/use-sources.ts @@ -0,0 +1,74 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { ALL_MAJOR_DOMAINS, sanitizeDomain } from "@/lib/sources" + +const STORAGE_KEY = "apartment-finder-sources" + +type Stored = { selected: string[]; custom: string[] } + +function load(): Stored { + if (typeof window === "undefined") return { selected: [], custom: [] } + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return { selected: [], custom: [] } + const parsed = JSON.parse(raw) as Partial + const known = new Set(ALL_MAJOR_DOMAINS) + return { + selected: (parsed.selected ?? []).filter((d) => known.has(d)), + custom: (parsed.custom ?? []).map((d) => sanitizeDomain(d)).filter((d): d is string => !!d), + } + } catch { + return { selected: [], custom: [] } + } +} + +// Source picker state, persisted per browser. Selected majors + custom domains +// are *includes*: sites the search should be sure to cover, on top of a normal +// broad web search. Empty = no specific includes (search the whole web). +export function useSources() { + const [selected, setSelected] = useState([]) + const [custom, setCustom] = useState([]) + + // localStorage is only readable on the client, and reading it during the + // first render would mismatch the server-rendered HTML — hydrate after mount. + useEffect(() => { + const s = load() + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot localStorage hydration, not a cascading sync + setSelected(s.selected) + setCustom(s.custom) + }, []) + + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ selected, custom })) + } catch { /* private mode etc. — selection just won't persist */ } + }, [selected, custom]) + + const toggle = useCallback((domain: string) => { + setSelected((prev) => + prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain], + ) + }, []) + + const addCustom = useCallback((input: string): boolean => { + const d = sanitizeDomain(input) + if (!d) return false + setCustom((prev) => (prev.includes(d) ? prev : [...prev, d])) + return true + }, []) + + const removeCustom = useCallback((domain: string) => { + setCustom((prev) => prev.filter((d) => d !== domain)) + }, []) + + const reset = useCallback(() => { + setSelected([]) + setCustom([]) + }, []) + + const includeSources = [...selected, ...custom] + const hasIncludes = includeSources.length > 0 + + return { selected, custom, toggle, addCustom, removeCustom, reset, hasIncludes, includeSources } as const +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/api.ts b/typescript-recipes/parallel-apartment-finder/src/lib/api.ts new file mode 100644 index 0000000..bf70bbd --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/api.ts @@ -0,0 +1,5 @@ +const API_BASE = (process.env.NEXT_PUBLIC_API_BASE ?? "").replace(/\/$/, "") + +export function api(path: string): string { + return `${API_BASE}${path}` +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/bay-area.ts b/typescript-recipes/parallel-apartment-finder/src/lib/bay-area.ts new file mode 100644 index 0000000..62ef2c0 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/bay-area.ts @@ -0,0 +1,318 @@ +// Bay Area city + neighborhood data, preloaded so the app never has to +// discover it at runtime: city centers drive the map, reference points anchor +// proximity scoring, rent floors drive auto-budgets and price plausibility, +// and neighborhood centroids resolve locally instead of via Nominatim +// (~1s per lookup). Pure data — safe to import from client and server code. +// +// rentFloors = entry-level ("starts around") monthly rent per bedroom count, +// calibrated to mid-2026 Bay Area asking rents (Zumper / Zillow / apartments.com, +// July 2026 — after the ~22% YoY AI-boom spike). Studio and 4-5BR are +// interpolated where listing data is thin (studio ≈ 0.78×1BR, +N BR ≈ ×1.2). + +export type LatLng = { lat: number; lng: number } + +export type BayAreaNeighborhood = { + name: string + lat: number + lng: number +} + +export type BayAreaCity = { + /** Stable key, e.g. "san-francisco" */ + key: string + /** Display label and search-bar value, e.g. "San Francisco" */ + label: string + /** Full form used in FindAll objectives and geocode queries */ + full: string + aliases: string[] + center: LatLng + zoom: number + referencePoint: { name: string } & LatLng + /** Typical monthly-rent floors by bedroom count (string keys to match AppConfig) */ + rentFloors: Record + neighborhoods: BayAreaNeighborhood[] +} + +export const BAY_AREA_CITIES: BayAreaCity[] = [ + { + key: "san-francisco", + label: "San Francisco", + full: "San Francisco, CA", + aliases: ["sf", "san fran"], + center: { lat: 37.7749, lng: -122.4194 }, + zoom: 13, + referencePoint: { name: "Caltrain · 4th & King", lat: 37.7764, lng: -122.3973 }, + rentFloors: { 0: 2500, 1: 3400, 2: 4600, 3: 6200, 4: 7800, 5: 9500 }, + neighborhoods: [ + { name: "Mission", lat: 37.7599, lng: -122.4148 }, + { name: "SoMa", lat: 37.7785, lng: -122.4056 }, + { name: "South Beach", lat: 37.7813, lng: -122.3892 }, + { name: "Mission Bay", lat: 37.7699, lng: -122.3922 }, + { name: "Dogpatch", lat: 37.7576, lng: -122.3884 }, + { name: "Potrero Hill", lat: 37.7605, lng: -122.4005 }, + { name: "Noe Valley", lat: 37.7502, lng: -122.4337 }, + { name: "Castro", lat: 37.7609, lng: -122.435 }, + { name: "Hayes Valley", lat: 37.7759, lng: -122.4245 }, + { name: "NoPa", lat: 37.7777, lng: -122.4404 }, + { name: "Lower Haight", lat: 37.772, lng: -122.4306 }, + { name: "Haight-Ashbury", lat: 37.7692, lng: -122.4463 }, + { name: "Duboce Triangle", lat: 37.769, lng: -122.433 }, + { name: "Bernal Heights", lat: 37.7399, lng: -122.4166 }, + { name: "Glen Park", lat: 37.7331, lng: -122.4338 }, + { name: "Inner Sunset", lat: 37.7601, lng: -122.4692 }, + { name: "Outer Sunset", lat: 37.7554, lng: -122.4946 }, + { name: "Sunset", lat: 37.7554, lng: -122.485 }, + { name: "Inner Richmond", lat: 37.7801, lng: -122.4645 }, + { name: "Outer Richmond", lat: 37.7783, lng: -122.4893 }, + { name: "Richmond", lat: 37.78, lng: -122.47 }, + { name: "Marina", lat: 37.8021, lng: -122.4369 }, + { name: "Cow Hollow", lat: 37.7976, lng: -122.4359 }, + { name: "Pacific Heights", lat: 37.7925, lng: -122.4382 }, + { name: "Nob Hill", lat: 37.793, lng: -122.4161 }, + { name: "Russian Hill", lat: 37.8014, lng: -122.4182 }, + { name: "North Beach", lat: 37.806, lng: -122.4103 }, + { name: "Telegraph Hill", lat: 37.8024, lng: -122.4058 }, + { name: "Financial District", lat: 37.7946, lng: -122.3999 }, + { name: "Tenderloin", lat: 37.7847, lng: -122.4145 }, + { name: "Chinatown", lat: 37.7941, lng: -122.4078 }, + { name: "Western Addition", lat: 37.7804, lng: -122.4293 }, + { name: "Fillmore", lat: 37.784, lng: -122.433 }, + { name: "Japantown", lat: 37.7854, lng: -122.4297 }, + { name: "Excelsior", lat: 37.7249, lng: -122.426 }, + { name: "Bayview", lat: 37.7299, lng: -122.3865 }, + ], + }, + { + key: "oakland", + label: "Oakland", + full: "Oakland, CA", + aliases: [], + center: { lat: 37.8044, lng: -122.2712 }, + zoom: 13, + referencePoint: { name: "19th St BART", lat: 37.808, lng: -122.2687 }, + rentFloors: { 0: 1700, 1: 2200, 2: 2800, 3: 3600, 4: 4500, 5: 5400 }, + neighborhoods: [ + { name: "Temescal", lat: 37.834, lng: -122.262 }, + { name: "Rockridge", lat: 37.8443, lng: -122.2519 }, + { name: "Lake Merritt", lat: 37.8021, lng: -122.2571 }, + { name: "Uptown", lat: 37.809, lng: -122.2705 }, + { name: "Downtown", lat: 37.8027, lng: -122.2716 }, + { name: "Jack London Square", lat: 37.7946, lng: -122.2782 }, + { name: "Grand Lake", lat: 37.811, lng: -122.247 }, + { name: "Adams Point", lat: 37.8095, lng: -122.256 }, + { name: "Fruitvale", lat: 37.7752, lng: -122.2242 }, + { name: "West Oakland", lat: 37.8126, lng: -122.2949 }, + { name: "Piedmont Avenue", lat: 37.825, lng: -122.253 }, + { name: "Montclair", lat: 37.828, lng: -122.21 }, + { name: "Laurel", lat: 37.793, lng: -122.197 }, + { name: "Dimond", lat: 37.794, lng: -122.211 }, + ], + }, + { + key: "berkeley", + label: "Berkeley", + full: "Berkeley, CA", + aliases: [], + center: { lat: 37.8715, lng: -122.273 }, + zoom: 14, + referencePoint: { name: "Downtown Berkeley BART", lat: 37.8701, lng: -122.2681 }, + rentFloors: { 0: 1900, 1: 2300, 2: 2900, 3: 3600, 4: 4300, 5: 5200 }, + neighborhoods: [ + { name: "Downtown", lat: 37.87, lng: -122.27 }, + { name: "Southside", lat: 37.866, lng: -122.258 }, + { name: "Northside", lat: 37.876, lng: -122.26 }, + { name: "Elmwood", lat: 37.858, lng: -122.253 }, + { name: "North Berkeley", lat: 37.88, lng: -122.282 }, + { name: "West Berkeley", lat: 37.866, lng: -122.296 }, + { name: "South Berkeley", lat: 37.848, lng: -122.273 }, + { name: "Claremont", lat: 37.859, lng: -122.242 }, + ], + }, + { + key: "san-jose", + label: "San Jose", + full: "San Jose, CA", + aliases: ["sj"], + center: { lat: 37.3382, lng: -121.8863 }, + zoom: 12, + referencePoint: { name: "Diridon Station", lat: 37.3297, lng: -121.9026 }, + rentFloors: { 0: 2100, 1: 2600, 2: 3300, 3: 4300, 4: 5300, 5: 6300 }, + neighborhoods: [ + { name: "Downtown", lat: 37.335, lng: -121.89 }, + { name: "Japantown", lat: 37.348, lng: -121.894 }, + { name: "Willow Glen", lat: 37.308, lng: -121.89 }, + { name: "Rose Garden", lat: 37.331, lng: -121.918 }, + { name: "Santana Row", lat: 37.321, lng: -121.948 }, + { name: "North San Jose", lat: 37.39, lng: -121.93 }, + { name: "Berryessa", lat: 37.395, lng: -121.86 }, + { name: "Cambrian", lat: 37.257, lng: -121.93 }, + { name: "Almaden Valley", lat: 37.236, lng: -121.86 }, + { name: "Evergreen", lat: 37.306, lng: -121.786 }, + ], + }, + { + key: "palo-alto", + label: "Palo Alto", + full: "Palo Alto, CA", + aliases: [], + center: { lat: 37.4419, lng: -122.143 }, + zoom: 13, + referencePoint: { name: "Palo Alto Caltrain", lat: 37.4433, lng: -122.165 }, + rentFloors: { 0: 2600, 1: 3300, 2: 4300, 3: 5800, 4: 7200, 5: 8800 }, + neighborhoods: [ + { name: "Downtown", lat: 37.445, lng: -122.161 }, + { name: "Midtown", lat: 37.433, lng: -122.129 }, + { name: "College Terrace", lat: 37.425, lng: -122.152 }, + { name: "Crescent Park", lat: 37.452, lng: -122.145 }, + { name: "Old Palo Alto", lat: 37.436, lng: -122.15 }, + { name: "Barron Park", lat: 37.413, lng: -122.136 }, + ], + }, + { + key: "mountain-view", + label: "Mountain View", + full: "Mountain View, CA", + aliases: ["mv"], + center: { lat: 37.3861, lng: -122.0839 }, + zoom: 13, + referencePoint: { name: "Mountain View Caltrain", lat: 37.3945, lng: -122.076 }, + rentFloors: { 0: 2500, 1: 3100, 2: 3900, 3: 5100, 4: 6200, 5: 7200 }, + neighborhoods: [ + { name: "Downtown", lat: 37.394, lng: -122.079 }, + { name: "Old Mountain View", lat: 37.39, lng: -122.082 }, + { name: "North Bayshore", lat: 37.423, lng: -122.085 }, + { name: "Shoreline West", lat: 37.4, lng: -122.09 }, + { name: "Whisman", lat: 37.402, lng: -122.062 }, + { name: "San Antonio", lat: 37.407, lng: -122.109 }, + ], + }, + { + key: "sunnyvale", + label: "Sunnyvale", + full: "Sunnyvale, CA", + aliases: [], + center: { lat: 37.3688, lng: -122.0363 }, + zoom: 13, + referencePoint: { name: "Sunnyvale Caltrain", lat: 37.3784, lng: -122.0312 }, + rentFloors: { 0: 2400, 1: 3000, 2: 3700, 3: 4800, 4: 5800, 5: 6800 }, + neighborhoods: [ + { name: "Downtown", lat: 37.377, lng: -122.03 }, + { name: "Cherry Chase", lat: 37.355, lng: -122.045 }, + { name: "Lakewood", lat: 37.396, lng: -122.013 }, + { name: "Ponderosa", lat: 37.35, lng: -122.017 }, + { name: "Birdland", lat: 37.36, lng: -122.063 }, + ], + }, + { + key: "redwood-city", + label: "Redwood City", + full: "Redwood City, CA", + aliases: [], + center: { lat: 37.4852, lng: -122.2364 }, + zoom: 13, + referencePoint: { name: "Redwood City Caltrain", lat: 37.4857, lng: -122.2317 }, + rentFloors: { 0: 2300, 1: 2900, 2: 3600, 3: 4700, 4: 5700, 5: 6700 }, + neighborhoods: [ + { name: "Downtown", lat: 37.486, lng: -122.231 }, + { name: "Centennial", lat: 37.489, lng: -122.24 }, + { name: "Woodside Plaza", lat: 37.468, lng: -122.253 }, + { name: "Redwood Shores", lat: 37.533, lng: -122.247 }, + { name: "Friendly Acres", lat: 37.475, lng: -122.214 }, + ], + }, + { + key: "daly-city", + label: "Daly City", + full: "Daly City, CA", + aliases: [], + center: { lat: 37.6879, lng: -122.4702 }, + zoom: 13, + referencePoint: { name: "Daly City BART", lat: 37.7063, lng: -122.4692 }, + rentFloors: { 0: 1900, 1: 2500, 2: 3200, 3: 4100, 4: 4900, 5: 5700 }, + neighborhoods: [ + { name: "Westlake", lat: 37.701, lng: -122.485 }, + { name: "Serramonte", lat: 37.671, lng: -122.472 }, + { name: "Original Daly City", lat: 37.706, lng: -122.46 }, + { name: "Crocker", lat: 37.679, lng: -122.455 }, + ], + }, + { + key: "fremont", + label: "Fremont", + full: "Fremont, CA", + aliases: [], + center: { lat: 37.5485, lng: -121.9886 }, + zoom: 12, + referencePoint: { name: "Fremont BART", lat: 37.5574, lng: -121.9766 }, + rentFloors: { 0: 2000, 1: 2600, 2: 3300, 3: 4200, 4: 5000, 5: 5800 }, + neighborhoods: [ + { name: "Centerville", lat: 37.554, lng: -122.001 }, + { name: "Niles", lat: 37.577, lng: -121.981 }, + { name: "Irvington", lat: 37.522, lng: -121.963 }, + { name: "Mission San Jose", lat: 37.527, lng: -121.92 }, + { name: "Ardenwood", lat: 37.556, lng: -122.057 }, + { name: "Warm Springs", lat: 37.489, lng: -121.929 }, + ], + }, +] + +const byName = new Map() +for (const c of BAY_AREA_CITIES) { + byName.set(c.key, c) + byName.set(c.label.toLowerCase(), c) + byName.set(c.full.toLowerCase(), c) + for (const a of c.aliases) byName.set(a, c) +} + +/** Resolve "SF", "Oakland", "Oakland, CA", "san-jose", … to a city entry. */ +export function cityByName(name: string | null | undefined): BayAreaCity | null { + if (!name) return null + const key = name.toLowerCase().trim() + return byName.get(key) ?? byName.get(key.split(",")[0].trim()) ?? null +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +// City label + alias terms, longest first so "Palo Alto" wins over a shorter +// alias and multi-word names match before single tokens. +const CITY_QUERY_TERMS: { term: string; city: BayAreaCity }[] = BAY_AREA_CITIES + .flatMap((c) => [c.label, ...c.aliases].map((term) => ({ term, city: c }))) + .sort((a, b) => b.term.length - a.term.length) + +/** + * Detect a Bay Area city named anywhere in a free-text query, case-insensitive + * and word-bounded, e.g. "Palo Alto homes in the bubble" -> Palo Alto. Returns + * null when no known city (or alias) is mentioned. + */ +export function cityInQuery(query: string | null | undefined): BayAreaCity | null { + if (!query || !query.trim()) return null + for (const { term, city } of CITY_QUERY_TERMS) { + if (new RegExp(`(? normalizeNeighborhood(n.name) === key) + return hit ? { lat: hit.lat, lng: hit.lng } : null +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/listing-url.ts b/typescript-recipes/parallel-apartment-finder/src/lib/listing-url.ts new file mode 100644 index 0000000..0f8cd90 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/listing-url.ts @@ -0,0 +1,85 @@ +// One source of truth (client + server) for telling an individual rental +// listing URL apart from a search-results / category / geo-index page. +// Individual listings carry a street address or a numeric id in the path; +// the patterns below only ever appear on list/category pages. + +const SEARCH_PAGE_PATTERNS: RegExp[] = [ + /\/apartments\/$/i, + /\/apartments-\d+-bedrooms\/$/i, + /\/apartments-under-\d+\/$/i, + /\/\d+-bedroom-apartments/i, + /\/rentals$/i, + /\/apartments\/[a-z-]+(?:\/|$)/i, + // Explicit search-results URLs (craigslist /search/apa?query=…, generic ?q=). + /\/search[/?#]/i, + /[?&](?:query|q|search|searchQueryState)=/i, + /#search/i, + // Category / geo-index list pages on the major aggregators. + /\/for_rent\//i, // Trulia/Zillow: /for_rent/San_Francisco,CA + /\/for_sale\//i, + /-for-rent\/?(?:[?#]|$)/i, // …/apartments-for-rent (Redfin/HotPads) + /\/(?:city|zipcode|neighborhood|county|state)\/\d/i, // Redfin geo indexes + /\/apartments-for-rent\/[a-z-]+\/?$/i, // Zumper geo search: /apartments-for-rent/san-francisco-ca + // Price-band category pages: apartmentfinder /…/Under-3000 (slash) and + // hotpads /…/apartments-under-3000 (hyphen) — accept either separator. + /[/-](?:under|over)-\$?\d{3,}(?:[/?#]|$)/i, + /-apartments\/?$/i, // "…-Apartments" area list page + /-apartments\/(?:under|over|cheap|luxury|pet|furnished|studio|\d)/i, // "…-Apartments/" + /apartments-\d+-bedrooms?/i, // zillow-style /…/apartments-2-bedrooms + /\/\d+-bedrooms?(?:[/?#]|$)/i, // apartments.com-style /{geo}/2-bedrooms index + /\/shopping-centers?\//i, // POI/directory pages (apartmenthomeliving) + /\/find\//i, // forrent.com-style /find/… search paths + /(?:less|more|under|over)-than-\$?\d{2,}/i, // price-filter search segments (…/less-than-3000) + // Rentler city/state index pages: /places-for-rent[/{state}[/{city}]] (a + // map of all listings). Individual units add /{street-slug}/{numeric-id} + // beyond the city, so those deeper paths are NOT matched here. + /\/places-for-rent(?:\/[a-z]{2}(?:\/[a-z0-9-]+)?)?\/?(?:[?#]|$)/i, + // Facet / filter path segments. These are range/filter controls that only + // appear on search-results pages, never on an individual unit. Scoped to the + // range forms (min-max, price-band) so a unit slug like "2-beds-1-bath" or + // "half-price-special" is NOT caught. + /\/price-(?:na|\d+)-/i, // realtor: /price-na-800, /price-1000-3000 + /\d+k-price(?:[/?#]|$)/i, // compass: /5k-price + /[/-]beds-\d+-\d+/i, // realtor: /beds-2-2 (min-max facet) + /[/-]from-\d{3,}(?:[/?#]|$)/i, // apartmenthomeliving: /from-5000 + // apartments.com city index (/san-francisco-ca). Real apartments.com deep + // links always carry a digit-led street address or a trailing id segment, so + // a single all-lowercase-hyphen segment that ends the path is an index page. + /apartments\.com\/[a-z-]+\/?(?:[?#]|$)/i, +] + +// Aggregator hosts we never link to even if a URL looks listing-shaped — +// mirrors the server's default BLOCKED_DOMAINS so client link-picking agrees. +const BLOCKED_LINK_HOSTS = ["zillow.com", "yelp.com", "loopnet.com", "crexi.com"] + +export function isSearchOrCategoryUrl(url: string | null | undefined): boolean { + if (!url) return false + return SEARCH_PAGE_PATTERNS.some((p) => p.test(url)) +} + +/** + * A safe, absolute http(s) URL that points at a specific page (has a path or + * query) AND is not a search/category/index page — i.e. a real listing link. + */ +export function isIndividualListingUrl(url: string | null | undefined): boolean { + if (!url) return false + try { + const u = new URL(url) // absolute only + if (u.protocol !== "http:" && u.protocol !== "https:") return false + const host = u.hostname.toLowerCase().replace(/^www\./, "") + if (BLOCKED_LINK_HOSTS.some((d) => host === d || host.endsWith(`.${d}`))) return false + // Craigslist detail pages live under `/d/` (e.g. /view/d// or + // //apa/d//.html). Their slugs often embed the search + // terms — "…-mission-bedroom-under-3800/…" — which would otherwise trip the + // price-band / category patterns and drop a real listing. A `/d/` path is + // an individual unit; only `/search/` is a Craigslist index (rejected below + // by the generic patterns since it has no `/d/`). + if ((host === "craigslist.org" || host.endsWith(".craigslist.org")) && /\/d\//i.test(u.pathname)) { + return true + } + const deep = u.pathname.replace(/\/+$/, "").length > 0 || u.search.length > 0 + return deep && !isSearchOrCategoryUrl(url) + } catch { + return false + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/neighborhoods.ts b/typescript-recipes/parallel-apartment-finder/src/lib/neighborhoods.ts new file mode 100644 index 0000000..ff7def5 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/neighborhoods.ts @@ -0,0 +1,34 @@ +// Query-time neighborhood parsing against the preloaded Bay Area tables +// (lib/bay-area.ts). Matched names show as "Parsed" chips and are passed to +// the FindAll objective as a priority hint. + +import { cityByName } from "./bay-area" + +function neighborhoodsForCity(city: string): string[] { + return cityByName(city)?.neighborhoods.map((n) => n.name) ?? [] +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +// Returns the known neighborhoods mentioned in the query, in query order, +// deduped case-insensitively. +export function extractNeighborhoodsFromQuery(query: string, city: string): string[] { + const list = neighborhoodsForCity(city) + if (!list.length || !query.trim()) return [] + const hits: { name: string; index: number }[] = [] + for (const n of list) { + const re = new RegExp(`(? a.index - b.index) + const seen = new Set() + return hits.filter((h) => { + const k = h.name.toLowerCase() + if (seen.has(k)) return false + seen.add(k) + return true + }).map((h) => h.name) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/palette.ts b/typescript-recipes/parallel-apartment-finder/src/lib/palette.ts new file mode 100644 index 0000000..4bbb637 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/palette.ts @@ -0,0 +1,37 @@ +// Parallel design-system tokens (parallel.ai). Warm off-white base, index +// black text, signal orange as a sparing accent, neutral grey borders. Key +// names are kept stable (incl. the legacy `blue*` names) so every consumer +// picks up the on-brand values without a rename sweep. Per the brand system, +// interactive text is index black (underline on hover), signal orange is +// reserved for primary CTAs and selected states, and red is errors only. +export const Z = { + // Signal orange — primary CTA / one key accent per section. Not a fill color. + blue: "#FB631B", + // Interactive / link text: index black (the brand's link affordance is an + // underline on hover, not a blue). Emphasis uses the same. + blueDark: "#1D1B16", + blueDarker: "#1D1B16", + // Selected / "parsed" chip + soft-CTA fills: orange wash and orange-light. + blueSoft: "#FCDDCF", + blueSofter: "#FEF3EC", + blueBorder: "#F9BC9F", + text: "#1D1B16", + textSoft: "#3A352A", + textMid: "#5C5B59", + textFaint: "#858483", + bgPage: "#FCFCFA", + bgCard: "#FFFFFF", + bgSubtle: "#F6F6F6", + border: "#E5E5E5", + borderSoft: "#EEEEEE", + green: "#137333", + greenSoft: "#E6F4EA", + amber: "#C77700", + amberSoft: "#FFF4E0", + red: "#E14942", + redSoft: "#FDECEA", +} + +export const FONT_HEADING = "'Geist Variable', 'Geist', system-ui, sans-serif" +export const FONT_BODY = "'Geist Variable', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" +export const FONT_MONO = "'Geist Mono Variable', 'Geist Mono', 'FT System Mono', 'SF Mono', Menlo, monospace" diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/search-audio.ts b/typescript-recipes/parallel-apartment-finder/src/lib/search-audio.ts new file mode 100644 index 0000000..e70a6f6 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/search-audio.ts @@ -0,0 +1,143 @@ +// Procedural "AI searching" sound via Web Audio API. No files, no deps. +// +// Tuned to the Parallel design language translated to audio: warm, precise, +// and technical, never sci-fi. Restraint is the point, like "orange is a +// signal, not a fill": the bed is a soft, low, slowly-breathing drone (the +// off-white ground), and the one bright accent is the blip when a candidate +// verifies (the signal). Everything is low-passed for warmth (no clinical +// highs) with gentle envelopes. Only sine/triangle partials, lightly detuned. +// +// The AudioContext is created lazily and only resumed from a user gesture +// (the Search click), per browser autoplay policy. Muting persists via the UI. + +type Drone = { stop: () => void } + +let ctx: AudioContext | null = null +let master: GainNode | null = null +let drone: Drone | null = null +let muted = false + +function ensure(): AudioContext | null { + if (typeof window === "undefined") return null + if (!ctx) { + const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (!AC) return null + ctx = new AC() + master = ctx.createGain() + master.gain.value = 0.9 + master.connect(ctx.destination) + } + if (ctx.state === "suspended") void ctx.resume() + return ctx +} + +// Major pentatonic, ascending: successive matches feel like they resolve. +const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21] + +export const searchAudio = { + setMuted(m: boolean) { + muted = m + // Hard-gate the master output so mute is instant and total, regardless of + // any node still playing. Restore on unmute so later searches are audible. + if (ctx && master) { + master.gain.cancelScheduledValues(ctx.currentTime) + master.gain.setValueAtTime(m ? 0 : 0.9, ctx.currentTime) + } + if (m) { drone?.stop(); drone = null } + }, + isMuted: () => muted, + + start() { + if (muted) return + const c = ensure() + if (!c || !master || drone) return + const now = c.currentTime + + const bed = c.createGain() + bed.gain.value = 0 + bed.connect(master) + + // Warm-but-airy foundation: a mid root + its fifth, gently low-passed + // higher so it reads bright and friendly rather than dark. + const filt = c.createBiquadFilter() + filt.type = "lowpass" + filt.frequency.value = 850 + filt.Q.value = 1.5 + filt.connect(bed) + const o1 = c.createOscillator(); o1.type = "triangle"; o1.frequency.value = 130.81 // C3 + const o2 = c.createOscillator(); o2.type = "sine"; o2.frequency.value = 196.0; o2.detune.value = 4 // G3 + const o3 = c.createOscillator(); o3.type = "sine"; o3.frequency.value = 392.0; o3.detune.value = -3 // G4 airy shimmer + const o3g = c.createGain(); o3g.gain.value = 0.28 + o1.connect(filt); o2.connect(filt); o3.connect(o3g); o3g.connect(filt) + + // Slow filter drift + a gentle amplitude "breath": processing, not a beat. + const drift = c.createOscillator(); drift.type = "sine"; drift.frequency.value = 0.13 + const driftG = c.createGain(); driftG.gain.value = 320 + drift.connect(driftG); driftG.connect(filt.frequency) + const breath = c.createOscillator(); breath.type = "sine"; breath.frequency.value = 0.4 + const breathG = c.createGain(); breathG.gain.value = 0.018 + breath.connect(breathG); breathG.connect(bed.gain) + + bed.gain.setValueAtTime(0, now) + bed.gain.linearRampToValueAtTime(0.055, now + 1.1) // restrained + o1.start(); o2.start(); o3.start(); drift.start(); breath.start() + + drone = { + stop: () => { + const t = c.currentTime + bed.gain.cancelScheduledValues(t) + bed.gain.setValueAtTime(Math.max(bed.gain.value, 0.0001), t) + bed.gain.linearRampToValueAtTime(0, t + 0.7) + for (const o of [o1, o2, o3, drift, breath]) o.stop(t + 0.8) + }, + } + }, + + // The signal: a warm bell for the nth verified match, pitched up the scale. + tick(n: number) { + if (muted) return + const c = ensure() + if (!c || !master) return + const step = PENTA[n % PENTA.length] + 12 * Math.min(2, Math.floor(n / PENTA.length)) + const base = 523.25 * Math.pow(2, Math.min(step, 24) / 12) // C5 root, up an octave = brighter + const t = c.currentTime + const g = c.createGain(); g.gain.value = 0; g.connect(master) + // Bright, friendly chime: fundamental + octave + a sparkle two octaves up. + const soft = c.createBiquadFilter(); soft.type = "lowpass"; soft.frequency.value = 6000; soft.connect(g) + const o = c.createOscillator(); o.type = "sine"; o.frequency.value = base + const oct = c.createOscillator(); oct.type = "sine"; oct.frequency.value = base * 2 + const octG = c.createGain(); octG.gain.value = 0.4 + const spk = c.createOscillator(); spk.type = "sine"; spk.frequency.value = base * 3 + const spkG = c.createGain(); spkG.gain.value = 0.14 + o.connect(soft); oct.connect(octG); octG.connect(soft); spk.connect(spkG); spkG.connect(soft) + g.gain.setValueAtTime(0, t) + g.gain.linearRampToValueAtTime(0.12, t + 0.01) + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.55) + o.start(t); oct.start(t); spk.start(t) + o.stop(t + 0.6); oct.stop(t + 0.6); spk.stop(t + 0.6) + }, + + stop(success: boolean) { + const c = ctx + if (!c || !master) { drone?.stop(); drone = null; return } + if (success && !muted) { + // Cheerful major arpeggio: C, E, G, C. Bright and friendly resolve. + const t = c.currentTime + const soft = c.createBiquadFilter(); soft.type = "lowpass"; soft.frequency.value = 6000; soft.connect(master) + const notes = [523.25, 659.25, 783.99, 1046.5] // C5, E5, G5, C6 + notes.forEach((f, i) => { + const o = c.createOscillator(); o.type = "sine"; o.frequency.value = f + const oct = c.createOscillator(); oct.type = "sine"; oct.frequency.value = f * 2 + const octG = c.createGain(); octG.gain.value = 0.18 + const g = c.createGain(); g.gain.value = 0 + o.connect(g); oct.connect(octG); octG.connect(g); g.connect(soft) + const s = t + i * 0.1 + g.gain.setValueAtTime(0, s) + g.gain.linearRampToValueAtTime(0.11, s + 0.02) + g.gain.exponentialRampToValueAtTime(0.0001, s + 0.75) + o.start(s); oct.start(s); o.stop(s + 0.8); oct.stop(s + 0.8) + }) + } + drone?.stop(); drone = null + }, +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/server/app-config.ts b/typescript-recipes/parallel-apartment-finder/src/lib/server/app-config.ts new file mode 100644 index 0000000..fa28c4b --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/server/app-config.ts @@ -0,0 +1,42 @@ +// Builds the AppConfig object served to the client. Called from the server +// layout (config is inlined into the initial HTML — no client fetch) and from +// the /api/config route used by the /docs live-config table. + +import { + APP_TITLE, CITY, CITY_SHORT, DEFAULT_BUDGET, + REFERENCE_POINT_NAME, REFERENCE_POINT_LAT, REFERENCE_POINT_LNG, + MAP_CENTER_LAT, MAP_CENTER_LNG, MAP_ZOOM, + BRAND_NAME, BRAND_LOGO_URL, + SUGGESTIONS, RENT_FLOORS, + AGGREGATOR_SOURCES, STALE_AGGREGATOR_DAYS, STALE_DIRECT_DAYS, +} from "./config" +import type { AppConfig } from "@/types" + +export function buildAppConfig(): AppConfig { + return { + appTitle: APP_TITLE, + city: CITY, + cityShort: CITY_SHORT, + referencePoint: { + name: REFERENCE_POINT_NAME, + lat: REFERENCE_POINT_LAT, + lng: REFERENCE_POINT_LNG, + }, + mapCenter: { lat: MAP_CENTER_LAT, lng: MAP_CENTER_LNG }, + mapZoom: MAP_ZOOM, + defaultBudget: DEFAULT_BUDGET, + brand: { + name: BRAND_NAME, + logoUrl: BRAND_LOGO_URL, + }, + suggestions: SUGGESTIONS, + rentFloors: Object.fromEntries( + Object.entries(RENT_FLOORS).map(([k, v]) => [String(k), v]), + ), + staleness: { + aggregatorSources: AGGREGATOR_SOURCES, + aggregatorDays: STALE_AGGREGATOR_DAYS, + directDays: STALE_DIRECT_DAYS, + }, + } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/server/config.ts b/typescript-recipes/parallel-apartment-finder/src/lib/server/config.ts new file mode 100644 index 0000000..57fd3b7 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/server/config.ts @@ -0,0 +1,114 @@ +// Server-side configuration for the Next.js API routes. Every value is +// env-overridable. + +function envStr(key: string, fallback: string): string { + return process.env[key] ?? fallback +} +function envNum(key: string, fallback: number): number { + const v = process.env[key] + const n = v != null ? Number(v) : NaN + return Number.isFinite(n) ? n : fallback +} + +export const CITY = envStr("CITY", "San Francisco, CA") +export const CITY_SHORT = envStr("CITY_SHORT", CITY.split(",")[0].trim()) + +export const REFERENCE_POINT_NAME = envStr("REFERENCE_POINT_NAME", "Caltrain · 4th & King") +export const REFERENCE_POINT_LAT = envNum("REFERENCE_POINT_LAT", 37.7764) +export const REFERENCE_POINT_LNG = envNum("REFERENCE_POINT_LNG", -122.3973) + +export const MAP_CENTER_LAT = envNum("MAP_CENTER_LAT", REFERENCE_POINT_LAT) +export const MAP_CENTER_LNG = envNum("MAP_CENTER_LNG", REFERENCE_POINT_LNG) +export const MAP_ZOOM = envNum("MAP_ZOOM", 13) + +export const DEFAULT_BUDGET = envNum("SEARCH_BUDGET", 6000) + +export const LISTING_SITES = envStr( + "LISTING_SITES", + "trulia.com,craigslist.org,hotpads.com,rent.com," + + "redfin.com,realtor.com,padmapper.com,rentcafe.com,zumper.com,movoto.com," + + "rentberry.com,showcase.com,compass.com", +) + +// Domain-level blocks. Kept deliberately small: category/search index pages +// are filtered precisely by URL pattern (lib/listing-url), so we no longer +// blanket-block whole aggregators. apartments.com in particular has huge, +// extractable individual-listing inventory — blocking it was silently killing +// most Bay Area results. zillow (heavy bot-walls → dead outbound links), +// yelp (not rental listings), and loopnet/crexi (commercial real estate, not +// apartments) stay blocked. +export const BLOCKED_DOMAINS = envStr("BLOCKED_DOMAINS", "zillow.com,yelp.com,loopnet.com,crexi.com") + .split(",").map((d) => d.trim().toLowerCase()).filter(Boolean) + +export const GEO_COUNTRY = envStr("GEO_COUNTRY", "us") + +export const APP_TITLE = envStr("APP_TITLE", "Bay Area Apartment Finder") +export const BRAND_NAME = envStr("BRAND_NAME", "Apartment Finder") +export const BRAND_LOGO_URL = envStr("BRAND_LOGO_URL", "/app-logo.svg") + +// Starter queries. Each was measured against real discovery runs before being +// listed here, because a chip that finalizes to an empty grid is the worst +// thing on the page. Two failure modes to avoid when editing: +// - Budget at the entry-level floor. `fits_budget` is a hard FindAll match +// condition, so pricing a chip at its RENT_FLOORS value verifies almost +// nothing ("Studio in Palo Alto under $2,600" finalized to zero listings). +// Aim ~1.25-1.4x the floor for that city and bedroom count. +// - Thin inventory. Boutique neighborhoods and the smaller Peninsula/South +// Bay cities verify candidates that are mostly category and index pages, +// which the listing parser drops — the run ends with matches but no cards. +// Dense cities and large neighborhoods hold up run to run. +// Spell any neighborhood exactly as lib/bay-area.ts has it so it parses to a +// 📍 chip ("UC Berkeley" does not, which is why the old chip showed none), and +// leave square footage out — it constrains discovery and slows the run. +// Last measured 2026-08-19 (parsed-candidate average over 2-4 discovery runs +// each): Mission Bay 1BR 5.0, Mission 2BR 4.5, Hayes Valley 1BR 3.5. +export const SUGGESTIONS = envStr( + "SEARCH_SUGGESTIONS", + "1 bedroom in Mission Bay under $5,000|" + + "2 bedroom in the Mission under $7,000|" + + "1 bedroom in Hayes Valley under $4,400", +).split("|").map((s) => s.trim()).filter(Boolean) + +// Typical monthly-rent floors by bedroom count for the default city (SF). +// Drives the auto-budget when a query omits one, and the price-fit score. +export const RENT_FLOORS: Record = (() => { + const out: Record = {} + for (const pair of envStr("RENT_FLOORS", "0:2500,1:3400,2:4600,3:6200,4:7800,5:9500").split(",")) { + const [k, v] = pair.split(":") + const kn = parseInt(k?.trim() ?? "", 10) + const vn = parseInt(v?.trim() ?? "", 10) + if (Number.isFinite(kn) && Number.isFinite(vn)) out[kn] = vn + } + return out +})() + +export const AGGREGATOR_SOURCES = envStr( + "AGGREGATOR_SOURCES", "trulia,hotpads,padmapper,rentcafe,rent,showcase", +).split(",").map((s) => s.trim()).filter(Boolean) + +export const STALE_AGGREGATOR_DAYS = envNum("STALE_AGGREGATOR_DAYS", 14) +export const STALE_DIRECT_DAYS = envNum("STALE_DIRECT_DAYS", 45) + +// Generator tiers and match limit for an interactive search. Enrichment (a +// per-listing Task) dominates wall-clock, so the search must not block on +// enriching every match — the client caps how long it waits and finalizes with +// what's ready (see use-search). +// - Discovery uses the fast "base" generator; finding candidate URLs is easy. +// - Enrichment defaults to "base" here (see the note below). A higher tier +// extracts pages more reliably at the cost of latency; raise it only if blank +// price/beds listings are getting filtered out and hurting recall. +// - match_limit is set well above the number we expect to show. Funnel logs +// show only ~15-20% of verified candidates survive parsing — the rest are +// category/index pages, url-less entries, blocked hosts, or duplicates — so a +// limit of ~25 yields a handful of real listings where 10 yielded ~2. Recall +// scales with the pool (a run that verified 16 kept 6; runs that verified 10 +// kept 2). The cost is more enrichment Tasks per search; the client caps how +// long it waits, so the extra shows up as token cost, not proportional latency. +export const FINDALL_GENERATOR = envStr("FINDALL_GENERATOR", "base") +export const FINDALL_MATCH_LIMIT = envNum("FINDALL_MATCH_LIMIT", 25) +// NOTE: production has always run "base" here (the env var is unset on Vercel +// and the old code read the env directly with a "base" fallback, ignoring this +// constant's former "core" default). Kept at "base" so cleanup changes no +// behavior; bump the env var to "core" deliberately if extraction quality +// warrants the extra latency. +export const FINDALL_ENRICH_PROCESSOR = envStr("FINDALL_ENRICH_PROCESSOR", "base") diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/server/geocode.ts b/typescript-recipes/parallel-apartment-finder/src/lib/server/geocode.ts new file mode 100644 index 0000000..ba6054d --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/server/geocode.ts @@ -0,0 +1,74 @@ +// OSM Nominatim geocoding. +// Callers must sequence requests (~1/s) per Nominatim's usage policy. + +import { CITY, GEO_COUNTRY } from "./config" + +function cleanAddress(address: string): string { + let cleaned = address.replace(/^\d+BR,?\s*/, "") + cleaned = cleaned.replace(/\s*-\s*\$[\d,]+\/month$/, "") + cleaned = cleaned.replace(/,?\s*\$[\d,]+\/month$/, "") + cleaned = cleaned.replace(/\s*-\s*Apartments?\.?.*$/i, "") + cleaned = cleaned.replace(/\s+Apt\.?\s+[\w-]+/gi, "") + cleaned = cleaned.replace(/\s+Unit\s+[\w-]+/gi, "") + cleaned = cleaned.replace(/\s+Suite\s+[\w-]+/gi, "") + cleaned = cleaned.replace(/\s+#\s*[\w-]+/g, "") + cleaned = cleaned.replace(/\s+Apartments?,?\s*/gi, " ") + return cleaned.trim().replace(/,$/, "").trim() +} + +async function queryNominatim(q: string): Promise<{ lat: number; lng: number } | null> { + const params = new URLSearchParams({ + q, format: "json", limit: "1", countrycodes: GEO_COUNTRY, + }) + try { + const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { + headers: { "User-Agent": "ApartmentFinder/1.0 (apartment search app)" }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) return null + const data = await res.json() as { lat: string; lon: string }[] + if (!data.length) return null + const lat = parseFloat(data[0].lat) + const lng = parseFloat(data[0].lon) + return Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null + } catch { + return null + } +} + +// Neighborhood-centroid fallback for when the street address won't resolve. +// Callers must apply the same ~1/s sequencing as geocodeAddress. +export async function geocodeNeighborhood( + neighborhood: string, + city?: string | null, +): Promise<{ lat: number; lng: number } | null> { + const targetCity = city?.trim() || CITY + const n = neighborhood.trim() + if (n.length < 3) return null + return queryNominatim(`${n}, ${targetCity}`) +} + +export async function geocodeAddress( + rawAddress: string, + city?: string | null, +): Promise<{ lat: number; lng: number } | null> { + const targetCity = city?.trim() || CITY + const address = cleanAddress(rawAddress) + if (!address || address.length < 4) return null + + const query = address.toLowerCase().includes(targetCity.toLowerCase()) + ? address : `${address}, ${targetCity}` + const coords = await queryNominatim(query) + if (coords) return coords + + // Fallback: strip the street-type suffix, which sometimes confuses OSM. + const simplified = address.replace( + /\s+(St|Ave|Blvd|Dr|Rd|Ct|Way|Ln|Pl|Street|Avenue|Boulevard|Drive|Road|Court|Place|Lane)\.?\b/gi, "", + ) + if (simplified !== address) { + const fq = simplified.toLowerCase().includes(targetCity.toLowerCase()) + ? simplified : `${simplified}, ${targetCity}` + return queryNominatim(fq) + } + return null +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/server/listings.ts b/typescript-recipes/parallel-apartment-finder/src/lib/server/listings.ts new file mode 100644 index 0000000..0082f10 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/server/listings.ts @@ -0,0 +1,537 @@ +// Candidate → listing parsing, plausibility filters, and scoring. +// Listing parsing, scoring, and dedupe logic for the search pipeline. + +import { + BLOCKED_DOMAINS, LISTING_SITES, RENT_FLOORS, + REFERENCE_POINT_LAT, REFERENCE_POINT_LNG, +} from "./config" +import { cityByName } from "@/lib/bay-area" +import { isSearchOrCategoryUrl } from "@/lib/listing-url" +import type { Candidate } from "./parallel" + +const NA_VALUES = new Set(["", "N/A", "NA", "null", "None", "unknown", "Unknown", "-"]) + +function outputVal(output: Candidate["output"], key: string): string | null { + const obj = output?.[key] + if (!obj) return null + const v = obj.value + if (v == null) return null + const s = String(v).trim() + return NA_VALUES.has(s) ? null : s +} + +function outputFloat(output: Candidate["output"], key: string): number | null { + const s = outputVal(output, key) + if (!s) return null + const m = s.match(/\d+(?:\.\d+)?/) + return m ? parseFloat(m[0]) : null +} + +function outputBool( + output: Candidate["output"], key: string, + trueWords: string[] = ["yes", "true", "available", "allowed", "included"], +): boolean | null { + const s = outputVal(output, key) + if (!s) return null + const sl = s.toLowerCase() + if (trueWords.some((w) => sl.includes(w))) return true + if (["no", "none", "not", "false", "unavailable", "n/a"].some((w) => sl.includes(w))) return false + return null +} + +function parseIntLoose(s: string | null): number | null { + if (!s) return null + const m = s.replace(/\$/g, "").match(/[\d,]+/) + if (!m) return null + const n = parseInt(m[0].replace(/,/g, ""), 10) + return Number.isFinite(n) ? n : null +} + +function cleanExtractedAddress(raw: string): string { + let cleaned = raw.replace(/,?\s*\$[\d,.]+\/?(?:mo|month)?$/i, "") + cleaned = cleaned.replace(/,?\s*\$[\d,.]+\s*$/, "") + return cleaned.trim().replace(/,$/, "").trim() +} + +function addressFromName(name: string): string | null { + if (/\d+\s+\w+\s+(St|Ave|Blvd|Dr|Rd|Way|Ln|Pl|Ct)/.test(name)) { + return cleanExtractedAddress(name) + } + return null +} + +function normalizeAddress(addr: string): string { + let s = addr.toLowerCase().trim() + s = s.replace(/\s*(apt|unit|suite|ste|#)\s*[\w-]+/gi, "") + s = s.replace(/,?\s*[A-Za-z\s]+,\s*[A-Z]{2}\s*\d{5}(-\d{4})?$/, "") + s = s.replace(/,?\s*[A-Z]{2}\s+\d{5}(-\d{4})?$/, "") + s = s.replace(/,?\s*\d{5}(-\d{4})?$/, "") + s = s.replace(/,?\s*[A-Z]{2}$/, "") + return s.trim().replace(/,$/, "").trim() +} + +function detectSource(url: string): string { + for (const site of LISTING_SITES.split(",")) { + const domain = site.trim() + if (domain && url.includes(domain)) return domain.split(".")[0] + } + return "web" +} + +function isBlockedUrl(url: string): boolean { + if (!url) return false + const u = url.toLowerCase() + return BLOCKED_DOMAINS.some((d) => u.includes(d)) +} + +// Lower bound for price-plausibility checks: 55% of the typical rent for +// that bedroom count (permissive enough for BMR units, strict enough to +// catch street-number miscues). $400 floor when beds are unknown. +function absoluteMinPrice(beds: number | null, floors: Record = RENT_FLOORS): number { + if (beds == null) return 400 + const typical = floors[beds] ?? floors[Math.min(beds, 5)] ?? 800 + return Math.floor(typical * 0.55) +} + +// Options threaded from the API routes: per-city rent floors / proximity +// anchor from the Bay Area table (defaults are the env-configured SF values), +// plus the bedroom min/max parsed from the query (studio → max 0). Bedroom +// bounds and budget are RANKING signals in scoreListing, never hard drops. +// Note: user-selected sources are search *includes*, not a filter — they +// steer discovery via the FindAll objective and never reject results here. +export interface ParseOptions { + floors?: Record + refLat?: number + refLng?: number + minBeds?: number | null + maxBeds?: number | null +} + +// A wide rent range in the extracted evidence ("$1,255 - $2,980") is the +// signature of a multi-unit building or category page, not a single unit. +// Lease-term variance on one unit stays narrow, so only flag ratios ≥ 1.4. +function hasWideRentRange(strings: (string | null | undefined)[]): boolean { + for (const s of strings) { + if (!s) continue + const m = s.match(/\$?\s*(\d[\d,]{2,})\s*(?:-|–|—|to)\s*\$?\s*(\d[\d,]{2,})/) + if (!m) continue + const lo = parseInt(m[1].replace(/,/g, ""), 10) + const hi = parseInt(m[2].replace(/,/g, ""), 10) + if (lo > 0 && hi > lo && hi / lo >= 1.4) return true + } + return false +} + +const JUNK_ADDRESS_PATTERNS = [ + /^[A-Z][a-z]+,?\s+[A-Z]{2}$/, + /^[A-Z]{2}\s+\d{5}/, + /^\$[\d,.]+/, + /^\d{1,3}$/, + /apartments?\s+for\s+rent/i, + /bedroom\s+apartments?\s+in/i, + /rentals?\s+in\s+/i, + /housing\s+in\s+/i, +] + +export interface ParsedListing { + id: string + title: string | null + address: string | null + neighborhood: string | null + price: number | null + bedrooms: number | null + bathrooms: number | null + sqft: number | null + lat: number | null + lng: number | null + geo_precision: "address" | "neighborhood" | null + source: string + url: string | null + has_parking: boolean + has_laundry: boolean + spam_score: number + phone: string | null + body: string | null + details: Record + match_basis: { name: string; value: string; matched: boolean }[] + citations: { title: string; url: string }[] + score: number +} + +// US state codes other than CA. This app targets California (SF / Bay Area); +// an explicit non-CA state in a listing's address, name, or URL means the unit +// is out of area (e.g. a "750 Greenwich St, New York, NY" that shares a street +// name with SF and would otherwise geocode near the reference and slip in). +const US_STATES_NON_CA = new Set([ + "AL", "AK", "AZ", "AR", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", + "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", + "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", + "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", "DC", +]) + +function isOutOfCalifornia(address: string, name: string, url: string): boolean { + // Explicit ", XX" state code in the address or candidate name. If CA appears, + // it's in-state; only a non-CA code with no CA present is out of area. + const codes = [...`${address} , ${name}`.matchAll(/,\s*([A-Za-z]{2})\b/g)].map((m) => m[1].toUpperCase()) + if (codes.includes("CA")) return false + if (codes.some((c) => US_STATES_NON_CA.has(c))) return true + // Geo-suffixed listing URL, e.g. ".../750-greenwich-st-new-york-ny//". + // Require a word before the two-letter token so a stray "-2b/" isn't read as + // a state; a real SF deep link ends "-san-francisco-ca/..." (CA → in-state). + const m = url.toLowerCase().match(/-[a-z]{3,}-([a-z]{2})(?:\/|$)/) + if (m) { + const st = m[1].toUpperCase() + if (st !== "CA" && US_STATES_NON_CA.has(st)) return true + } + return false +} + +const WORD_NUM: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 } + +// Best-effort bedroom count from free text (candidate name / description) when +// enrichment didn't return one. Handles digits and spelled-out numbers, with or +// without a hyphen ("2 bed", "1-bedroom", "two br", "studio"). Returns null when +// the text names no count — we show "?" rather than invent a number. +function parseBedsFromText(text: string): number | null { + if (/\bstudio\b/i.test(text)) return 0 + const w = text.match(/\b(one|two|three|four|five|six)[\s-]*(?:bed(?:room)?s?|br|bd)\b/i) + if (w) return WORD_NUM[w[1].toLowerCase()] + const d = text.match(/\b(\d{1,2})[\s-]*(?:bed(?:room)?s?|br|bd)\b/i) + if (d) return parseInt(d[1], 10) + return null +} + +function candidateToListing( + candidate: Candidate, + opts: ParseOptions = {}, + drops?: Record, +): Omit | null { + const name = candidate.name ?? "" + const url = candidate.url ?? "" + const description = candidate.description ?? "" + const output = candidate.output ?? {} + // Record why a candidate is dropped (for the finalize funnel log). + const rej = (reason: string): null => { + if (drops) drops[reason] = (drops[reason] ?? 0) + 1 + return null + } + + if (!url) return rej("no_url") + if (isBlockedUrl(url)) return rej("blocked_host") + if (isSearchOrCategoryUrl(url)) return rej("category_page") + + // Multi-unit building / category page: the rent evidence spans a wide range + // rather than naming one unit's price. Reject so these don't pose as a unit. + const matchConditionValues = Object.values(output) + .filter((o) => o?.type === "match_condition") + .map((o) => String(o?.value ?? "")) + if (hasWideRentRange([outputVal(output, "monthly_rent_usd"), ...matchConditionValues])) return rej("wide_price_range") + + const address = outputVal(output, "street_address") || addressFromName(name) || name + if (!address || address.length < 5) return rej("no_address") + if (isOutOfCalifornia(address, name, url)) return rej("out_of_area") + + let price = parseIntLoose(outputVal(output, "monthly_rent_usd")) + if (price == null) { + for (const [key, obj] of Object.entries(output)) { + const val = obj?.value + if (!val) continue + if (key.includes("rent") || key.includes("price") || key.includes("cost") || key.includes("amount")) { + price = parseIntLoose(String(val)) + if (price != null) break + } + } + } + + let beds = parseIntLoose(outputVal(output, "bedrooms")) + if (beds == null) { + for (const [key, obj] of Object.entries(output)) { + const val = obj?.value + if (!val) continue + if (key.includes("bedroom") || (key.includes("bed") && key.includes("br"))) { + beds = parseIntLoose(String(val)) + if (beds != null) break + } + } + } + + // Pre-enrichment fallback: FindAll candidate names often embed the facts + // ("2500 Mission St Apt 301, $4,550, 2 bedrooms, Mission"), so parse price + // and beds out of the name/description rather than showing "? bd" while + // enrichment is still running. Enriched values (above) always win. The $ + // anchor keeps street numbers from being mistaken for a price. + const nameAndDesc = `${name} ${description}` + if (price == null) { + const m = nameAndDesc.match(/\$\s*([\d,]{3,})(?:\s*\/\s*mo(?:nth)?)?/i) + if (m) price = parseIntLoose(m[1]) + } + if (beds == null) beds = parseBedsFromText(nameAndDesc) + + if (price != null && (price < 500 || price > 50000)) price = null + if (beds != null && (beds < 0 || beds > 10)) beds = null + + // We intentionally do NOT reject listings missing an extracted price/beds. + // If it's a real, accessible individual listing (it passed the category / + // blocked-host / wide-range / address-shape gates), show it with details + // blank rather than hide a place the user could actually open. + + if (price != null && price < absoluteMinPrice(beds, opts.floors)) return rej("below_price_floor") + + // Bedroom count (min/max from the query) is a RANKING signal, not a hard + // gate — see scoreListing. Dropping bedroom mismatches outright left studio + // searches (etc.) with zero results when only nearby-size units enriched. + + // Street-number miscue guard: reject if the "price" appears in the address. + if (price != null) { + for (const m of address.matchAll(/\d+/g)) { + if (parseInt(m[0], 10) === price) return rej("price_is_address") + } + } + + if (JUNK_ADDRESS_PATTERNS.some((p) => p.test(address.trim()))) return rej("junk_address") + + const hasStreetNumber = /\d+\s+\w+/.test(address) + const isNamedBuilding = /(apartments?|towers?|plaza|square|heights|village|terrace|residences|lofts|place)/i.test(name) + if (!hasStreetNumber && !isNamedBuilding) return rej("not_listing_shaped") + + const bathrooms = outputFloat(output, "bathrooms") + const sqftStr = outputVal(output, "square_feet") + let sqft = sqftStr && /\d/.test(sqftStr) ? parseInt(sqftStr.replace(/\D/g, ""), 10) : null + if (sqft != null && (sqft < 100 || sqft > 10000)) sqft = null + + const parkingType = outputVal(output, "parking_type") + const laundryType = outputVal(output, "laundry_type") + let hasParking = outputBool(output, "parking_type", + ["garage", "covered", "carport", "parking", "yes", "available", "included"]) + if (hasParking == null && parkingType) { + hasParking = !parkingType.toLowerCase().includes("no") && !parkingType.toLowerCase().includes("none") + } + let hasLaundry = outputBool(output, "laundry_type", + ["in-unit", "in unit", "washer", "dryer", "laundry", "yes", "shared"]) + if (hasLaundry == null && laundryType) { + hasLaundry = !laundryType.toLowerCase().includes("no") && !laundryType.toLowerCase().includes("none") + } + + const isActiveStr = outputVal(output, "is_currently_active") + let isCurrentlyActive: boolean | null = null + if (isActiveStr != null) { + const sl = isActiveStr.trim().toLowerCase() + if (["yes", "true", "active", "available"].includes(sl)) isCurrentlyActive = true + else if (["no", "false", "leased", "rented", "pending", "unavailable", "removed", "off-market"].includes(sl)) { + isCurrentlyActive = false + } + } + + const daysStr = outputVal(output, "days_on_market") + let daysOnMarket: number | null = null + if (daysStr != null) { + const n = parseInt(daysStr.replace(/\D/g, "") || "0", 10) + daysOnMarket = n >= 0 && n <= 3650 ? n : null + } + + const detailsRaw: Record = { + available_date: outputVal(output, "available_date"), + lease_term: outputVal(output, "lease_term"), + pet_policy: outputVal(output, "pet_policy"), + is_furnished: outputBool(output, "is_furnished"), + utilities_included: outputVal(output, "utilities_included"), + amenities: outputVal(output, "building_amenities"), + neighborhood_name: outputVal(output, "neighborhood"), + parking_type: parkingType, + laundry_type: laundryType, + is_currently_active: isCurrentlyActive, + days_on_market: daysOnMarket, + } + const details = Object.fromEntries( + Object.entries(detailsRaw).filter(([, v]) => v != null && v !== ""), + ) + + const matchBasis: { name: string; value: string; matched: boolean }[] = [] + for (const [key, obj] of Object.entries(output)) { + if (obj?.type === "match_condition") { + matchBasis.push({ name: key, value: String(obj.value ?? ""), matched: Boolean(obj.is_matched) }) + } + } + + const citations: { title: string; url: string }[] = [] + const seen = new Set() + for (const b of candidate.basis ?? []) { + for (const c of b.citations ?? []) { + if (c.url && !seen.has(c.url)) { + seen.add(c.url) + citations.push({ title: (c.title ?? c.url).slice(0, 120), url: c.url }) + } + } + } + + return { + id: url, // stable across polls; saved-targets also keys by url + title: name, + address, + neighborhood: (details.neighborhood_name as string) ?? null, + price, + bedrooms: beds, + bathrooms, + sqft, + lat: null, + lng: null, + geo_precision: null, + source: detectSource(url), + url, + has_parking: hasParking ?? false, + has_laundry: hasLaundry ?? false, + spam_score: 0, + phone: outputVal(output, "contact_phone"), + body: description, + details, + match_basis: matchBasis, + citations: citations.slice(0, 5), + } +} + +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371 + const dLat = ((lat2 - lat1) * Math.PI) / 180 + const dLng = ((lng2 - lng1) * Math.PI) / 180 + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2 + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) +} + +// The Bay Area fits comfortably within ~120km of any of its city references +// (SF to San Jose is ~75km). A geocoded listing farther than this from the +// search's reference point is out of area (e.g. a same-state Los Angeles unit +// that shares a street name), so drop it. A listing that failed to geocode has +// null coords and is kept — absence of a location is not evidence it's far. +export const REGION_MAX_KM = 150 +export function isFarFromReference( + lat: number | null, + lng: number | null, + opts: ParseOptions = {}, +): boolean { + if (opts.refLat == null || opts.refLng == null || lat == null || lng == null) return false + return haversineKm(lat, lng, opts.refLat, opts.refLng) > REGION_MAX_KM +} + +// Equal-weight 3-factor score (recency + price fit + proximity), max 100. +// Every result is freshly discovered, so recency is always full. +export function scoreListing( + l: { + price: number | null; bedrooms: number | null + lat: number | null; lng: number | null + geo_precision?: "address" | "neighborhood" | null + }, + budget: number, + opts: ParseOptions = {}, +): number { + let score = 33 + const floors = opts.floors ?? RENT_FLOORS + + if (l.price) { + const typical = l.bedrooms != null + ? floors[l.bedrooms] ?? floors[Math.min(l.bedrooms, 5)] + : undefined + const ratio = budget ? l.price / budget : 1.0 + let pricePts: number + if (ratio > 1.0) pricePts = 0 + else if (typical != null && l.price < typical * 0.6) pricePts = 6 + else if (ratio <= 0.7) pricePts = 33 + else if (ratio <= 0.8) pricePts = 28 + else if (ratio <= 0.9) pricePts = 22 + else pricePts = 14 + score += pricePts + } + + // Proximity, up to 33. A failed geocode is not evidence the unit is far + // away, so unknown location earns a neutral 12 instead of 0 (otherwise the + // listing caps at 66 and falls below the strong-fit bar on geocoder luck). + // Neighborhood-centroid coords are approximate, so their tiers are + // discounted 25%. + if (l.lat != null && l.lng != null) { + const km = haversineKm( + l.lat, l.lng, + opts.refLat ?? REFERENCE_POINT_LAT, opts.refLng ?? REFERENCE_POINT_LNG, + ) + const full = km < 1.0 ? 33 : km < 2.5 ? 24 : km < 5.0 ? 16 : 9 + score += l.geo_precision === "neighborhood" ? Math.round(full * 0.75) : full + } else { + score += 12 + } + + // Bedroom fit: a known mismatch vs the requested min/max is demoted (so + // exact-size units rank first) but never dropped — a studio search should + // still surface nearby 1BRs rather than nothing. ~15 points per bedroom off. + if (l.bedrooms != null) { + if (opts.minBeds != null && l.bedrooms < opts.minBeds) score -= 15 * (opts.minBeds - l.bedrooms) + if (opts.maxBeds != null && l.bedrooms > opts.maxBeds) score -= 15 * (l.bedrooms - opts.maxBeds) + } + + return Math.max(0, Math.min(score, 100)) +} + +export function parseCandidates( + candidates: Candidate[], + minBeds: number | null, + budget: number, + opts: ParseOptions = {}, + drops?: Record, +): ParsedListing[] { + // Bedroom min/max feed scoreListing as ranking signals; accept it either + // positionally (minBeds) or via opts, whichever the caller set. + const o: ParseOptions = { ...opts, minBeds: opts.minBeds ?? minBeds } + const seenAddresses = new Set() + const out: ParsedListing[] = [] + for (const c of candidates) { + const l = candidateToListing(c, o, drops) + if (!l) continue + // Budget and bedroom fit are ranking signals, not hard gates: an + // accessible listing that's a bit over budget or a nearby size is still + // worth showing (scoring sinks it below the strong fits) rather than + // hidden. We only surface accessible individual listings. + const norm = normalizeAddress(l.address ?? "") + if (norm && norm.length > 3) { + if (seenAddresses.has(norm)) { + if (drops) drops.duplicate_address = (drops.duplicate_address ?? 0) + 1 + continue + } + seenAddresses.add(norm) + } + out.push({ ...l, score: scoreListing(l, budget, o) }) + } + return out +} + +// Shared by the poll/finalize routes: decode the request's city param into +// ParseOptions (per-city rent floors + proximity anchor from the Bay Area +// table). Source selection is a discovery-time include, not a parse filter. +export function parseOptionsFrom(sp: URLSearchParams): ParseOptions { + const opts: ParseOptions = {} + const city = cityByName(sp.get("city")) + if (city) { + opts.floors = city.rentFloors + opts.refLat = city.referencePoint.lat + opts.refLng = city.referencePoint.lng + } + const maxBedsRaw = sp.get("maxBeds") + if (maxBedsRaw != null && maxBedsRaw !== "") { + const n = Number(maxBedsRaw) + if (Number.isFinite(n)) opts.maxBeds = n + } + const minBedsRaw = sp.get("minBeds") + if (minBedsRaw != null && minBedsRaw !== "") { + const n = Number(minBedsRaw) + if (Number.isFinite(n)) opts.minBeds = n + } + return opts +} + +// Bedroom intent parsed from a free-text query. A studio search has an exact +// ceiling of 0; "2 bedroom" is a floor of 2 with no ceiling (2+ is fine). +export function bedroomBounds(query: string): { min: number | null; max: number | null } { + if (/\bstudios?\b/i.test(query) && !/\d\s*(?:br|bed|bedroom)/i.test(query)) { + return { min: 0, max: 0 } + } + const m = query.match(/(\d+)\s*(?:br|bed|bedroom)/i) + return m ? { min: parseInt(m[1], 10), max: null } : { min: null, max: null } +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/server/parallel.ts b/typescript-recipes/parallel-apartment-finder/src/lib/server/parallel.ts new file mode 100644 index 0000000..c47eb7e --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/server/parallel.ts @@ -0,0 +1,338 @@ +// Parallel FindAll API client + prompt definitions. Each function is a single +// short HTTP call — serverless-friendly. + +import { + BLOCKED_DOMAINS, CITY_SHORT, + FINDALL_GENERATOR, FINDALL_MATCH_LIMIT, FINDALL_ENRICH_PROCESSOR, +} from "./config" + +const API_BASE = process.env.PARALLEL_API_BASE ?? "https://api.parallel.ai" +const FINDALL_BETA = "findall-2025-09-15" + +function headers(beta = true): Record { + const key = process.env.PARALLEL_API_KEY + if (!key) throw new Error("PARALLEL_API_KEY is not set") + const h: Record = { + "x-api-key": key, + "Content-Type": "application/json", + } + if (beta) h["parallel-beta"] = FINDALL_BETA + return h +} + +async function parallelFetch(path: string, init?: RequestInit, beta = true): Promise { + const res = await fetch(`${API_BASE}${path}`, { ...init, headers: headers(beta) }) + if (!res.ok) { + const text = await res.text().catch(() => "") + throw new Error(`Parallel API ${res.status}: ${text.slice(0, 200)}`) + } + return res.json() +} + +// ── FindAll match conditions ───────────────────────────────────────────── + +function matchConditions(minBeds: number | null, budget: number, city: string) { + let blockedClause = "" + if (BLOCKED_DOMAINS.length) { + const listed = BLOCKED_DOMAINS.join(", ") + blockedClause = + ` Reject any candidate whose URL is on these domains: ${listed}. ` + + `Prefer the original landlord's, broker's, or property-management website ` + + `over those aggregators.` + } + return [ + { + name: "is_rental_listing", + description: + `The page is one individual rental unit's own listing page in or near ${city}, ` + + "reachable at its own URL and showing a specific street address. " + + "Reject search-results pages, neighborhood or price category/index pages, " + + "pages that list many different properties, directory or map pages, and " + + "news articles." + + blockedClause + + " Mark matched only for a single specific unit's listing on a non-blocked " + + "domain; if the page is a search, category, or multi-property list, mark it not matched.", + }, + { + name: "fits_budget", + description: + `The asking monthly rent is at or below $${budget} US dollars. ` + + "If the rent is not shown on the page, treat this as matched (do not reject for missing data).", + }, + ] +} + +// ── Enrichment field definitions ───────────────────────────────────────── +// Entity → Action → Specifics → Error handling; "" is the unknown sentinel. + +const ENRICHMENTS: { name: string; description: string }[] = [ + { name: "street_address", description: + "Entity: this rental listing's unit address. " + + "Action: extract the exact street address as written on the page. " + + "Specifics: include unit/apt number if shown (e.g. '123 Main St #4'); " + + "do not include city, state, or zip. " + + "If only a neighborhood or no street address is shown, return an empty string." }, + { name: "monthly_rent_usd", description: + "Entity: this rental unit's asking monthly rent. " + + "Action: extract the listed monthly rent. " + + "Specifics: an integer in US dollars, no '$' sign, no commas, no '/mo' " + + "suffix (e.g. '4500' for $4,500/month). Use the headline rent, NOT a " + + "deposit, application fee, security deposit, or 'starting at' range minimum. " + + "Do NOT confuse the rent with the street number of the address, the zip " + + "code, the year built, or square footage. " + + "If no monthly rent is shown on the page, return an empty string." }, + { name: "bedrooms", description: + "Entity: this rental unit. " + + "Action: extract the bedroom count of the unit being advertised. " + + "Specifics: an integer (e.g. '0' for studio, '3' for a 3-bedroom). " + + "Pick the number for the specific unit; do not return a range or " + + "the bedroom counts of other units in the same building. " + + "If the bedroom count is not shown, return an empty string." }, + { name: "bathrooms", description: + "Entity: this rental unit. " + + "Action: extract the bathroom count. " + + "Specifics: as a decimal number (e.g. '1', '1.5', '2.5'). " + + "If the page does not state a bathroom count, return an empty string." }, + { name: "square_feet", description: + "Entity: this rental unit. " + + "Action: extract the interior square footage. " + + "Specifics: as an integer with no commas or 'sqft' suffix (e.g. '1200'). " + + "If the page does not state square footage, return an empty string." }, + { name: "available_date", description: + "Entity: this rental unit's first move-in date. " + + "Action: extract the date the unit is or becomes available. " + + "Specifics: prefer ISO format YYYY-MM-DD if a specific date is shown. " + + "Otherwise return one of these phrases verbatim: 'available now', " + + "'available immediately', or 'available soon'. " + + "If no availability information appears, return an empty string." }, + { name: "lease_term", description: + "Entity: this rental unit's lease length. " + + "Action: extract the lease length and type. " + + "Specifics: short phrase (e.g. '12-month', 'month-to-month', " + + "'6-month minimum', 'flexible'). " + + "If no lease term is mentioned, return an empty string." }, + { name: "pet_policy", description: + "Entity: this rental unit's pet policy. " + + "Action: extract whether pets are allowed and any restrictions. " + + "Specifics: short phrase (e.g. 'Cats OK, no dogs', 'No pets', " + + "'Dogs under 25lb', 'Pets allowed'). " + + "If pets are not mentioned at all, return an empty string." }, + { name: "is_furnished", description: + "Entity: this rental unit. " + + "Action: classify the furnishing status. " + + "Specifics: return one of exactly: 'furnished', 'partially furnished', " + + "'unfurnished'. " + + "If furnishing isn't mentioned, return an empty string." }, + { name: "utilities_included", description: + "Entity: this rental unit. " + + "Action: extract which utilities are included in rent. " + + "Specifics: comma-separated list (e.g. 'water, trash', 'all included', " + + "'none included'). " + + "If utilities are not mentioned, return an empty string." }, + { name: "parking_type", description: + "Entity: this rental unit. " + + "Action: classify the parking situation. " + + "Specifics: short phrase (e.g. 'garage included', '1 covered spot', " + + "'street only', 'no parking', 'extra $200/mo'). " + + "If parking is not mentioned, return an empty string." }, + { name: "laundry_type", description: + "Entity: this rental unit. " + + "Action: classify the laundry situation. " + + "Specifics: short phrase (e.g. 'in-unit washer/dryer', " + + "'shared on floor', 'coin-op in basement', 'none'). " + + "If laundry is not mentioned, return an empty string." }, + { name: "building_amenities", description: + "Entity: the building or property containing this unit. " + + "Action: extract building-level amenities (not unit-specific). " + + "Specifics: comma-separated list of features (e.g. " + + "'gym, rooftop, doorman, elevator, pool'). Exclude utilities and " + + "in-unit features. " + + "If no building amenities are listed, return an empty string." }, + { name: "neighborhood", description: + "Entity: the city neighborhood of this unit. " + + "Action: extract the specific neighborhood name. " + + "Specifics: a single name like 'Downtown', 'Midtown', 'Old Town'; " + + "do not return the city or zip code. " + + "If only the city is mentioned, return an empty string." }, + { name: "contact_phone", description: + "Entity: the contact for this listing. " + + "Action: extract a phone number to inquire about the unit. " + + "Specifics: plain digits with separators (e.g. '(555) 555-1234'). " + + "If no phone number is shown on the page, return an empty string." }, + { name: "contact_email", description: + "Entity: the contact for this listing. " + + "Action: extract an email address to inquire about the unit. " + + "Specifics: a single email address (e.g. 'leasing@example.com'). " + + "If no email is shown on the page, return an empty string." }, + { name: "is_currently_active", description: + "Entity: the listing status of this rental unit. " + + "Action: determine whether the unit is currently being actively marketed. " + + "Specifics: return 'yes' if the page shows this unit is available to rent right now. " + + "Return 'no' if the page indicates the unit is leased, rented, pending, off-market, " + + "no-longer-available, or 'this listing has been removed'. " + + "If the page is reachable and shows a normal listing without a removed/rented " + + "status banner, return 'yes' (assume listed because it's findable)." }, + { name: "days_on_market", description: + "Entity: this rental listing. " + + "Action: extract how many days the unit has been on the market. " + + "Specifics: an integer (e.g. '7'). Use 'days on market', 'listed N days ago', " + + "'posted N days ago', or compute from a 'first listed' / 'posted on' date. " + + "If only a posted date is shown without an explicit count, compute the days " + + "between that date and today. " + + "If no posted date or days-on-market is shown anywhere, return an empty string." }, +] + +// FindAll returns only match-condition fields inline; the per-listing facts +// come from a dedicated enrichment pass whose schema mirrors ENRICHMENTS. +function enrichmentOutputSchema() { + const properties: Record = {} + for (const e of ENRICHMENTS) { + properties[e.name] = { type: "string", description: e.description } + } + return { + type: "object", + properties, + required: Object.keys(properties), + additionalProperties: false, + } +} + +// ── API calls ──────────────────────────────────────────────────────────── + +export interface FindAllCreateResult { + findallId: string + objective: string +} + +export async function findallCreate(opts: { + query: string + budget: number + city?: string | null + requirements?: string | null + neighborhoods?: string[] | null + sources?: string[] | null + minBeds?: number | null +}): Promise { + const city = opts.city?.trim() || CITY_SHORT + // 0 is studio (not "unset"), so express it explicitly rather than dropping it. + const bedsStr = + opts.minBeds == null ? "" : + opts.minBeds === 0 ? "studio " : + `${opts.minBeds} bedroom ` + let objective = + `Find ${bedsStr}apartments for rent ` + + `under ${opts.budget} dollars per month in ${city}` + if (opts.query && !opts.query.toLowerCase().includes(city.toLowerCase())) { + objective += `. ${opts.query}` + } + if (opts.neighborhoods?.length) { + objective += `. Prioritize listings in these ${city} neighborhoods: ${opts.neighborhoods.join(", ")}` + } + if (opts.sources?.length) { + objective += `. Search the web broadly, and be sure to include listings from these websites: ${opts.sources.join(", ")}` + } + if (opts.requirements) objective += `. Requirements: ${opts.requirements}` + // Steer the generator toward real inventory: individual unit pages, not the + // search/category index pages that otherwise fill most of the verified slots. + objective += ". Return individual rental listing pages, each with its own URL and street address; do not return search-results, category, or neighborhood index pages." + + const data = await parallelFetch("/v1beta/findall/runs", { + method: "POST", + body: JSON.stringify({ + objective, + entity_type: "apartment rental listings", + match_conditions: matchConditions(opts.minBeds ?? null, opts.budget, city), + enrichments: ENRICHMENTS, + generator: FINDALL_GENERATOR, + match_limit: FINDALL_MATCH_LIMIT, + }), + }) as Record + + const findallId = (data.findall_id ?? data.run_id) as string | undefined + if (!findallId) throw new Error("FindAll create returned no id") + return { findallId, objective } +} + +export interface FindAllStatus { + state: string + generated: number + matched: number +} + +export async function findallStatus(findallId: string): Promise { + const data = await parallelFetch(`/v1beta/findall/runs/${findallId}`) as Record + const statusObj = data.status + let state: string + let metrics: Record + if (statusObj && typeof statusObj === "object") { + const s = statusObj as Record + state = (s.status as string) ?? "" + metrics = (s.metrics as Record) ?? {} + } else { + state = (statusObj as string) ?? "" + metrics = (data.metrics as Record) ?? {} + } + return { + state, + generated: metrics.generated_candidates_count ?? 0, + matched: metrics.matched_candidates_count ?? 0, + } +} + +export interface Candidate { + name?: string + url?: string + description?: string + match_status?: string + output?: Record + basis?: { citations?: { url?: string; title?: string }[] }[] +} + +export async function findallResult(findallId: string): Promise { + const data = await parallelFetch(`/v1beta/findall/runs/${findallId}/result`) as Record + const candidates = (data.candidates ?? []) as Candidate[] + return candidates.filter((c) => c.match_status === "matched") +} + +export async function findallEnrich(findallId: string): Promise { + await parallelFetch(`/v1beta/findall/runs/${findallId}/enrich`, { + method: "POST", + body: JSON.stringify({ + processor: FINDALL_ENRICH_PROCESSOR, + output_schema: { type: "json", json_schema: enrichmentOutputSchema() }, + }), + }) +} + +// ── Task API (per-listing secondary verification) ──────────────────────── + +export async function taskCreate( + inputData: Record, + outputSchema: Record, + processor: string, +): Promise { + const data = await parallelFetch("/v1/tasks/runs", { + method: "POST", + body: JSON.stringify({ + input: inputData, + task_spec: { output_schema: { type: "json", json_schema: outputSchema } }, + processor, + }), + }, false) as Record + const runId = data.run_id as string | undefined + if (!runId) throw new Error("Task create returned no run_id") + return runId +} + +export async function taskStatus(runId: string): Promise { + const data = await parallelFetch(`/v1/tasks/runs/${runId}`, undefined, false) as Record + const s = data.status + if (s && typeof s === "object") return ((s as Record).status as string) ?? "" + return (s as string) ?? "" +} + +export async function taskResult(runId: string): Promise> { + const data = await parallelFetch(`/v1/tasks/runs/${runId}/result`, undefined, false) as Record + const output = (data.output ?? {}) as Record + return (output.content ?? {}) as Record +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/server/verify.ts b/typescript-recipes/parallel-apartment-finder/src/lib/server/verify.ts new file mode 100644 index 0000000..9419806 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/server/verify.ts @@ -0,0 +1,92 @@ +// Secondary listing verification via the Task API. Approach: avoid subjective +// "is_likely_spam" outputs — decompose into fact-based booleans the API can +// verify with citations, then weight them in code. + +export const SPAM_SCHEMA = { + type: "object", + properties: { + demands_off_platform_payment: { + type: "boolean", + description: + "Entity: this rental listing's body text. " + + "Action: determine if the listing requests payment via wire transfer, " + + "Western Union, MoneyGram, Zelle, Cash App, gift cards, or any other " + + "off-platform / irreversible payment method. " + + "If no payment method is mentioned, return false.", + }, + owner_claims_to_be_abroad: { + type: "boolean", + description: + "Entity: this rental listing's body text. " + + "Action: determine if the owner/landlord explicitly claims to be " + + "out of the country, deployed in the military, relocated for work, " + + "or otherwise unable to show the unit in person. " + + "If no such claim appears, return false.", + }, + withholds_address_until_contact: { + type: "boolean", + description: + "Entity: this rental listing's body text. " + + "Action: determine if the listing explicitly withholds the property " + + "address (e.g., 'address upon serious inquiry', 'message for address'). " + + "If a specific street address is shown, return false. " + + "If no address is mentioned at all, return false.", + }, + no_in_person_viewing_offered: { + type: "boolean", + description: + "Entity: this rental listing's body text. " + + "Action: determine if the listing requires email-only contact and " + + "explicitly disallows or avoids in-person viewings (e.g., 'email only', " + + "'no calls', 'no in-person showings'). " + + "If a phone number, tour link, or open-house time is shown, return false.", + }, + unusual_incentives: { + type: "boolean", + description: + "Entity: this rental listing's body text. " + + "Action: determine if the listing offers unusually generous incentives " + + "that suggest below-market pricing or pressure to commit (e.g., " + + "'first month free', 'no deposit', 'rent well below market'). " + + "Standard offers like 'pet rent waived' or 'parking included' do NOT count. " + + "If no incentives are mentioned, return false.", + }, + }, + required: [ + "demands_off_platform_payment", + "owner_claims_to_be_abroad", + "withholds_address_until_contact", + "no_in_person_viewing_offered", + "unusual_incentives", + ], + additionalProperties: false, +} + +// Weights chosen so any single canonical scam signal alone (off-platform +// payment) clears the hide threshold (50), while soft signals accumulate. +const SPAM_WEIGHTS: Record = { + demands_off_platform_payment: 60, + owner_claims_to_be_abroad: 30, + withholds_address_until_contact: 25, + no_in_person_viewing_offered: 20, + unusual_incentives: 15, +} + +// Sources we trust enough to skip verification on. +export const TRUSTED_SOURCES = new Set([ + "apartments", "zillow", "redfin", "realtor", "trulia", "rent", "hotpads", +]) + +export function computeSpamScore(content: Record): { score: number; flags: string[] } { + let score = 0 + const flags: string[] = [] + for (const [key, weight] of Object.entries(SPAM_WEIGHTS)) { + if (content[key] === true) { + score += weight + flags.push(key) + } + } + return { score: Math.min(100, score), flags } +} + +export const TASK_SPAM_PROCESSOR = process.env.TASK_SPAM_PROCESSOR ?? "base" diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/sources.ts b/typescript-recipes/parallel-apartment-finder/src/lib/sources.ts new file mode 100644 index 0000000..63a9ac5 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/sources.ts @@ -0,0 +1,40 @@ +// Listing-source configuration shared by the source picker UI and the search +// API routes. Selected sources are *includes* — sites to make sure the search +// covers, layered on top of a normal broad web search, not an exclusive +// allowlist. + +export type SourceOption = { domain: string; label: string } + +// The ~10 majors offered as picker chips. zillow.com / apartments.com / yelp +// are intentionally absent — they're in BLOCKED_DOMAINS (aggregators whose +// listings go stale and whose pages resist extraction). +export const MAJOR_SOURCES: SourceOption[] = [ + { domain: "craigslist.org", label: "Craigslist" }, + { domain: "redfin.com", label: "Redfin" }, + { domain: "trulia.com", label: "Trulia" }, + { domain: "hotpads.com", label: "HotPads" }, + { domain: "zumper.com", label: "Zumper" }, + { domain: "padmapper.com", label: "PadMapper" }, + { domain: "rentcafe.com", label: "RentCafe" }, + { domain: "rent.com", label: "Rent.com" }, + { domain: "realtor.com", label: "Realtor.com" }, + { domain: "compass.com", label: "Compass" }, +] + +export const ALL_MAJOR_DOMAINS = MAJOR_SOURCES.map((s) => s.domain) + +// Normalize free-text user input ("https://www.example.com/rentals", "Example.COM") +// to a bare registrable-ish hostname; null when it can't be one. Custom domains +// are echoed into the FindAll prompt, so reject anything that isn't a clean +// hostname. +export function sanitizeDomain(input: string): string | null { + let s = input.trim().toLowerCase() + if (!s) return null + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, "") // strip scheme + s = s.split(/[/?#]/)[0] // strip path + s = s.replace(/^www\./, "") + s = s.replace(/:\d+$/, "") // strip port + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(s)) return null + if (s.length > 100) return null + return s +} diff --git a/typescript-recipes/parallel-apartment-finder/src/lib/utils.ts b/typescript-recipes/parallel-apartment-finder/src/lib/utils.ts new file mode 100644 index 0000000..a16250e --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/lib/utils.ts @@ -0,0 +1,65 @@ +import { isIndividualListingUrl } from "./listing-url" + +/** + * Return `url` only if it is a safe http(s) link, otherwise `fallback`. + * Listing and citation URLs are scraped/LLM-extracted (untrusted); React + * does not block `javascript:`/`data:` hrefs, so we allow-list schemes here. + */ +export function safeUrl(url: string | null | undefined, fallback = "#"): string { + if (!url) return fallback + try { + const u = new URL(url, "https://example.invalid") + return u.protocol === "http:" || u.protocol === "https:" ? url : fallback + } catch { + return fallback + } +} + +/** + * Pick the outbound link for a listing. Always resolve to a specific listing + * page — never a top-level domain / homepage, and never a search/category + * index: prefer the listing URL, then the first citation that is itself an + * individual listing page, and only fall back (e.g. to an address search) + * when no real listing page exists. + */ +// How specific a listing URL is: deeper paths and an id-like (numeric) segment +// mean "an actual unit" vs a shallow geo/browse page (…/mission-san-francisco-ca). +function urlSpecificity(u: string): number { + try { + const segs = new URL(u).pathname.split("/").filter(Boolean) + return segs.length + (segs.some((s) => /\d/.test(s)) ? 2 : 0) + } catch { + return 0 + } +} + +export function pickSourceUrl( + url: string | null | undefined, + citations: { url: string }[] | null | undefined, + fallback = "#", +): string { + // Consider the listing URL and every citation; keep only real individual + // listing links, then pick the MOST SPECIFIC one. This avoids linking to a + // shallow browse/landing page (which passes the "deep" check) when a precise + // unit link is available among the citations. Ties keep the earliest (the + // listing URL first), so behavior is stable. + const candidates = [url, ...(citations ?? []).map((c) => c.url)].filter(isIndividualListingUrl) as string[] + if (!candidates.length) return fallback + let best = candidates[0] + let bestScore = urlSpecificity(best) + for (const c of candidates.slice(1)) { + const s = urlSpecificity(c) + if (s > bestScore) { best = c; bestScore = s } + } + return best +} + +/** Escape a string for safe interpolation into a raw HTML string. */ +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} diff --git a/typescript-recipes/parallel-apartment-finder/src/providers/config-provider.tsx b/typescript-recipes/parallel-apartment-finder/src/providers/config-provider.tsx new file mode 100644 index 0000000..3a2f142 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/providers/config-provider.tsx @@ -0,0 +1,27 @@ +"use client" + +import { createContext, useContext, type ReactNode } from "react" +import type { AppConfig } from "@/types" + +type ConfigState = { + config: AppConfig | null + loading: boolean + error: string | null +} + +const ConfigContext = createContext({ config: null, loading: false, error: null }) + +export function useConfigState() { + return useContext(ConfigContext) +} + +// Config is env-derived and built on the server (lib/server/app-config.ts), +// so the layout passes it in as a prop and it's available on first render — +// no client fetch, no loading screen. +export function ConfigProvider({ config, children }: { config: AppConfig; children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/typescript-recipes/parallel-apartment-finder/src/types/css.d.ts b/typescript-recipes/parallel-apartment-finder/src/types/css.d.ts new file mode 100644 index 0000000..1dd9bee --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/types/css.d.ts @@ -0,0 +1,4 @@ +declare module "*.css" { + const content: { [className: string]: string } + export default content +} diff --git a/typescript-recipes/parallel-apartment-finder/src/types/index.ts b/typescript-recipes/parallel-apartment-finder/src/types/index.ts new file mode 100644 index 0000000..7606f47 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/src/types/index.ts @@ -0,0 +1,88 @@ +export type ListingDetails = { + available_date?: string | null + lease_term?: string | null + pet_policy?: string | null + is_furnished?: boolean | null + utilities_included?: string | null + amenities?: string | null + neighborhood_name?: string | null + parking_type?: string | null + laundry_type?: string | null + is_currently_active?: boolean | null + days_on_market?: number | null +} + +export type MatchCondition = { + name: string + value: string + matched: boolean +} + +export type Citation = { + title: string + url: string +} + +export type Listing = { + id: string + source: string + title: string | null + url: string | null + price: number | null + bedrooms: number | null + bathrooms: number | null + sqft: number | null + address: string | null + neighborhood: string | null + lat: number | null + lng: number | null + geo_precision?: "address" | "neighborhood" | null + has_parking: boolean | null + has_laundry: boolean | null + spam_score: number + spam_flags?: string[] + needs_verification?: boolean + body: string | null + details?: ListingDetails + phone?: string | null + reasoning?: string + score?: number + listed_at?: string | null + fetched_at?: string | null + match_basis?: MatchCondition[] + citations?: Citation[] +} + +export type AppConfig = { + appTitle: string + city: string + cityShort: string + referencePoint: { name: string; lat: number; lng: number } + mapCenter: { lat: number; lng: number } + mapZoom: number + defaultBudget: number + brand: { + name: string + logoUrl: string + } + suggestions: string[] + rentFloors: Record + staleness: { + aggregatorSources: string[] + aggregatorDays: number + directDays: number + } +} + +export type StepStatus = "pending" | "active" | "done" | "error" + +export type ProcessStep = { + id: string + title: string + subtitle?: string + detail?: string + status: StepStatus + progress?: { matched: number; total: number } +} + +export type ViewMode = "list" | "map" | "saved" diff --git a/typescript-recipes/parallel-apartment-finder/tsconfig.json b/typescript-recipes/parallel-apartment-finder/tsconfig.json new file mode 100644 index 0000000..b8cdcc7 --- /dev/null +++ b/typescript-recipes/parallel-apartment-finder/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/website/cookbook.json b/website/cookbook.json index 4de670d..e85b7bf 100644 --- a/website/cookbook.json +++ b/website/cookbook.json @@ -38,6 +38,18 @@ "imageUrl": "https://assets.parallel.ai/cookbook/vercel_cookbook_picture.png", "tags": ["template", "search", "extract", "task", "sse", "vercel", "nextjs"] }, + { + "slug": "parallel-apartment-finder", + "popular": false, + "featured": false, + "title": "Apartment Finder", + "description": "Discover, enrich, and verify real Bay Area apartment listings from natural-language criteria with Parallel FindAll and Task.", + "repoUrl": "https://github.com/parallel-web/parallel-cookbook/tree/main/typescript-recipes/parallel-apartment-finder", + "websiteUrl": "https://apartment-finder-web.vercel.app", + "creators": ["elijahgjacob"], + "imageUrl": "https://apartment-finder-web.vercel.app/opengraph-image", + "tags": ["task", "enrichment", "nextjs", "vercel", "typescript"] + }, { "slug": "parallel-tasks-sse", "popular": true,