diff --git a/.lintstagedrc.js b/.lintstagedrc.js index ca5685beb..839869f43 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -13,6 +13,11 @@ const config = { '*.{js,jsx,ts,tsx,cjs,mjs,json,jsonc,md,yml,yaml,css,scss,html}': [ 'prettier --write', ], + + // JSON schema validation for the project registry + 'src/data/projects.json': [ + 'node scripts/validate-json-schema.js src/data/schema/projects.schema.json', + ], }; export default config; diff --git a/functions/api/projects.test.ts b/functions/api/projects.test.ts new file mode 100644 index 000000000..9f3ec14d8 --- /dev/null +++ b/functions/api/projects.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { onRequest } from './projects'; +import type { Project } from '../types'; + +// Minimal mocks — projects.ts doesn't use env or ctx bindings +const env = {} as Parameters[0]['env']; +const ctx = { + waitUntil: () => {}, + passThroughOnException: () => {}, +} as unknown as ExecutionContext; + +function makeContext(url: string) { + return { request: new Request(url), env, ctx }; +} + +async function getJson(url: string) { + const res = await onRequest(makeContext(url)); + const body = await res.json<{ + data: Project[]; + meta: Record; + }>(); + return { res, body }; +} + +// ─── Unit: response shape ───────────────────────────────────────────────────── + +describe('GET /api/projects — response shape', () => { + it('returns 200 with application/json content-type', async () => { + const res = await onRequest(makeContext('http://localhost/api/projects')); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/json'); + }); + + it('data is an array of project objects', async () => { + const { body } = await getJson('http://localhost/api/projects'); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThan(0); + + const first = body.data[0]; + expect(first).toHaveProperty('slug'); + expect(first).toHaveProperty('title'); + expect(first).toHaveProperty('description'); + expect(first).toHaveProperty('repositoryUrls'); + expect(first).toHaveProperty('projectUrl'); + expect(first).toHaveProperty('status'); + }); + + it('meta contains expected pagination fields', async () => { + const { body } = await getJson('http://localhost/api/projects'); + const { meta } = body; + expect(meta).toHaveProperty('total'); + expect(meta).toHaveProperty('page'); + expect(meta).toHaveProperty('limit'); + expect(meta).toHaveProperty('totalPages'); + expect(meta).toHaveProperty('hasNextPage'); + expect(meta).toHaveProperty('hasPrevPage'); + }); +}); + +// ─── Unit: status filter ────────────────────────────────────────────────────── + +describe('GET /api/projects — status filter', () => { + it('returns only active projects when status=active', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?status=active' + ); + expect(body.data.length).toBeGreaterThan(0); + expect(body.data.every((p: Project) => p.status === 'active')).toBe(true); + }); + + it('returns only development projects when status=development', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?status=development' + ); + expect(body.data.every((p: Project) => p.status === 'development')).toBe( + true + ); + }); + + it('returns only archived projects when status=archived', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?status=archived' + ); + expect(body.data.every((p: Project) => p.status === 'archived')).toBe(true); + }); + + it('returns 400 for an invalid status value', async () => { + const res = await onRequest( + makeContext('http://localhost/api/projects?status=invalid') + ); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toMatch(/invalid status/i); + }); +}); + +// ─── Unit: search ───────────────────────────────────────────────────────────── + +describe('GET /api/projects — search', () => { + it('matches on title (case-insensitive)', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?search=BUDGET' + ); + expect(body.data.length).toBeGreaterThan(0); + expect( + body.data.every( + (p: Project) => + p.title.toLowerCase().includes('budget') || + p.description.toLowerCase().includes('budget') + ) + ).toBe(true); + }); + + it('matches on description (case-insensitive)', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?search=procurement' + ); + expect(body.data.length).toBeGreaterThan(0); + expect( + body.data.some((p: Project) => + p.description.toLowerCase().includes('procurement') + ) + ).toBe(true); + }); + + it('returns empty data array for a search term with no matches', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?search=zzz-no-match-xyz' + ); + expect(body.data).toHaveLength(0); + expect(body.meta.total).toBe(0); + }); + + it('can combine search and status filters', async () => { + const { body } = await getJson( + 'http://localhost/api/projects?search=budget&status=active' + ); + expect(body.data.every((p: Project) => p.status === 'active')).toBe(true); + }); +}); + +// ─── Unit: pagination ───────────────────────────────────────────────────────── + +describe('GET /api/projects — pagination', () => { + let totalProjects: number; + + beforeAll(async () => { + const { body } = await getJson('http://localhost/api/projects'); + totalProjects = body.meta.total as number; + }); + + it('defaults to page=1 and limit=20', async () => { + const { body } = await getJson('http://localhost/api/projects'); + expect(body.meta.page).toBe(1); + expect(body.meta.limit).toBe(20); + }); + + it('respects a custom limit', async () => { + const { body } = await getJson('http://localhost/api/projects?limit=5'); + expect(body.data.length).toBeLessThanOrEqual(5); + expect(body.meta.limit).toBe(5); + }); + + it('respects a custom page', async () => { + const { body: page1 } = await getJson( + 'http://localhost/api/projects?limit=3&page=1' + ); + const { body: page2 } = await getJson( + 'http://localhost/api/projects?limit=3&page=2' + ); + expect(page1.data[0].slug).not.toBe(page2.data[0]?.slug); + }); + + it('caps limit at 100', async () => { + const { body } = await getJson('http://localhost/api/projects?limit=999'); + expect(body.meta.limit).toBe(100); + }); + + it('meta.total reflects unfiltered dataset size', async () => { + const { body } = await getJson('http://localhost/api/projects?limit=1'); + expect(body.meta.total).toBe(totalProjects); + }); + + it('hasPrevPage is false on page 1', async () => { + const { body } = await getJson('http://localhost/api/projects?page=1'); + expect(body.meta.hasPrevPage).toBe(false); + }); + + it('hasNextPage is true when results exceed one page', async () => { + const { body } = await getJson('http://localhost/api/projects?limit=1'); + if (totalProjects > 1) { + expect(body.meta.hasNextPage).toBe(true); + } + }); + + it('clamps out-of-range page to last valid page', async () => { + const { body } = await getJson('http://localhost/api/projects?page=9999'); + expect(body.meta.page).toBeLessThanOrEqual(body.meta.totalPages as number); + }); + + it('treats non-numeric page as 1', async () => { + const { body } = await getJson('http://localhost/api/projects?page=abc'); + expect(body.meta.page).toBe(1); + }); + + it('treats non-numeric limit as 20', async () => { + const { body } = await getJson('http://localhost/api/projects?limit=abc'); + expect(body.meta.limit).toBe(20); + }); +}); diff --git a/functions/api/projects.ts b/functions/api/projects.ts new file mode 100644 index 000000000..ff8ec68d4 --- /dev/null +++ b/functions/api/projects.ts @@ -0,0 +1,75 @@ +import { Env, Project } from '../types'; +import projectsData from '../../src/data/projects.json'; + +const VALID_STATUSES = ['active', 'development', 'archived'] as const; +type ProjectStatus = (typeof VALID_STATUSES)[number]; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function onRequest(context: { + request: Request; + env: Env; + ctx: ExecutionContext; +}): Promise { + const url = new URL(context.request.url); + const params = url.searchParams; + + const search = params.get('search')?.trim().toLowerCase() ?? null; + const statusParam = params.get('status'); + const pageParam = params.get('page'); + const limitParam = params.get('limit'); + + if ( + statusParam !== null && + !VALID_STATUSES.includes(statusParam as ProjectStatus) + ) { + return jsonResponse( + { + error: `Invalid status value. Allowed values: ${VALID_STATUSES.join(', ')}`, + }, + 400 + ); + } + + const page = Math.max(1, parseInt(pageParam ?? '1', 10) || 1); + const limit = Math.min( + 100, + Math.max(1, parseInt(limitParam ?? '20', 10) || 20) + ); + + let projects = projectsData as Project[]; + + if (statusParam) { + projects = projects.filter(p => p.status === statusParam); + } + + if (search) { + projects = projects.filter( + p => + p.title.toLowerCase().includes(search) || + p.description.toLowerCase().includes(search) + ); + } + + const total = projects.length; + const totalPages = Math.max(1, Math.ceil(total / limit)); + const safePage = Math.min(page, totalPages); + const offset = (safePage - 1) * limit; + + return jsonResponse({ + data: projects.slice(offset, offset + limit), + meta: { + total, + page: safePage, + limit, + totalPages, + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + }, + }); +} diff --git a/functions/index.ts b/functions/index.ts index f8b41876d..4fc374559 100644 --- a/functions/index.ts +++ b/functions/index.ts @@ -8,6 +8,7 @@ import { onRequest as forexRequest, } from './api/forex'; import { onRequest as crawlRequest } from './api/crawl'; +import { onRequest as projectsRequest } from './api/projects'; import { onRequest as weatherKVRequest } from './weather'; import { onRequest as forexKVRequest } from './forex'; import { Env } from './types'; @@ -108,6 +109,19 @@ export default { }); } + if (path === '/api/projects') { + const response = await projectsRequest({ request, env, ctx }); + const newHeaders = new Headers(response.headers); + Object.keys(corsHeaders).forEach(key => { + newHeaders.set(key, corsHeaders[key]); + }); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } + if (path === '/api/crawl') { const response = await crawlRequest({ request, env, ctx }); // Add CORS headers to the response @@ -127,7 +141,7 @@ export default { return new Response( JSON.stringify({ status: 'online', - functions: ['weather', 'forex', 'crawl'], + functions: ['weather', 'forex', 'crawl', 'projects'], endpoints: [ { path: '/api/weather', @@ -204,6 +218,35 @@ export default { }, ], }, + { + path: '/api/projects', + description: + 'Get officially recognized BetterGov.ph projects with optional filtering, search, and pagination', + parameters: [ + { + name: 'search', + required: false, + description: + 'Case-insensitive substring search across title and description', + }, + { + name: 'status', + required: false, + description: + 'Filter by project status: active, development, or archived', + }, + { + name: 'page', + required: false, + description: 'Page number (1-based, default: 1)', + }, + { + name: 'limit', + required: false, + description: 'Items per page (default: 20, max: 100)', + }, + ], + }, ], timestamp: new Date().toISOString(), }), @@ -225,6 +268,7 @@ export default { '/api/weather', '/api/forex', '/api/crawl', + '/api/projects', '/weather', '/forex', ], diff --git a/functions/integration/projects.integration.test.ts b/functions/integration/projects.integration.test.ts new file mode 100644 index 000000000..fdd0142d4 --- /dev/null +++ b/functions/integration/projects.integration.test.ts @@ -0,0 +1,124 @@ +/** + * Integration tests for the /api/projects route through the full Worker + * fetch handler (functions/index.ts), which adds CORS headers and handles + * routing. No live server is started — the handler is called directly. + */ +import { describe, it, expect } from 'vitest'; +import worker from '../index'; +import type { Project } from '../types'; + +const env = {} as Parameters[1]; +const ctx = { + waitUntil: () => {}, + passThroughOnException: () => {}, +} as unknown as ExecutionContext; + +async function workerFetch(url: string, method = 'GET') { + return worker.fetch(new Request(url, { method }), env, ctx); +} + +async function workerJson(url: string) { + const res = await workerFetch(url); + const body = await res.json<{ + data: Project[]; + meta: Record; + }>(); + return { res, body }; +} + +// ─── Routing ────────────────────────────────────────────────────────────────── + +describe('Worker routing — /api/projects', () => { + it('routes GET /api/projects to the projects handler', async () => { + const res = await workerFetch('http://localhost/api/projects'); + expect(res.status).toBe(200); + }); + + it('returns 404 for unrecognised paths', async () => { + const res = await workerFetch('http://localhost/api/does-not-exist'); + expect(res.status).toBe(404); + const body = await res.json<{ availableEndpoints: string[] }>(); + expect(body.availableEndpoints).toContain('/api/projects'); + }); + + it('lists /api/projects in /api/status endpoint list', async () => { + const res = await workerFetch('http://localhost/api/status'); + expect(res.status).toBe(200); + const body = await res.json<{ functions: string[] }>(); + expect(body.functions).toContain('projects'); + }); +}); + +// ─── CORS ───────────────────────────────────────────────────────────────────── + +describe('Worker CORS — /api/projects', () => { + it('includes Access-Control-Allow-Origin: * on GET', async () => { + const res = await workerFetch('http://localhost/api/projects'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + + it('responds to OPTIONS preflight with 200 and CORS headers', async () => { + const res = await workerFetch('http://localhost/api/projects', 'OPTIONS'); + expect(res.status).toBe(200); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(res.headers.get('Access-Control-Allow-Methods')).toContain('GET'); + }); +}); + +// ─── End-to-end query param flow ───────────────────────────────────────────── + +describe('Worker end-to-end — /api/projects query params', () => { + it('returns full project list with correct pagination meta', async () => { + const { body } = await workerJson('http://localhost/api/projects'); + expect(body.data.length).toBeGreaterThan(0); + expect(body.meta.page).toBe(1); + expect(body.meta.limit).toBe(20); + expect(typeof body.meta.total).toBe('number'); + }); + + it('filters by status=active end-to-end', async () => { + const { body } = await workerJson( + 'http://localhost/api/projects?status=active' + ); + expect(body.data.every((p: Project) => p.status === 'active')).toBe(true); + }); + + it('searches projects end-to-end', async () => { + const { body } = await workerJson( + 'http://localhost/api/projects?search=hotlines' + ); + expect(body.data.length).toBeGreaterThan(0); + expect( + body.data.some( + (p: Project) => + p.title.toLowerCase().includes('hotlines') || + p.description.toLowerCase().includes('hotlines') + ) + ).toBe(true); + }); + + it('paginates results end-to-end', async () => { + const { body } = await workerJson( + 'http://localhost/api/projects?limit=3&page=1' + ); + expect(body.data.length).toBeLessThanOrEqual(3); + expect(body.meta.limit).toBe(3); + }); + + it('returns 400 for invalid status through the full worker', async () => { + const res = await workerFetch('http://localhost/api/projects?status=bogus'); + expect(res.status).toBe(400); + }); + + it('all returned projects have required fields', async () => { + const { body } = await workerJson('http://localhost/api/projects'); + for (const project of body.data) { + expect(project.slug).toBeTruthy(); + expect(project.title).toBeTruthy(); + expect(project.description).toBeTruthy(); + expect(Array.isArray(project.repositoryUrls)).toBe(true); + expect(project.projectUrl).toMatch(/^https:\/\//); + expect(['active', 'development', 'archived']).toContain(project.status); + } + }); +}); diff --git a/functions/integration/projects.live.test.ts b/functions/integration/projects.live.test.ts new file mode 100644 index 000000000..a4f156ee6 --- /dev/null +++ b/functions/integration/projects.live.test.ts @@ -0,0 +1,217 @@ +/** + * Live integration tests for GET /api/projects. + * + * These tests start a real Wrangler dev server (same runtime as production) + * and make actual HTTP requests against it. Run with: + * + * npm run test:functions:live + * + * Requires no external services — the projects endpoint only serves bundled + * JSON data. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { unstable_dev } from 'wrangler'; +import type { Unstable_DevWorker } from 'wrangler'; + +let worker: Unstable_DevWorker; +let baseUrl: string; + +beforeAll(async () => { + worker = await unstable_dev('./functions/index.ts', { + experimental: { disableExperimentalWarning: true }, + logLevel: 'error', + config: 'wrangler.test.jsonc', + }); + baseUrl = `http://${worker.address}:${worker.port}`; +}); + +afterAll(async () => { + await worker?.stop(); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function get(path: string) { + return fetch(`${baseUrl}${path}`); +} + +async function getJson(path: string) { + const res = await get(path); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body = (await res.json()) as any; + return { res, body }; +} + +// ─── Health & routing ───────────────────────────────────────────────────────── + +describe('Live — routing', () => { + it('GET /api/projects returns 200', async () => { + const res = await get('/api/projects'); + expect(res.status).toBe(200); + }); + + it('GET /api/status lists projects as an available function', async () => { + const { body } = await getJson('/api/status'); + expect(body.functions).toContain('projects'); + const projectsEndpoint = body.endpoints.find( + (e: { path: string }) => e.path === '/api/projects' + ); + expect(projectsEndpoint).toBeDefined(); + }); + + it('unknown route returns 404 with /api/projects in availableEndpoints', async () => { + const { res, body } = await getJson('/api/not-found'); + expect(res.status).toBe(404); + expect(body.availableEndpoints).toContain('/api/projects'); + }); +}); + +// ─── CORS ───────────────────────────────────────────────────────────────────── + +describe('Live — CORS headers', () => { + it('GET response includes Access-Control-Allow-Origin: *', async () => { + const res = await get('/api/projects'); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('OPTIONS preflight returns 200 with CORS headers', async () => { + const res = await fetch(`${baseUrl}/api/projects`, { method: 'OPTIONS' }); + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(res.headers.get('access-control-allow-methods')).toContain('GET'); + }); +}); + +// ─── Response shape ─────────────────────────────────────────────────────────── + +describe('Live — response shape', () => { + it('returns application/json content-type', async () => { + const res = await get('/api/projects'); + expect(res.headers.get('content-type')).toContain('application/json'); + }); + + it('body has data array and meta object', async () => { + const { body } = await getJson('/api/projects'); + expect(Array.isArray(body.data)).toBe(true); + expect(typeof body.meta).toBe('object'); + }); + + it('every project has all required fields with correct types', async () => { + const { body } = await getJson('/api/projects'); + for (const project of body.data) { + expect(typeof project.slug).toBe('string'); + expect(typeof project.title).toBe('string'); + expect(typeof project.description).toBe('string'); + expect(Array.isArray(project.repositoryUrls)).toBe(true); + expect(project.projectUrl).toMatch(/^https:\/\//); + expect(['active', 'development', 'archived']).toContain(project.status); + } + }); + + it('meta contains correct pagination fields', async () => { + const { body } = await getJson('/api/projects'); + expect(typeof body.meta.total).toBe('number'); + expect(body.meta.page).toBe(1); + expect(body.meta.limit).toBe(20); + expect(typeof body.meta.totalPages).toBe('number'); + expect(typeof body.meta.hasNextPage).toBe('boolean'); + expect(typeof body.meta.hasPrevPage).toBe('boolean'); + }); +}); + +// ─── Filtering ──────────────────────────────────────────────────────────────── + +describe('Live — status filter', () => { + it('?status=active returns only active projects', async () => { + const { body } = await getJson('/api/projects?status=active'); + expect(body.data.length).toBeGreaterThan(0); + expect( + body.data.every((p: { status: string }) => p.status === 'active') + ).toBe(true); + }); + + it('?status=invalid returns 400 with error message', async () => { + const { res, body } = await getJson('/api/projects?status=invalid'); + expect(res.status).toBe(400); + expect(typeof body.error).toBe('string'); + expect(body.error).toMatch(/invalid status/i); + }); +}); + +// ─── Search ─────────────────────────────────────────────────────────────────── + +describe('Live — search', () => { + it('?search=hotlines matches the Hotlines project by title', async () => { + const { body } = await getJson('/api/projects?search=hotlines'); + expect(body.data.length).toBeGreaterThan(0); + expect( + body.data.some( + (p: { title: string; description: string }) => + p.title.toLowerCase().includes('hotlines') || + p.description.toLowerCase().includes('hotlines') + ) + ).toBe(true); + }); + + it('?search=budget returns budget-related projects', async () => { + const { body } = await getJson('/api/projects?search=budget'); + expect(body.data.length).toBeGreaterThan(0); + expect( + body.data.every( + (p: { title: string; description: string }) => + p.title.toLowerCase().includes('budget') || + p.description.toLowerCase().includes('budget') + ) + ).toBe(true); + }); + + it('?search= returns empty data and total=0', async () => { + const { body } = await getJson('/api/projects?search=zzz-nomatch-xyz'); + expect(body.data).toHaveLength(0); + expect(body.meta.total).toBe(0); + }); + + it('search is case-insensitive', async () => { + const { body: lower } = await getJson('/api/projects?search=budget'); + const { body: upper } = await getJson('/api/projects?search=BUDGET'); + expect(lower.meta.total).toBe(upper.meta.total); + }); +}); + +// ─── Pagination ─────────────────────────────────────────────────────────────── + +describe('Live — pagination', () => { + it('?limit=3 returns at most 3 results', async () => { + const { body } = await getJson('/api/projects?limit=3'); + expect(body.data.length).toBeLessThanOrEqual(3); + expect(body.meta.limit).toBe(3); + }); + + it('?page=2&limit=3 returns a different slice than page=1', async () => { + const { body: p1 } = await getJson('/api/projects?page=1&limit=3'); + const { body: p2 } = await getJson('/api/projects?page=2&limit=3'); + expect(p1.data[0].slug).not.toBe(p2.data[0]?.slug); + }); + + it('?limit=999 is capped at 100', async () => { + const { body } = await getJson('/api/projects?limit=999'); + expect(body.meta.limit).toBe(100); + }); + + it('hasPrevPage=false on page 1', async () => { + const { body } = await getJson('/api/projects?page=1'); + expect(body.meta.hasPrevPage).toBe(false); + }); + + it('hasNextPage=true when total > limit', async () => { + const { body } = await getJson('/api/projects?limit=1'); + if (body.meta.total > 1) { + expect(body.meta.hasNextPage).toBe(true); + } + }); + + it('out-of-range page clamps to last valid page', async () => { + const { body } = await getJson('/api/projects?page=9999'); + expect(body.meta.page).toBeLessThanOrEqual(body.meta.totalPages); + }); +}); diff --git a/functions/types.ts b/functions/types.ts index 7db914620..fbd165d14 100644 --- a/functions/types.ts +++ b/functions/types.ts @@ -1,3 +1,12 @@ +export interface Project { + slug: string; + title: string; + description: string; + repositoryUrls: string[]; + projectUrl: string; + status: 'active' | 'development' | 'archived'; +} + export interface Env { // KV Namespaces WEATHER_KV: KVNamespace; diff --git a/functions/vitest.config.ts b/functions/vitest.config.ts new file mode 100644 index 000000000..30757469e --- /dev/null +++ b/functions/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts'], + exclude: ['node_modules', 'dist', '**/*.live.test.ts'], + }, +}); diff --git a/functions/vitest.live.config.ts b/functions/vitest.live.config.ts new file mode 100644 index 000000000..c609ae326 --- /dev/null +++ b/functions/vitest.live.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.live.test.ts'], + exclude: ['node_modules', 'dist'], + // Server startup can take several seconds + hookTimeout: 30000, + testTimeout: 15000, + }, +}); diff --git a/package-lock.json b/package-lock.json index db39d317f..078210907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,8 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", "uuid": "^9.0.1", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^4.1.5" } }, "node_modules/@algolia/abtesting": { @@ -1134,6 +1135,18 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -1144,6 +1157,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1976,9 +2000,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1995,9 +2016,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2014,9 +2032,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2033,9 +2048,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2052,9 +2064,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2071,9 +2080,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2090,9 +2096,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2109,9 +2112,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2128,9 +2128,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2153,9 +2150,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2178,9 +2172,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2203,9 +2194,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2228,9 +2216,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2253,9 +2238,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2278,9 +2260,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2303,9 +2282,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2472,6 +2448,35 @@ "meilisearch": "0.50" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3181,6 +3186,263 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3280,9 +3542,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3297,9 +3556,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3314,9 +3570,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3331,9 +3584,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3348,9 +3598,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3365,9 +3612,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3382,9 +3626,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3399,9 +3640,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3416,9 +3654,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3433,9 +3668,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3450,9 +3682,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3467,9 +3696,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3484,9 +3710,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3743,9 +3966,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3763,9 +3983,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3783,9 +4000,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3803,9 +4017,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3922,6 +4133,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3967,6 +4189,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", @@ -4040,6 +4273,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-speech-recognition": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.1.tgz", @@ -4412,56 +4652,149 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "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/@typescript-eslint/visitor-keys/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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/visitor-keys/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==", + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, + "license": "MIT", "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/abbrev": { @@ -4783,6 +5116,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4975,6 +5318,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -6093,6 +6446,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6601,6 +6961,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6618,6 +6988,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8344,9 +8724,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8368,9 +8745,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8392,9 +8766,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8416,9 +8787,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9223,6 +9591,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -10070,6 +10449,47 @@ "dev": true, "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -10421,6 +10841,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10470,6 +10897,20 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10847,6 +11288,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", @@ -10874,6 +11322,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -11851,6 +12309,216 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -12021,6 +12689,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index a33cce10d..647bbc17f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "wrangler": "wrangler", "preview": "vite preview", "prepare": "husky", - "functions:dev": "wrangler dev ./functions/index.ts", + "test:functions": "vitest run --config functions/vitest.config.ts", + "test:functions:watch": "vitest --config functions/vitest.config.ts", + "test:functions:live": "vitest run --config functions/vitest.live.config.ts", + "functions:dev": "wrangler dev ./functions/index.ts --config wrangler.test.jsonc", "functions:build": "tsc -p ./functions/tsconfig.json", "functions:deploy": "wrangler deploy ./functions/index.ts", "index:meilisearch": "node ./scripts/index_meilisearch.cjs", @@ -99,7 +102,8 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", "uuid": "^9.0.1", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^4.1.5" }, "overrides": { "react-helmet-async": { diff --git a/scripts/validate-json-schema.js b/scripts/validate-json-schema.js index 1259dc51a..34dfa697a 100755 --- a/scripts/validate-json-schema.js +++ b/scripts/validate-json-schema.js @@ -51,7 +51,7 @@ try { process.exit(1); } -const ajv = new Ajv({ allErrors: true, verbose: true }); +const ajv = new Ajv({ allErrors: true, verbose: true, strict: false }); let validate; try { validate = ajv.compile(schema); diff --git a/src/data/projects.json b/src/data/projects.json new file mode 100644 index 000000000..9facf6512 --- /dev/null +++ b/src/data/projects.json @@ -0,0 +1,136 @@ +[ + { + "slug": "bettergov-portal", + "title": "BetterGov.ph Portal", + "description": "Community-led Philippine national government portal providing accessible information on government services, agencies, and officials.", + "repositoryUrls": ["https://github.com/bettergovph/bettergov"], + "projectUrl": "https://bettergov.ph", + "status": "active" + }, + { + "slug": "budget-tracker-2026", + "title": "2026 Budget Tracker", + "description": "Interactive tracker for the 2026 Philippine national budget, enabling citizens to monitor government spending allocations.", + "repositoryUrls": ["https://github.com/bettergovph/2026-budget"], + "projectUrl": "https://2026-budget.bettergov.ph", + "status": "active" + }, + { + "slug": "better-lgu", + "title": "Better LGU", + "description": "Directory and information platform for Philippine local government units, covering provinces, cities, and municipalities.", + "repositoryUrls": [], + "projectUrl": "https://lgu.bettergov.ph", + "status": "active" + }, + { + "slug": "budget-tracker", + "title": "Budget Tracker", + "description": "Interactive platform for tracking and visualizing Philippine national budget allocations and expenditures.", + "repositoryUrls": ["https://github.com/bettergovph/open-budget-browser"], + "projectUrl": "https://budget.bettergov.ph", + "status": "active" + }, + { + "slug": "transparency-portal", + "title": "Transparency Portal", + "description": "Civic transparency platform aggregating Philippine government financial and procurement data for public accountability.", + "repositoryUrls": ["https://github.com/bettergovph/transparency-dashboard"], + "projectUrl": "https://transparency.bettergov.ph", + "status": "active" + }, + { + "slug": "open-data-portal", + "title": "Open Data Portal", + "description": "Public data platform providing accessible Philippine government datasets for transparency, research, and civic innovation.", + "repositoryUrls": ["https://github.com/bettergovph/open-data-portal"], + "projectUrl": "https://data.bettergov.ph", + "status": "active" + }, + { + "slug": "bantay-ph", + "title": "Bantay PH", + "description": "Community-powered watchdog platform for monitoring Philippine government projects, contracts, and public spending.", + "repositoryUrls": [], + "projectUrl": "https://bantay.bettergov.ph", + "status": "active" + }, + { + "slug": "petitions", + "title": "Petitions", + "description": "Online petition platform for Filipino citizens to raise civic issues and advocate for government action.", + "repositoryUrls": ["https://github.com/bettergovph/petition"], + "projectUrl": "https://petition.ph", + "status": "active" + }, + { + "slug": "tax-directory", + "title": "Tax Directory", + "description": "Public-facing platform providing tax-related tools and calculators for easier citizen access to tax information.", + "repositoryUrls": ["https://github.com/bettergovph/ph-tax-directory"], + "projectUrl": "https://taxdirectory.bettergov.ph", + "status": "active" + }, + { + "slug": "philgeps", + "title": "Philgeps", + "description": "BetterGov interface for exploring and monitoring Philippine Government Electronic Procurement System data.", + "repositoryUrls": ["https://github.com/bettergovph/open-philgeps-data"], + "projectUrl": "https://philgeps.bettergov.ph", + "status": "active" + }, + { + "slug": "saln-tracker", + "title": "SALN Tracker", + "description": "Platform for tracking and exploring Statement of Assets, Liabilities, and Net Worth filings of Philippine public officials.", + "repositoryUrls": ["https://github.com/bettergovph/saln-tracker-ph"], + "projectUrl": "https://saln.bettergov.ph", + "status": "active" + }, + { + "slug": "hotlines", + "title": "Hotlines", + "description": "Comprehensive directory of emergency and government hotlines in the Philippines.", + "repositoryUrls": ["https://github.com/bettergovph/hotlines"], + "projectUrl": "https://hotlines.bettergov.ph", + "status": "active" + }, + { + "slug": "open-bayan", + "title": "Open Bayan", + "description": "Open civic data platform connecting Filipinos to barangay-level government information.", + "repositoryUrls": ["https://github.com/bettergovph/openbayan.org"], + "projectUrl": "https://www.openbayan.org", + "status": "active" + }, + { + "slug": "open-congress-api", + "title": "Open Congress API", + "description": "Open API providing structured data on the Philippine Congress, including legislative records and member information.", + "repositoryUrls": ["https://github.com/bettergovph/open-congress-api"], + "projectUrl": "https://open-congress-api.bettergov.ph", + "status": "active" + }, + { + "slug": "opengov-blockchain", + "title": "OpenGov Blockchain", + "description": "Blockchain-based platform for ensuring transparency and immutability of Philippine government records.", + "repositoryUrls": [ + "https://github.com/bettergovph/govchain", + "https://github.com/bettergovph/govchaind", + "https://github.com/bettergovph/govchain-ts" + ], + "projectUrl": "https://govchain.bettergov.ph", + "status": "active" + }, + { + "slug": "research-and-visualizations", + "title": "Research & Visualizations", + "description": "Data visualization platform presenting insights from Philippine government data through interactive charts and analyses.", + "repositoryUrls": [ + "https://github.com/bettergovph/open-data-visualization" + ], + "projectUrl": "https://visualizations.bettergov.ph", + "status": "active" + } +] diff --git a/src/data/schema/projects.schema.json b/src/data/schema/projects.schema.json new file mode 100644 index 000000000..91981db6d --- /dev/null +++ b/src/data/schema/projects.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "BetterGov.ph Project Registry", + "description": "Registry of officially recognized BetterGov.ph projects", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "slug", + "title", + "description", + "repositoryUrls", + "projectUrl", + "status" + ], + "additionalProperties": false, + "properties": { + "slug": { + "type": "string", + "description": "Unique URL-friendly identifier for the project", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "description": "Display name of the project", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Short description of what the project does", + "minLength": 1 + }, + "repositoryUrls": { + "type": "array", + "description": "GitHub or other VCS repository URLs (supports multi-repo projects; can be empty if repo is private)", + "items": { + "type": "string", + "pattern": "^https://" + } + }, + "projectUrl": { + "type": "string", + "description": "Live URL where the project is accessible", + "pattern": "^https://" + }, + "status": { + "type": "string", + "description": "Current development/deployment status of the project", + "enum": ["active", "development", "archived"] + } + } + } +} diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc new file mode 100644 index 000000000..4cdf5f41c --- /dev/null +++ b/wrangler.test.jsonc @@ -0,0 +1,20 @@ +{ + // Wrangler config for local function development and testing. + // Does not require a built ./dist directory — functions only, no static assets. + "name": "bettergovph-test", + "compatibility_date": "2025-11-17", + "kv_namespaces": [ + { + "id": "124001eaebfb4815b32db5344fc59251", + "binding": "BROWSER_KV", + }, + { + "id": "a1151781bef24f3092aa3ddefe86aac5", + "binding": "FOREX_KV", + }, + { + "id": "7e1c950bb42d4282b13e3e9524ce5335", + "binding": "WEATHER_KV", + }, + ], +}