From 59dc4f075c56c7f8fa607249c5994445ec354404 Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 13:08:59 +0800 Subject: [PATCH 01/10] feat: add project schema --- scripts/validate-json-schema.js | 2 +- src/data/schema/projects.schema.json | 54 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/data/schema/projects.schema.json 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/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"] + } + } + } +} From 71410bd9d44a5bf79bb0071b42e0191f9d650636 Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 13:09:57 +0800 Subject: [PATCH 02/10] feat: add schema validation for projects.json file --- .lintstagedrc.js | 5 +++++ 1 file changed, 5 insertions(+) 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; From d2273272b1b62390cda366ea137de6433be42f62 Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 13:10:34 +0800 Subject: [PATCH 03/10] feat: create api for projects --- functions/api/projects.ts | 75 +++++++++++++++++++++++++++++++++++++++ functions/index.ts | 46 +++++++++++++++++++++++- functions/types.ts | 9 +++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 functions/api/projects.ts 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/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; From 64d59eb1ff29a55da7574d893e32bd3606387b18 Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 13:32:58 +0800 Subject: [PATCH 04/10] feat: seed projects.json with existing linked projects in bettergov.ph main site --- src/data/projects.json | 136 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/data/projects.json 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" + } +] From 53a8544e03ad24644a2c654c4022818a2317d375 Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 14:43:23 +0800 Subject: [PATCH 05/10] chore: add vitest for api testing --- package-lock.json | 979 +++++++++++++++++++++++++++++++++++++++------- package.json | 6 +- 2 files changed, 837 insertions(+), 148 deletions(-) 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..6d14f05d6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "wrangler": "wrangler", "preview": "vite preview", "prepare": "husky", + "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", "functions:build": "tsc -p ./functions/tsconfig.json", "functions:deploy": "wrangler deploy ./functions/index.ts", @@ -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": { From ee92f1955652e1fef55510a77ffb096fb07dd2aa Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 14:43:52 +0800 Subject: [PATCH 06/10] feat: add unit, integration, and live tests for projects api --- functions/api/projects.test.ts | 210 +++++++++++++++++ .../integration/projects.integration.test.ts | 124 ++++++++++ functions/integration/projects.live.test.ts | 217 ++++++++++++++++++ functions/vitest.config.ts | 9 + functions/vitest.live.config.ts | 12 + wrangler.test.jsonc | 5 + 6 files changed, 577 insertions(+) create mode 100644 functions/api/projects.test.ts create mode 100644 functions/integration/projects.integration.test.ts create mode 100644 functions/integration/projects.live.test.ts create mode 100644 functions/vitest.config.ts create mode 100644 functions/vitest.live.config.ts create mode 100644 wrangler.test.jsonc 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/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/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/wrangler.test.jsonc b/wrangler.test.jsonc new file mode 100644 index 000000000..c7164bf97 --- /dev/null +++ b/wrangler.test.jsonc @@ -0,0 +1,5 @@ +{ + // Minimal wrangler config for local test runs — no assets directory required. + "name": "bettergovph-test", + "compatibility_date": "2025-11-17", +} From c773f59c0c644fbe2ae17ca1bbfae406ebb0ec91 Mon Sep 17 00:00:00 2001 From: zelkim Date: Fri, 8 May 2026 14:53:17 +0800 Subject: [PATCH 07/10] feat: change wrangler.test.jsonc to conform to needs for functions:dev --- package.json | 2 +- wrangler.test.jsonc | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6d14f05d6..647bbc17f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "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", + "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", diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc index c7164bf97..4cdf5f41c 100644 --- a/wrangler.test.jsonc +++ b/wrangler.test.jsonc @@ -1,5 +1,20 @@ { - // Minimal wrangler config for local test runs — no assets directory required. + // 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", + }, + ], } From c3a448f66d1da0d042d481184ee36ad3ef955c0e Mon Sep 17 00:00:00 2001 From: zelkim Date: Sun, 10 May 2026 01:02:56 +0800 Subject: [PATCH 08/10] feat!: revert project api implementation --- .lintstagedrc.js | 2 +- functions/api/projects.test.ts | 210 ----------------- functions/api/projects.ts | 75 ------ functions/index.ts | 46 +--- .../integration/projects.integration.test.ts | 124 ---------- functions/integration/projects.live.test.ts | 217 ------------------ functions/types.ts | 9 - functions/vitest.config.ts | 9 - functions/vitest.live.config.ts | 12 - package.json | 8 +- src/data/projects.json | 136 ----------- wrangler.test.jsonc | 20 -- 12 files changed, 4 insertions(+), 864 deletions(-) delete mode 100644 functions/api/projects.test.ts delete mode 100644 functions/api/projects.ts delete mode 100644 functions/integration/projects.integration.test.ts delete mode 100644 functions/integration/projects.live.test.ts delete mode 100644 functions/vitest.config.ts delete mode 100644 functions/vitest.live.config.ts delete mode 100644 src/data/projects.json delete mode 100644 wrangler.test.jsonc diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 839869f43..0e4831289 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -15,7 +15,7 @@ const config = { ], // JSON schema validation for the project registry - 'src/data/projects.json': [ + 'public/api/projects.json': [ 'node scripts/validate-json-schema.js src/data/schema/projects.schema.json', ], }; diff --git a/functions/api/projects.test.ts b/functions/api/projects.test.ts deleted file mode 100644 index 9f3ec14d8..000000000 --- a/functions/api/projects.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index ff8ec68d4..000000000 --- a/functions/api/projects.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 4fc374559..f8b41876d 100644 --- a/functions/index.ts +++ b/functions/index.ts @@ -8,7 +8,6 @@ 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'; @@ -109,19 +108,6 @@ 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 @@ -141,7 +127,7 @@ export default { return new Response( JSON.stringify({ status: 'online', - functions: ['weather', 'forex', 'crawl', 'projects'], + functions: ['weather', 'forex', 'crawl'], endpoints: [ { path: '/api/weather', @@ -218,35 +204,6 @@ 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(), }), @@ -268,7 +225,6 @@ 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 deleted file mode 100644 index fdd0142d4..000000000 --- a/functions/integration/projects.integration.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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 deleted file mode 100644 index a4f156ee6..000000000 --- a/functions/integration/projects.live.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * 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 fbd165d14..7db914620 100644 --- a/functions/types.ts +++ b/functions/types.ts @@ -1,12 +1,3 @@ -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 deleted file mode 100644 index 30757469e..000000000 --- a/functions/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index c609ae326..000000000 --- a/functions/vitest.live.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -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.json b/package.json index 647bbc17f..a33cce10d 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,7 @@ "wrangler": "wrangler", "preview": "vite preview", "prepare": "husky", - "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:dev": "wrangler dev ./functions/index.ts", "functions:build": "tsc -p ./functions/tsconfig.json", "functions:deploy": "wrangler deploy ./functions/index.ts", "index:meilisearch": "node ./scripts/index_meilisearch.cjs", @@ -102,8 +99,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", "uuid": "^9.0.1", - "vite": "^5.4.2", - "vitest": "^4.1.5" + "vite": "^5.4.2" }, "overrides": { "react-helmet-async": { diff --git a/src/data/projects.json b/src/data/projects.json deleted file mode 100644 index 9facf6512..000000000 --- a/src/data/projects.json +++ /dev/null @@ -1,136 +0,0 @@ -[ - { - "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/wrangler.test.jsonc b/wrangler.test.jsonc deleted file mode 100644 index 4cdf5f41c..000000000 --- a/wrangler.test.jsonc +++ /dev/null @@ -1,20 +0,0 @@ -{ - // 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", - }, - ], -} From 444a699e4cdfe1ce37aad6bf97e7f0a20dcb9a99 Mon Sep 17 00:00:00 2001 From: zelkim Date: Sun, 10 May 2026 01:05:08 +0800 Subject: [PATCH 09/10] feat: add static projects.json in public/api/ --- CLAUDE.md | 110 +++++++++++++++++++++++++++++++ public/api/projects.json | 136 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 CLAUDE.md create mode 100644 public/api/projects.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ad8ed6d03 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +BetterGov.ph is a community-led initiative to create a better Philippine national government portal. It is a React SPA deployed to Netlify (static) and/or Cloudflare Workers (with serverless functions). + +## Commands + +```bash +# Development +npm install # Install dependencies (Node.js v22+) +npm run dev # Start Vite dev server at http://localhost:5173 + +# Build +npm run build # Type check → generate metadata/sitemap → Vite build (outputs to dist/) + +# Lint & Format +npm run lint # ESLint with zero warnings policy +npm run format # Prettier + +# Testing +npm run test:e2e # Run all Playwright E2E tests headlessly +npm run test:e2e:ui # Open Playwright interactive UI +npm run test:e2e:debug # Run with Playwright Inspector +npx playwright test e2e/homepage.spec.ts # Run a single test file + +# Cloudflare Functions (serverless) +npm run functions:dev # Local dev server via wrangler +npm run functions:build # TypeScript compile functions/ +npm run functions:deploy # Deploy to Cloudflare Workers + +# Search Indexing (Meilisearch) +npm run index:meilisearch # Index government services +npm run index:flood-control:arcgis # Index flood control data +npm run index:all # Run all indexers +``` + +## Architecture + +### Frontend (src/) + +React 19 SPA using React Router v6, Vite, Tailwind CSS v4, and TypeScript. + +- **`src/App.tsx`** — root router; all routes defined here with nested layouts +- **`src/pages/`** — page components mirroring URL structure. Government section uses nested `layout.tsx` + `index.tsx` + `[param].tsx` pattern +- **`src/components/layout/`** — `Navbar.tsx`, `Footer.tsx`, `Breadcrumb.tsx` +- **`src/components/ui/`** — reusable primitives (Button, Card, Dialog, ScrollArea, etc.) +- **`src/data/`** — static JSON data files: + - `directory/` — government directory data (executive, departments, legislative, diplomatic, constitutional, LGU data by region) + - `services/` — government services by category + - `visa/`, `websites/`, `hotlines` — domain-specific data + - `schema/` — JSON Schema files for validating data files +- **`src/lib/`** — utilities: `api.ts` (fetch-with-cache), `lgu.ts`, `forex.ts`, `weather.ts`, `seoTemplates.ts` +- **`src/i18n.ts`** — i18next configuration; translation files live in `public/locales/{locale}/{namespace}.json` + +Path alias: `@/` resolves to `src/`. + +### Cloudflare Functions (functions/) + +Serverless workers separate from the React app, deployed via Wrangler: + +- **`api/weather.ts`** — fetches Philippine city weather from OpenWeatherMap; stores in `WEATHER_KV` +- **`api/forex.ts`** — fetches BSP exchange rates; stores in `FOREX_KV` +- **`api/crawl.ts`** — generic web crawler with pluggable backends (Jina.ai or Cloudflare Browser Rendering); stores in D1 `BETTERGOV_DB` +- **`lib/crawler.ts`** — `WebCrawler` interface; `lib/jina.ts` and `lib/cf-browser.ts` are implementations + +Functions have their own `tsconfig.json` targeting ES2022 with `@cloudflare/workers-types`. + +### Search (Meilisearch) + +Search is powered by Meilisearch via `@meilisearch/instant-meilisearch` and `react-instantsearch`. Scripts in `scripts/` handle indexing. See `docs/Meilisearch.md` for setup. + +### Build Pipeline + +`npm run build` runs in sequence: +1. `tsc` — TypeScript type check +2. `generate:metadata` — runs `generate-llms-txt.js` and `generate-sitemap.js` +3. `tsx scripts/write-ver-to-json.ts` — writes build version to `src/version.json` +4. `vite build` — outputs to `dist/` + +Husky + lint-staged runs linting/formatting on commit. Commitlint enforces Conventional Commits. + +## Conventions + +### Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): `[scope]: ` + +Common types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +### Data Files + +Static JSON data in `src/data/` drives most government directory pages. JSON schemas in `src/data/**/schema/` validate structure. Run validation with: + +```bash +node scripts/validate-json-schema.js "" +``` + +### Translations (i18n) + +- Add new namespace JSON to `public/locales/en/` (English is the required fallback) +- Register namespace in `src/i18n.ts` +- Use `useTranslation('namespace')` in components +- `nuqs` is used for URL search param state management + +### PR Disclosure + +Disclose AI-assisted contributions in PR descriptions to help maintainers review more thoroughly. diff --git a/public/api/projects.json b/public/api/projects.json new file mode 100644 index 000000000..9facf6512 --- /dev/null +++ b/public/api/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" + } +] From 0b6a922ba3ae0aeb68e43505cbc1c8f4f83a3ab3 Mon Sep 17 00:00:00 2001 From: zelkim Date: Sun, 10 May 2026 01:07:31 +0800 Subject: [PATCH 10/10] chore: cleanup, rm CLAUDE.md local knowledgebase --- CLAUDE.md | 110 ------------------------------------------------------ 1 file changed, 110 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ad8ed6d03..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,110 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -BetterGov.ph is a community-led initiative to create a better Philippine national government portal. It is a React SPA deployed to Netlify (static) and/or Cloudflare Workers (with serverless functions). - -## Commands - -```bash -# Development -npm install # Install dependencies (Node.js v22+) -npm run dev # Start Vite dev server at http://localhost:5173 - -# Build -npm run build # Type check → generate metadata/sitemap → Vite build (outputs to dist/) - -# Lint & Format -npm run lint # ESLint with zero warnings policy -npm run format # Prettier - -# Testing -npm run test:e2e # Run all Playwright E2E tests headlessly -npm run test:e2e:ui # Open Playwright interactive UI -npm run test:e2e:debug # Run with Playwright Inspector -npx playwright test e2e/homepage.spec.ts # Run a single test file - -# Cloudflare Functions (serverless) -npm run functions:dev # Local dev server via wrangler -npm run functions:build # TypeScript compile functions/ -npm run functions:deploy # Deploy to Cloudflare Workers - -# Search Indexing (Meilisearch) -npm run index:meilisearch # Index government services -npm run index:flood-control:arcgis # Index flood control data -npm run index:all # Run all indexers -``` - -## Architecture - -### Frontend (src/) - -React 19 SPA using React Router v6, Vite, Tailwind CSS v4, and TypeScript. - -- **`src/App.tsx`** — root router; all routes defined here with nested layouts -- **`src/pages/`** — page components mirroring URL structure. Government section uses nested `layout.tsx` + `index.tsx` + `[param].tsx` pattern -- **`src/components/layout/`** — `Navbar.tsx`, `Footer.tsx`, `Breadcrumb.tsx` -- **`src/components/ui/`** — reusable primitives (Button, Card, Dialog, ScrollArea, etc.) -- **`src/data/`** — static JSON data files: - - `directory/` — government directory data (executive, departments, legislative, diplomatic, constitutional, LGU data by region) - - `services/` — government services by category - - `visa/`, `websites/`, `hotlines` — domain-specific data - - `schema/` — JSON Schema files for validating data files -- **`src/lib/`** — utilities: `api.ts` (fetch-with-cache), `lgu.ts`, `forex.ts`, `weather.ts`, `seoTemplates.ts` -- **`src/i18n.ts`** — i18next configuration; translation files live in `public/locales/{locale}/{namespace}.json` - -Path alias: `@/` resolves to `src/`. - -### Cloudflare Functions (functions/) - -Serverless workers separate from the React app, deployed via Wrangler: - -- **`api/weather.ts`** — fetches Philippine city weather from OpenWeatherMap; stores in `WEATHER_KV` -- **`api/forex.ts`** — fetches BSP exchange rates; stores in `FOREX_KV` -- **`api/crawl.ts`** — generic web crawler with pluggable backends (Jina.ai or Cloudflare Browser Rendering); stores in D1 `BETTERGOV_DB` -- **`lib/crawler.ts`** — `WebCrawler` interface; `lib/jina.ts` and `lib/cf-browser.ts` are implementations - -Functions have their own `tsconfig.json` targeting ES2022 with `@cloudflare/workers-types`. - -### Search (Meilisearch) - -Search is powered by Meilisearch via `@meilisearch/instant-meilisearch` and `react-instantsearch`. Scripts in `scripts/` handle indexing. See `docs/Meilisearch.md` for setup. - -### Build Pipeline - -`npm run build` runs in sequence: -1. `tsc` — TypeScript type check -2. `generate:metadata` — runs `generate-llms-txt.js` and `generate-sitemap.js` -3. `tsx scripts/write-ver-to-json.ts` — writes build version to `src/version.json` -4. `vite build` — outputs to `dist/` - -Husky + lint-staged runs linting/formatting on commit. Commitlint enforces Conventional Commits. - -## Conventions - -### Commit Messages - -Follow [Conventional Commits](https://www.conventionalcommits.org/): `[scope]: ` - -Common types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` - -### Data Files - -Static JSON data in `src/data/` drives most government directory pages. JSON schemas in `src/data/**/schema/` validate structure. Run validation with: - -```bash -node scripts/validate-json-schema.js "" -``` - -### Translations (i18n) - -- Add new namespace JSON to `public/locales/en/` (English is the required fallback) -- Register namespace in `src/i18n.ts` -- Use `useTranslation('namespace')` in components -- `nuqs` is used for URL search param state management - -### PR Disclosure - -Disclose AI-assisted contributions in PR descriptions to help maintainers review more thoroughly.