mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
init(app): Setup client package for web api specific logic
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
seed.generated.sql
|
||||
worker-configuration.d.ts
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "cv-tailor",
|
||||
"dependencies": {
|
||||
"hono": "^4",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1",
|
||||
"@types/js-yaml": "^4",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"js-yaml": "^4",
|
||||
"typescript": "^5",
|
||||
"vite": "^7",
|
||||
"wrangler": "^4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||
|
||||
"@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.43.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260701.0", "unenv": "2.0.0-rc.24", "wrangler": "4.107.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-5ThLXS2ZXPoCa9xpRqDi9BebpkkbYbAhUU1lMomD+UXEp2SeB4/pVxeFF0j/9nbBlUhDOm5aariI31h8ZcXrBg=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260701.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260701.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260701.1", "", { "os": "linux", "cpu": "x64" }, "sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260701.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260701.1", "", { "os": "win32", "cpu": "x64" }, "sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.41", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.384", "", {}, "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260701.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260701.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"workerd": ["workerd@1.20260701.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260701.1", "@cloudflare/workerd-darwin-arm64": "1.20260701.1", "@cloudflare/workerd-linux-64": "1.20260701.1", "@cloudflare/workerd-linux-arm64": "1.20260701.1", "@cloudflare/workerd-windows-64": "1.20260701.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ=="],
|
||||
|
||||
"wrangler": ["wrangler@4.107.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260701.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260701.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260701.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ChatMessage, JobExtract, ResumePlan } from "../shared/types";
|
||||
import { Home } from "./screens/Home";
|
||||
import { PdfScreen } from "./screens/Pdf";
|
||||
import { Workspace } from "./screens/Workspace";
|
||||
|
||||
export interface GeneratedResume {
|
||||
plan: ResumePlan;
|
||||
source: "ai" | "fallback";
|
||||
note?: string;
|
||||
latex: string;
|
||||
pdfUrl: string;
|
||||
}
|
||||
|
||||
type Stage =
|
||||
| { name: "home" }
|
||||
| { name: "workspace"; jd: JobExtract }
|
||||
| { name: "pdf"; jd: JobExtract; result: GeneratedResume };
|
||||
|
||||
export function App() {
|
||||
const [stage, setStage] = useState<Stage>({ name: "home" });
|
||||
const [chat, setChat] = useState<ChatMessage[]>([]);
|
||||
|
||||
const openWorkspace = useCallback((jd: JobExtract) => {
|
||||
setChat([]);
|
||||
setStage({ name: "workspace", jd });
|
||||
}, []);
|
||||
|
||||
if (stage.name === "home") return <Home onExtracted={openWorkspace} />;
|
||||
|
||||
if (stage.name === "workspace") {
|
||||
return (
|
||||
<Workspace
|
||||
jd={stage.jd}
|
||||
messages={chat}
|
||||
onMessagesChange={setChat}
|
||||
onBack={() => setStage({ name: "home" })}
|
||||
onGenerated={(result) => setStage({ name: "pdf", jd: stage.jd, result })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PdfScreen
|
||||
result={stage.result}
|
||||
onBack={() => setStage({ name: "workspace", jd: stage.jd })}
|
||||
onRecompiled={(result) => setStage({ name: "pdf", jd: stage.jd, result })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export class ApiRequestError extends Error {
|
||||
readonly log?: string;
|
||||
|
||||
constructor(message: string, log?: string) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.log = log;
|
||||
}
|
||||
}
|
||||
|
||||
async function errorOf(res: Response, fallback: string): Promise<ApiRequestError> {
|
||||
let message = fallback;
|
||||
let log: string | undefined;
|
||||
try {
|
||||
const data: unknown = await res.json();
|
||||
if (data && typeof data === "object") {
|
||||
if ("error" in data && typeof data.error === "string") message = data.error;
|
||||
if ("log" in data && typeof data.log === "string") log = data.log;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
return new ApiRequestError(message, log);
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw await errorOf(res, `${path} failed (${res.status})`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile LaTeX to a PDF blob.
|
||||
* Dev: the Vite middleware at /local/compile runs the machine's real pdflatex.
|
||||
* Deployed: that route 404s and we fall back to the Worker's remote compile proxy.
|
||||
*/
|
||||
export async function compilePdf(latex: string): Promise<Blob> {
|
||||
const local = await fetch("/local/compile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ latex }),
|
||||
});
|
||||
if (local.ok) return local.blob();
|
||||
if (local.status === 422) throw await errorOf(local, "pdflatex failed");
|
||||
|
||||
const remote = await fetch("/api/pdf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ latex }),
|
||||
});
|
||||
if (remote.ok) return remote.blob();
|
||||
throw await errorOf(remote, `PDF compile failed (${remote.status})`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing #root element");
|
||||
createRoot(rootEl).render(<App />);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import type { JobExtract } from "../../shared/types";
|
||||
import { ApiRequestError, post } from "../api";
|
||||
|
||||
const MIN_TEXT_CHARS = 120;
|
||||
|
||||
export function Home({ onExtracted }: { onExtracted: (jd: JobExtract) => void }) {
|
||||
const [mode, setMode] = useState<"url" | "paste">("url");
|
||||
const [url, setUrl] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submitUrl(): Promise<void> {
|
||||
if (busy || url.trim().length === 0) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
onExtracted(await post<JobExtract>("/api/extract", { url }));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiRequestError ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function submitPaste(): void {
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedText = text.trim();
|
||||
if (trimmedTitle.length === 0) {
|
||||
setError("Give the job opportunity a title.");
|
||||
return;
|
||||
}
|
||||
if (trimmedText.length < MIN_TEXT_CHARS) {
|
||||
setError(`Paste the full job description (at least ${MIN_TEXT_CHARS} characters).`);
|
||||
return;
|
||||
}
|
||||
onExtracted({ url: "", title: trimmedTitle, text: trimmedText, source: "manual" });
|
||||
}
|
||||
|
||||
function switchMode(next: "url" | "paste"): void {
|
||||
setMode(next);
|
||||
setError("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<div className="home-card">
|
||||
<h1>CV Tailor</h1>
|
||||
<p className="sub">
|
||||
{mode === "url"
|
||||
? "Paste a link to a job posting. The résumé corpus is matched against it, you refine the selection with an AI agent, and a one-page LaTeX résumé is compiled to PDF."
|
||||
: "Paste the job description below. The résumé corpus is matched against it, you refine the selection with an AI agent, and a one-page LaTeX résumé is compiled to PDF."}
|
||||
</p>
|
||||
|
||||
{mode === "url" ? (
|
||||
<form
|
||||
className="home-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitUrl();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://boards.greenhouse.io/company/jobs/123456"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn primary" disabled={busy}>
|
||||
{busy ? "Extracting…" : "Tailor Résumé"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="paste-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitPaste();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Job opportunity title, e.g. Senior Platform Engineer at Aptos"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste the full job description here…"
|
||||
rows={12}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn primary">
|
||||
Tailor Résumé
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{mode === "url" ? (
|
||||
<>
|
||||
<p className="hint">
|
||||
Server-rendered boards (Greenhouse, Lever, Workable, Ashby job-post pages) extract
|
||||
best.
|
||||
</p>
|
||||
<button type="button" className="link" onClick={() => switchMode("paste")}>
|
||||
Or manually paste job description
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="link" onClick={() => switchMode("url")}>
|
||||
Or extract from a job posting URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import type { GeneratedResume } from "../App";
|
||||
import { ApiRequestError, compilePdf } from "../api";
|
||||
|
||||
interface PdfScreenProps {
|
||||
result: GeneratedResume;
|
||||
onBack: () => void;
|
||||
onRecompiled: (result: GeneratedResume) => void;
|
||||
}
|
||||
|
||||
export function PdfScreen({ result, onBack, onRecompiled }: PdfScreenProps) {
|
||||
const [latex, setLatex] = useState(result.latex);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const dirty = latex !== result.latex;
|
||||
|
||||
async function recompile(): Promise<void> {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const blob = await compilePdf(latex);
|
||||
URL.revokeObjectURL(result.pdfUrl);
|
||||
onRecompiled({ ...result, latex, pdfUrl: URL.createObjectURL(blob) });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError && err.log) {
|
||||
setError(`${err.message}\n\n${err.log}`);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pdf-screen">
|
||||
<header className="topbar">
|
||||
<button type="button" className="btn ghost" onClick={onBack}>
|
||||
← Back to workspace
|
||||
</button>
|
||||
<div className="topbar-title">
|
||||
<strong>Tailored résumé</strong>
|
||||
<span className="muted">
|
||||
plan source: {result.source}
|
||||
{result.note ? ` (${result.note})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<button type="button" className="btn" onClick={() => void recompile()} disabled={busy || !dirty}>
|
||||
{busy ? "Compiling…" : "Recompile"}
|
||||
</button>
|
||||
<a className="btn primary" href={result.pdfUrl} download="prad-nukala-resume.pdf">
|
||||
Download PDF
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <pre className="error banner">{error}</pre>}
|
||||
|
||||
<div className="pdf-grid">
|
||||
<section className="panel">
|
||||
<h2>LaTeX source {dirty && <span className="muted">(edited — recompile to refresh preview)</span>}</h2>
|
||||
<textarea
|
||||
className="latex-editor"
|
||||
value={latex}
|
||||
onChange={(e) => setLatex(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2>Preview</h2>
|
||||
<iframe className="preview-frame" title="PDF preview" src={result.pdfUrl} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatMessage, JobExtract, MatchResult, PlanResponse } from "../../shared/types";
|
||||
import type { GeneratedResume } from "../App";
|
||||
import { ApiRequestError, compilePdf, post } from "../api";
|
||||
|
||||
interface WorkspaceProps {
|
||||
jd: JobExtract;
|
||||
messages: ChatMessage[];
|
||||
onMessagesChange: (messages: ChatMessage[]) => void;
|
||||
onBack: () => void;
|
||||
onGenerated: (result: GeneratedResume) => void;
|
||||
}
|
||||
|
||||
export function Workspace({ jd, messages, onMessagesChange, onBack, onGenerated }: WorkspaceProps) {
|
||||
const [match, setMatch] = useState<MatchResult | null>(null);
|
||||
const [matchError, setMatchError] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [chatBusy, setChatBusy] = useState(false);
|
||||
const [genStep, setGenStep] = useState("");
|
||||
const [genError, setGenError] = useState("");
|
||||
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
post<MatchResult>("/api/match", { text: jd.text })
|
||||
.then((m) => {
|
||||
if (!cancelled) setMatch(m);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setMatchError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [jd.text]);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, chatBusy]);
|
||||
|
||||
async function sendChat(): Promise<void> {
|
||||
const content = draft.trim();
|
||||
if (content.length === 0 || chatBusy || !match) return;
|
||||
const next: ChatMessage[] = [...messages, { role: "user", content }];
|
||||
onMessagesChange(next);
|
||||
setDraft("");
|
||||
setChatBusy(true);
|
||||
try {
|
||||
const { reply } = await post<{ reply: string }>("/api/chat", { jd, match, messages: next });
|
||||
onMessagesChange([...next, { role: "assistant", content: reply }]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
onMessagesChange([...next, { role: "assistant", content: `(error) ${message}` }]);
|
||||
} finally {
|
||||
setChatBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
if (!match || genStep) return;
|
||||
setGenError("");
|
||||
try {
|
||||
setGenStep("Planning selection with AI…");
|
||||
const planRes = await post<PlanResponse>("/api/plan", { jd, match, chat: messages });
|
||||
setGenStep("Rendering LaTeX…");
|
||||
const { latex } = await post<{ latex: string }>("/api/latex", { plan: planRes.plan });
|
||||
setGenStep("Compiling PDF…");
|
||||
const blob = await compilePdf(latex);
|
||||
onGenerated({
|
||||
plan: planRes.plan,
|
||||
source: planRes.source,
|
||||
note: planRes.note,
|
||||
latex,
|
||||
pdfUrl: URL.createObjectURL(blob),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError && err.log) {
|
||||
setGenError(`${err.message}\n\n${err.log}`);
|
||||
} else {
|
||||
setGenError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setGenStep("");
|
||||
}
|
||||
}
|
||||
|
||||
const matchedItems = match?.items.filter((i) => i.score > 0) ?? [];
|
||||
|
||||
return (
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<button type="button" className="btn ghost" onClick={onBack}>
|
||||
← New posting
|
||||
</button>
|
||||
<div className="topbar-title">
|
||||
<strong>{jd.title}</strong>
|
||||
<span className="muted">
|
||||
{jd.source === "manual" ? "pasted manually" : `${jd.url} · extracted via ${jd.source}`}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="btn primary" onClick={() => void generate()} disabled={!match || genStep !== ""}>
|
||||
{genStep !== "" ? genStep : "Generate PDF"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{genError && <pre className="error banner">{genError}</pre>}
|
||||
|
||||
<div className="workspace-grid">
|
||||
<section className="panel">
|
||||
<h2>Job Posting</h2>
|
||||
<pre className="jd-text">{jd.text}</pre>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>
|
||||
Matched Material
|
||||
{match && <span className="muted"> — {match.skills.length} skills hit</span>}
|
||||
</h2>
|
||||
{matchError && <div className="error">{matchError}</div>}
|
||||
{!match && !matchError && <div className="loading">Matching against the corpus…</div>}
|
||||
{match && (
|
||||
<>
|
||||
<div className="chips">
|
||||
{match.skills.map((s) => (
|
||||
<span key={s.name} className={`chip ${s.category}`} title={s.category}>
|
||||
{s.name} <em>×{s.hits}</em>
|
||||
</span>
|
||||
))}
|
||||
{match.skills.length === 0 && <span className="muted">No skill overlap found.</span>}
|
||||
</div>
|
||||
<div className="items">
|
||||
{matchedItems.map((item) => (
|
||||
<article key={`${item.type}:${item.slug}`} className="item-card">
|
||||
<header>
|
||||
<span className={`badge ${item.type}`}>{item.type}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted">
|
||||
{item.company ? `${item.company} · ` : ""}
|
||||
{item.dates} · score {item.score}
|
||||
</span>
|
||||
</header>
|
||||
<div className="mini-chips">
|
||||
{item.matchedSkills.map((s) => (
|
||||
<span key={s} className="chip mini">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul>
|
||||
{item.bullets.slice(0, 3).map((b) => (
|
||||
<li key={b.slice(0, 40)}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="chat panel">
|
||||
<h2>Refine with the agent</h2>
|
||||
<div className="chat-log">
|
||||
{messages.length === 0 && (
|
||||
<div className="muted chat-empty">
|
||||
Ask the agent to emphasize certain skills, pick different projects, or adjust the
|
||||
headline — the conversation is folded into the plan when you hit Generate.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={`${i}-${m.content.slice(0, 20)}`} className={`msg ${m.role}`}>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
{chatBusy && <div className="msg assistant pending">…</div>}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
<form
|
||||
className="chat-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void sendChat();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={match ? "e.g. Lead with the cryptography work, skip consumer apps" : "Waiting for match…"}
|
||||
disabled={!match}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={!match || chatBusy || draft.trim().length === 0}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--panel-2: #1c2129;
|
||||
--border: #2d333b;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #4493f8;
|
||||
--accent-dim: #1f6feb;
|
||||
--danger: #f85149;
|
||||
--ok: #3fb950;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
background: rgba(248, 81, 73, 0.08);
|
||||
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error.banner {
|
||||
margin: 0 16px 12px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---------- home ---------- */
|
||||
|
||||
.home {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
width: min(640px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.home-card h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.home-card .sub {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.home-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-form input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.paste-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.paste-form input,
|
||||
.paste-form textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.paste-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
font: 13px/1.5 inherit;
|
||||
}
|
||||
|
||||
.paste-form input:focus,
|
||||
.paste-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.paste-form .btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.link {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
/* ---------- shared layout ---------- */
|
||||
|
||||
.workspace,
|
||||
.pdf-screen {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-title strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-title .muted {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------- workspace ---------- */
|
||||
|
||||
.workspace-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.jd-text {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font: 13px/1.55 inherit;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--muted);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip em {
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.chip.language {
|
||||
border-color: #3fb95066;
|
||||
}
|
||||
|
||||
.chip.framework {
|
||||
border-color: #4493f866;
|
||||
}
|
||||
|
||||
.chip.methodology {
|
||||
border-color: #d2a8ff66;
|
||||
}
|
||||
|
||||
.chip.domain {
|
||||
border-color: #f0883e66;
|
||||
}
|
||||
|
||||
.chip.mini {
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.item-card header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-card ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.item-card li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mini-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.experience {
|
||||
background: #1f6feb33;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.project {
|
||||
background: #3fb95033;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* ---------- chat ---------- */
|
||||
|
||||
.chat {
|
||||
margin: 0 16px 16px;
|
||||
height: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 72%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.msg.pending {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-form input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- pdf screen ---------- */
|
||||
|
||||
.pdf-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.latex-editor {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
padding: 10px;
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.latex-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #525659;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CV Tailor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
-- CV corpus schema: mirrors the YAML shapes under ../docs.
|
||||
CREATE TABLE profile (
|
||||
id INTEGER PRIMARY KEY,
|
||||
full_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
website TEXT,
|
||||
linkedin TEXT,
|
||||
github TEXT,
|
||||
twitter TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role TEXT NOT NULL,
|
||||
summary TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE experience (
|
||||
slug TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
company TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
location TEXT,
|
||||
profile_heading TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE projects (
|
||||
slug TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
url TEXT,
|
||||
-- slug of the experience (or 'education') this project belongs to
|
||||
associated_with TEXT
|
||||
);
|
||||
|
||||
-- One row per "- " bullet from the YAML description block scalars.
|
||||
CREATE TABLE bullets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_type TEXT NOT NULL CHECK (parent_type IN ('experience', 'project')),
|
||||
parent_slug TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
text TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_bullets_parent ON bullets (parent_type, parent_slug);
|
||||
|
||||
-- Canonical skill names attached to experience/project/education entries.
|
||||
CREATE TABLE item_skills (
|
||||
parent_type TEXT NOT NULL CHECK (parent_type IN ('experience', 'project', 'education')),
|
||||
parent_slug TEXT NOT NULL,
|
||||
skill TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_item_skills_skill ON item_skills (skill);
|
||||
|
||||
-- The skill inventory (docs/skills/*): languages carry a tier,
|
||||
-- frameworks carry a group parsed from the YAML comment headers.
|
||||
CREATE TABLE skills (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('language', 'framework', 'methodology')),
|
||||
grp TEXT,
|
||||
tier TEXT,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE education (
|
||||
id INTEGER PRIMARY KEY,
|
||||
school TEXT NOT NULL,
|
||||
degree TEXT,
|
||||
field TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
activities TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE honors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT,
|
||||
institution TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE organizations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
title TEXT
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "cv-tailor",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"deploy": "vite build && wrangler deploy",
|
||||
"check": "tsc -p tsconfig.app.json && tsc -p tsconfig.worker.json && tsc -p tsconfig.node.json",
|
||||
"types": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1",
|
||||
"@types/js-yaml": "^4",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5",
|
||||
"js-yaml": "^4",
|
||||
"typescript": "^5",
|
||||
"vite": "^7",
|
||||
"wrangler": "^4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Dev-only PDF compiler. Uses the same local TeX Live toolchain as the
|
||||
* repository Makefile, so dev output is byte-identical to `make`.
|
||||
* In production the client falls back to the Worker's /api/pdf route.
|
||||
*/
|
||||
export function latexDev(): Plugin {
|
||||
return {
|
||||
name: "latex-dev-compiler",
|
||||
apply: "serve",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/local/compile", (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c: Buffer) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
void (async () => {
|
||||
let dir: string | undefined;
|
||||
try {
|
||||
const { latex } = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
|
||||
latex: string;
|
||||
};
|
||||
dir = await mkdtemp(join(tmpdir(), "cvtex-"));
|
||||
await writeFile(join(dir, "main.tex"), latex, "utf8");
|
||||
await run("pdflatex", ["-interaction=nonstopmode", "-halt-on-error", "main.tex"], {
|
||||
cwd: dir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await readFile(join(dir, "main.pdf"));
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.end(pdf);
|
||||
} catch (err) {
|
||||
let log = err instanceof Error ? err.message : String(err);
|
||||
if (dir) {
|
||||
try {
|
||||
log = (await readFile(join(dir, "main.log"), "utf8")).slice(-4000);
|
||||
} catch {
|
||||
/* no log written */
|
||||
}
|
||||
}
|
||||
res.statusCode = 422;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "pdflatex failed", log }));
|
||||
} finally {
|
||||
if (dir) void rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Synchronize the YAML corpus under ../docs into the local D1 database.
|
||||
*
|
||||
* Usage: bun scripts/sync.ts [--remote]
|
||||
*
|
||||
* Applies migrations, regenerates seed.generated.sql from the YAML files,
|
||||
* and executes it against D1 (local by default).
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const APP = join(import.meta.dirname, "..");
|
||||
const DOCS = join(APP, "..", "docs");
|
||||
const TARGET = process.argv.includes("--remote") ? "--remote" : "--local";
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
const sql = (v: string | number | null | undefined): string =>
|
||||
v == null ? "NULL" : typeof v === "number" ? String(v) : `'${v.replace(/'/g, "''")}'`;
|
||||
|
||||
/** js-yaml parses ISO dates into Date objects; normalize everything to strings. */
|
||||
function iso(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** Split a `description` block scalar into its "- " bullets (with wrapped-line continuation). */
|
||||
function bulletsOf(desc: unknown): string[] {
|
||||
if (typeof desc !== "string") return [];
|
||||
const out: string[] = [];
|
||||
for (const line of desc.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith("- ")) out.push(t.slice(2).trim());
|
||||
else if (t && out.length > 0) out[out.length - 1] += ` ${t}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadYaml(path: string): Record<string, unknown> {
|
||||
return yaml.load(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------- build statements ----------
|
||||
|
||||
const stmts: string[] = [
|
||||
"DELETE FROM profile;",
|
||||
"DELETE FROM summaries;",
|
||||
"DELETE FROM experience;",
|
||||
"DELETE FROM projects;",
|
||||
"DELETE FROM bullets;",
|
||||
"DELETE FROM item_skills;",
|
||||
"DELETE FROM skills;",
|
||||
"DELETE FROM education;",
|
||||
"DELETE FROM honors;",
|
||||
"DELETE FROM organizations;",
|
||||
];
|
||||
|
||||
// about/profile.yml
|
||||
{
|
||||
const p = loadYaml(join(DOCS, "about/profile.yml"));
|
||||
stmts.push(
|
||||
`INSERT INTO profile (id, full_name, email, phone, website, linkedin, github, twitter) VALUES (1, ${sql(
|
||||
String(p["full-name"]),
|
||||
)}, ${sql(iso(p.email))}, ${sql(iso(p.phone))}, ${sql(iso(p.website))}, ${sql(
|
||||
iso(p.linkedin),
|
||||
)}, ${sql(iso(p.github))}, ${sql(iso(p.twitter))});`,
|
||||
);
|
||||
for (const s of (p.summary as { role: string; summary: string }[] | undefined) ?? []) {
|
||||
stmts.push(
|
||||
`INSERT INTO summaries (role, summary) VALUES (${sql(s.role)}, ${sql(s.summary.trim())});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// about/education.yml
|
||||
{
|
||||
const e = loadYaml(join(DOCS, "about/education.yml"));
|
||||
stmts.push(
|
||||
`INSERT INTO education (id, school, degree, field, start_date, end_date, activities) VALUES (1, ${sql(
|
||||
iso(e.school),
|
||||
)}, ${sql(iso(e.degree))}, ${sql(iso(e.field))}, ${sql(iso(e.start))}, ${sql(
|
||||
iso(e.end),
|
||||
)}, ${sql(typeof e.activities === "string" ? e.activities.trim() : null)});`,
|
||||
);
|
||||
for (const skill of (e.skills as string[] | undefined) ?? []) {
|
||||
stmts.push(
|
||||
`INSERT INTO item_skills (parent_type, parent_slug, skill) VALUES ('education', 'education', ${sql(skill)});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// about/honors.yml + organizations.yml (top-level lists)
|
||||
for (const h of yaml.load(readFileSync(join(DOCS, "about/honors.yml"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]) {
|
||||
stmts.push(
|
||||
`INSERT INTO honors (title, date, institution) VALUES (${sql(iso(h.title))}, ${sql(
|
||||
iso(h.date),
|
||||
)}, ${sql(iso(h.institution))});`,
|
||||
);
|
||||
}
|
||||
for (const o of yaml.load(readFileSync(join(DOCS, "about/organizations.yml"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]) {
|
||||
stmts.push(
|
||||
`INSERT INTO organizations (name, url, title) VALUES (${sql(iso(o.name))}, ${sql(
|
||||
iso(o.url),
|
||||
)}, ${sql(iso(o.title))});`,
|
||||
);
|
||||
}
|
||||
|
||||
// skills/languages.yml (tiered map)
|
||||
{
|
||||
const tiers = loadYaml(join(DOCS, "skills/languages.yml")) as Record<string, string[]>;
|
||||
let pos = 0;
|
||||
for (const tier of ["expert", "strong", "familiar"]) {
|
||||
for (const name of tiers[tier] ?? []) {
|
||||
stmts.push(
|
||||
`INSERT INTO skills (name, category, tier, position) VALUES (${sql(name)}, 'language', ${sql(tier)}, ${pos++});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skills/frameworks.yml (flat list; group headers live in YAML comments)
|
||||
{
|
||||
let grp: string | null = null;
|
||||
let pos = 0;
|
||||
for (const line of readFileSync(join(DOCS, "skills/frameworks.yml"), "utf8").split("\n")) {
|
||||
const t = line.trim();
|
||||
if (t.startsWith("#")) grp = t.replace(/^#\s*/, "");
|
||||
else if (t.startsWith("- "))
|
||||
stmts.push(
|
||||
`INSERT INTO skills (name, category, grp, position) VALUES (${sql(t.slice(2).trim())}, 'framework', ${sql(grp)}, ${pos++});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// skills/methodology.yml (flat list)
|
||||
{
|
||||
const items = yaml.load(readFileSync(join(DOCS, "skills/methodology.yml"), "utf8")) as string[];
|
||||
items.forEach((name, pos) =>
|
||||
stmts.push(
|
||||
`INSERT INTO skills (name, category, position) VALUES (${sql(name)}, 'methodology', ${pos});`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// experience/*.yml
|
||||
for (const file of readdirSync(join(DOCS, "experience")).filter((f) => /\.ya?ml$/.test(f)).sort()) {
|
||||
const slug = file.replace(/\.ya?ml$/, "");
|
||||
const e = loadYaml(join(DOCS, "experience", file));
|
||||
stmts.push(
|
||||
`INSERT INTO experience (slug, title, company, start_date, end_date, location, profile_heading) VALUES (${sql(
|
||||
slug,
|
||||
)}, ${sql(iso(e.title))}, ${sql(iso(e.company))}, ${sql(iso(e.start))}, ${sql(
|
||||
iso(e.end),
|
||||
)}, ${sql(iso(e.location))}, ${sql(iso(e["profile-heading"]))});`,
|
||||
);
|
||||
bulletsOf(e.description).forEach((text, position) =>
|
||||
stmts.push(
|
||||
`INSERT INTO bullets (parent_type, parent_slug, position, text) VALUES ('experience', ${sql(slug)}, ${position}, ${sql(text)});`,
|
||||
),
|
||||
);
|
||||
for (const skill of (e.skills as string[] | undefined) ?? []) {
|
||||
stmts.push(
|
||||
`INSERT INTO item_skills (parent_type, parent_slug, skill) VALUES ('experience', ${sql(slug)}, ${sql(skill)});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// projects/*.yml
|
||||
for (const file of readdirSync(join(DOCS, "projects")).filter((f) => /\.ya?ml$/.test(f)).sort()) {
|
||||
const slug = file.replace(/\.ya?ml$/, "");
|
||||
const p = loadYaml(join(DOCS, "projects", file));
|
||||
stmts.push(
|
||||
`INSERT INTO projects (slug, title, start_date, end_date, url, associated_with) VALUES (${sql(
|
||||
slug,
|
||||
)}, ${sql(iso(p.title))}, ${sql(iso(p.start))}, ${sql(iso(p.end))}, ${sql(
|
||||
iso(p["project-url"]),
|
||||
)}, ${sql(typeof p["associated-with"] === "string" ? basename(p["associated-with"]).replace(/\.ya?ml$/, "") : null)});`,
|
||||
);
|
||||
bulletsOf(p.description).forEach((text, position) =>
|
||||
stmts.push(
|
||||
`INSERT INTO bullets (parent_type, parent_slug, position, text) VALUES ('project', ${sql(slug)}, ${position}, ${sql(text)});`,
|
||||
),
|
||||
);
|
||||
for (const skill of (p.skills as string[] | undefined) ?? []) {
|
||||
stmts.push(
|
||||
`INSERT INTO item_skills (parent_type, parent_slug, skill) VALUES ('project', ${sql(slug)}, ${sql(skill)});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- execute ----------
|
||||
|
||||
const seedPath = join(APP, "seed.generated.sql");
|
||||
writeFileSync(seedPath, `${stmts.join("\n")}\n`);
|
||||
console.log(`Wrote ${stmts.length} statements to seed.generated.sql`);
|
||||
|
||||
execSync(`bunx wrangler d1 migrations apply cv-tailor ${TARGET}`, { cwd: APP, stdio: "inherit" });
|
||||
execSync(`bunx wrangler d1 execute cv-tailor ${TARGET} --file seed.generated.sql`, {
|
||||
cwd: APP,
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log("Sync complete.");
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Shared API contract between the Worker and the React client. */
|
||||
|
||||
export interface JobExtract {
|
||||
url: string;
|
||||
title: string;
|
||||
text: string;
|
||||
/** Where the text came from: JobPosting JSON-LD, stripped body HTML, non-HTML response, or user paste. */
|
||||
source: "json-ld" | "html" | "plain" | "manual";
|
||||
}
|
||||
|
||||
export interface MatchedSkill {
|
||||
name: string;
|
||||
category: "language" | "framework" | "methodology" | "domain";
|
||||
/** Number of times the skill (or an alias) appears in the job text. */
|
||||
hits: number;
|
||||
}
|
||||
|
||||
export interface RankedItem {
|
||||
type: "experience" | "project";
|
||||
slug: string;
|
||||
title: string;
|
||||
company?: string;
|
||||
dates: string;
|
||||
score: number;
|
||||
matchedSkills: string[];
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
skills: MatchedSkill[];
|
||||
items: RankedItem[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PlanSkillGroup {
|
||||
group: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface PlanExperience {
|
||||
slug: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface PlanProject {
|
||||
slug: string;
|
||||
heading: string;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
/** The tailored selection the LaTeX template is filled from. */
|
||||
export interface ResumePlan {
|
||||
/** Headline under the name, e.g. "Senior Blockchain & Protocol Engineer". */
|
||||
title: string;
|
||||
skills: PlanSkillGroup[];
|
||||
experience: PlanExperience[];
|
||||
projects: PlanProject[];
|
||||
}
|
||||
|
||||
export interface PlanResponse {
|
||||
plan: ResumePlan;
|
||||
source: "ai" | "fallback";
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
log?: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": [],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["client", "shared"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.worker.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["vite.config.ts", "plugins", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": [],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["worker", "shared", "worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import { latexDev } from "./plugins/latex-dev";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [latexDev(), react(), cloudflare()],
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
JobExtract,
|
||||
MatchResult,
|
||||
PlanExperience,
|
||||
PlanProject,
|
||||
PlanResponse,
|
||||
PlanSkillGroup,
|
||||
ResumePlan,
|
||||
} from "../shared/types";
|
||||
import { formatRange, type Corpus } from "./db";
|
||||
|
||||
const LIMITS = {
|
||||
skillGroups: 3,
|
||||
groupItems: 9,
|
||||
experiences: 2,
|
||||
expBullets: 3,
|
||||
projects: 3,
|
||||
projBullets: 3,
|
||||
bulletChars: 340,
|
||||
};
|
||||
|
||||
/** A rewrite thinner than this is a stub — fall back to the source bullet. */
|
||||
const MIN_BULLET_WORDS = 12;
|
||||
const MIN_BULLET_CHARS = 80;
|
||||
|
||||
const BULLET_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["i", "text"],
|
||||
properties: {
|
||||
i: { type: "integer", description: "Index of the source bullet this rewrite is based on" },
|
||||
text: {
|
||||
type: "string",
|
||||
description:
|
||||
"Rewrite of source bullet i: 18-30 words, keeps every number/metric and key technology from the source",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PLAN_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "skills", "experience", "projects"],
|
||||
properties: {
|
||||
title: { type: "string", description: "Resume headline under the name, at most 6 words" },
|
||||
skills: {
|
||||
type: "array",
|
||||
maxItems: LIMITS.skillGroups,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["group", "items"],
|
||||
properties: {
|
||||
group: { type: "string" },
|
||||
items: { type: "array", maxItems: LIMITS.groupItems, items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
experience: {
|
||||
type: "array",
|
||||
maxItems: LIMITS.experiences,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["slug", "bullets"],
|
||||
properties: {
|
||||
slug: { type: "string" },
|
||||
bullets: { type: "array", maxItems: LIMITS.expBullets, items: BULLET_SCHEMA },
|
||||
},
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
type: "array",
|
||||
maxItems: LIMITS.projects,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["slug", "heading", "bullets"],
|
||||
properties: {
|
||||
slug: { type: "string" },
|
||||
heading: { type: "string", description: "Descriptive Title Case heading, at most 5 words" },
|
||||
bullets: { type: "array", maxItems: LIMITS.projBullets, items: BULLET_SCHEMA },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PLAN_SYSTEM = `You are an elite resume writer tailoring Prad Nukala's resume to a specific job posting. You receive the posting, a keyword match report, Prad's complete career materials (experience entries and projects addressed by slug, each with an indexed pool of factual source bullets), and optionally a conversation with Prad.
|
||||
|
||||
Return ONLY JSON matching the schema.
|
||||
|
||||
BULLET QUALITY BAR — the resume lives or dies here:
|
||||
- Every bullet must read like a senior engineer's resume line: strong verb + specific system/technology + scope + concrete outcome, 18-30 words.
|
||||
- Preserve EVERY metric and number from the source bullet ($4.7M, 120+, 1M+, 4th-highest, 6-engineer). Never drop or round them. Never invent new ones.
|
||||
- Keep the source bullet's specific technologies; cut only filler words.
|
||||
- Stub bullets are forbidden. "Founded Sonr", "Led fundraising", "Used Flutter framework" are automatic failures — any bullet under 15 words is rejected and replaced.
|
||||
- Each bullet object carries "i" (the [index] of the source bullet in the materials) and "text" (your rewrite of THAT bullet).
|
||||
|
||||
Example rewrite:
|
||||
source [0]: "Founded the startup and successfully led executive fundraising efforts, securing $4.7M in capital to build a peer-to-peer identity and asset management system leveraging decentralized identifiers (DIDs), WebAuthn, and IPFS."
|
||||
GOOD: {"i": 0, "text": "Founded Sonr and raised $4.7M to build a peer-to-peer identity and asset-management platform on decentralized identifiers (DIDs), WebAuthn, and IPFS."}
|
||||
BAD: {"i": 0, "text": "Founded Sonr and led fundraising."}
|
||||
|
||||
SELECTION:
|
||||
- "title": resume headline, at most 6 words, mirroring how the posting names the role.
|
||||
- "skills": exactly 3 groups; the first is "Languages". Name the other two using the posting's own vocabulary (e.g. "Protocols & Frameworks", "Infrastructure & Security"). 5-9 items per group, copied verbatim from the skill inventory — exact spelling, no additions.
|
||||
- "experience": 1-2 entries by slug, most relevant first; 3 bullets for the first, 2 for the second. Pick the source bullets whose facts best answer the posting's requirements.
|
||||
- "projects": 2-3 entries by slug; heading is a short descriptive Title Case name (e.g. "Layer-1 Blockchain & Identity"), never a slug; 2-3 bullets each.
|
||||
- One-page budget: at most 11 bullets total across experience and projects.
|
||||
- Honor explicit requests from the conversation with Prad.
|
||||
|
||||
OUTPUT SHAPE (follow EXACTLY — "skills" is an ARRAY of {group, items} objects, never a map):
|
||||
{"title":"Senior Platform Engineer","skills":[{"group":"Languages","items":["Go","Rust"]},{"group":"Infrastructure","items":["Kubernetes","Docker"]}],"experience":[{"slug":"sonr","bullets":[{"i":0,"text":"..."}]}],"projects":[{"slug":"appchain","heading":"Layer-1 Blockchain & Identity","bullets":[{"i":1,"text":"..."}]}]}`;
|
||||
|
||||
/** Extract assistant text from any Workers AI output shape (legacy, ChatCompletions, Responses). */
|
||||
function textOf(out: unknown): string {
|
||||
if (typeof out === "string") return out;
|
||||
if (out && typeof out === "object") {
|
||||
if ("response" in out) {
|
||||
const r: unknown = out.response;
|
||||
if (typeof r === "string") return r;
|
||||
// json_schema mode returns the parsed object directly in `response`.
|
||||
if (r && typeof r === "object") return JSON.stringify(r);
|
||||
}
|
||||
if ("choices" in out && Array.isArray(out.choices)) {
|
||||
const first: unknown = out.choices[0];
|
||||
if (first && typeof first === "object" && "message" in first) {
|
||||
const msg: unknown = first.message;
|
||||
if (msg && typeof msg === "object" && "content" in msg && typeof msg.content === "string") {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("output_text" in out && typeof out.output_text === "string") return out.output_text;
|
||||
}
|
||||
throw new Error("Workers AI returned an unexpected shape");
|
||||
}
|
||||
|
||||
/** Frontier model (ChatCompletions shape) used for resume plan generation. */
|
||||
async function runPlanModel(env: Env, system: string, user: string): Promise<string> {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
];
|
||||
const out = await env.AI.run(env.PLAN_MODEL, {
|
||||
messages,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.4,
|
||||
// Reasoning models otherwise think past the AI gateway timeout (504) on this prompt size.
|
||||
reasoning_effort: "low",
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: { name: "resume_plan", schema: PLAN_SCHEMA },
|
||||
},
|
||||
});
|
||||
return textOf(out);
|
||||
}
|
||||
|
||||
/** Fast model used for the interactive chat. */
|
||||
async function runChatModel(env: Env, system: string, history: ChatMessage[]): Promise<string> {
|
||||
const out = await env.AI.run(env.CHAT_MODEL, {
|
||||
messages: [{ role: "system", content: system }, ...history.slice(-12)],
|
||||
max_tokens: 640,
|
||||
temperature: 0.7,
|
||||
});
|
||||
return textOf(out);
|
||||
}
|
||||
|
||||
function materialsDigest(corpus: Corpus, match: MatchResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("SKILL INVENTORY");
|
||||
for (const category of ["language", "framework", "methodology"] as const) {
|
||||
const names = corpus.skills.filter((s) => s.category === category).map((s) => s.name);
|
||||
lines.push(`- ${category}: ${names.join(", ")}`);
|
||||
}
|
||||
|
||||
lines.push("", "EXPERIENCE ENTRIES");
|
||||
for (const e of corpus.experience) {
|
||||
lines.push(`slug=${e.slug} | ${e.title} @ ${e.company} | ${formatRange(e.start_date, e.end_date, "month")}`);
|
||||
e.bullets.forEach((b, i) => lines.push(` [${i}] ${b}`));
|
||||
}
|
||||
|
||||
const projectRank = new Map<string, number>();
|
||||
match.items.forEach((item, i) => {
|
||||
if (item.type === "project") projectRank.set(item.slug, i);
|
||||
});
|
||||
const ranked = [...corpus.projects].sort(
|
||||
(a, b) => (projectRank.get(a.slug) ?? 99) - (projectRank.get(b.slug) ?? 99),
|
||||
);
|
||||
lines.push("", "PROJECTS (most relevant first)");
|
||||
for (const p of ranked.slice(0, 9)) {
|
||||
lines.push(`slug=${p.slug} | ${p.title} | ${formatRange(p.start_date, p.end_date, "year")}`);
|
||||
p.bullets.forEach((b, i) => lines.push(` [${i}] ${b}`));
|
||||
}
|
||||
const rest = ranked.slice(9);
|
||||
if (rest.length > 0) {
|
||||
lines.push("", `OTHER PROJECT SLUGS: ${rest.map((p) => `${p.slug} (${p.title})`).join("; ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function matchDigest(match: MatchResult): string {
|
||||
const skills = match.skills.map((s) => `${s.name} x${s.hits}`).join(", ");
|
||||
const items = match.items
|
||||
.filter((i) => i.score > 0)
|
||||
.slice(0, 12)
|
||||
.map((i) => `${i.type}:${i.slug} score=${i.score} [${i.matchedSkills.join(", ")}]`)
|
||||
.join("\n");
|
||||
return `Matched skills: ${skills || "none"}\nRanked material:\n${items || "none"}`;
|
||||
}
|
||||
|
||||
function parseLooseJson(text: string): unknown {
|
||||
const stripped = text.replace(/```(?:json)?/gi, "");
|
||||
const start = stripped.indexOf("{");
|
||||
const end = stripped.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new Error("model output contained no JSON object");
|
||||
return JSON.parse(stripped.slice(start, end + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vet a rewritten bullet against its source. A stub (too short) or a rewrite
|
||||
* that lost the source's numbers is replaced by the source bullet verbatim —
|
||||
* the corpus bullets are professionally written, so verbatim is the floor.
|
||||
*/
|
||||
function vetBullet(rewrite: unknown, source: string | undefined): string | null {
|
||||
const text = typeof rewrite === "string" ? rewrite.trim() : "";
|
||||
const words = text.length > 0 ? text.split(/\s+/).length : 0;
|
||||
const tooThin = text.length < MIN_BULLET_CHARS || words < MIN_BULLET_WORDS;
|
||||
const lostNumbers = source !== undefined && /\d/.test(source) && !/\d/.test(text);
|
||||
if (!tooThin && !lostNumbers) return text.slice(0, LIMITS.bulletChars);
|
||||
if (source !== undefined) return source.slice(0, LIMITS.bulletChars);
|
||||
return words >= 8 ? text.slice(0, LIMITS.bulletChars) : null;
|
||||
}
|
||||
|
||||
/** Resolve `{i, text}` bullet entries against the item's source pool, vetting each rewrite. */
|
||||
function pickBullets(rawBullets: unknown, pool: string[], cap: number): string[] {
|
||||
if (!Array.isArray(rawBullets)) return [];
|
||||
const out: string[] = [];
|
||||
const usedSources = new Set<number>();
|
||||
for (const rb of rawBullets) {
|
||||
if (out.length >= cap) break;
|
||||
let idx = -1;
|
||||
let text: unknown = rb;
|
||||
if (rb && typeof rb === "object") {
|
||||
if ("i" in rb && typeof rb.i === "number") idx = Math.trunc(rb.i);
|
||||
if ("text" in rb) text = rb.text;
|
||||
}
|
||||
const source = idx >= 0 && idx < pool.length ? pool[idx] : undefined;
|
||||
if (source !== undefined) {
|
||||
if (usedSources.has(idx)) continue;
|
||||
usedSources.add(idx);
|
||||
}
|
||||
const vetted = vetBullet(text, source);
|
||||
if (vetted !== null && !out.includes(vetted)) out.push(vetted);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-proposed skill name onto the canonical corpus spelling.
|
||||
* Exact (case-insensitive) match first, then substring either way for
|
||||
* abbreviations like "AWS" -> "AWS Lambda", "Cryptography" -> "Cryptography (ZKP & MPC)".
|
||||
*/
|
||||
function resolveSkill(name: string, canon: Map<string, string>): string | null {
|
||||
const lower = name.trim().toLowerCase();
|
||||
if (lower.length < 2) return null;
|
||||
const exact = canon.get(lower);
|
||||
if (exact) return exact;
|
||||
if (lower.length < 3) return null;
|
||||
for (const [key, value] of canon) {
|
||||
if (key.includes(lower) || lower.includes(key)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Accept skills as the schema's array of {group, items} OR a loose {group: items[]} map. */
|
||||
function readSkillGroups(raw: unknown): { group: string; items: unknown[] }[] {
|
||||
if (Array.isArray(raw)) {
|
||||
const out: { group: string; items: unknown[] }[] = [];
|
||||
for (const g of raw) {
|
||||
if (g && typeof g === "object" && "group" in g && "items" in g && typeof g.group === "string" && Array.isArray(g.items)) {
|
||||
out.push({ group: g.group, items: g.items });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.entries(raw).flatMap(([group, items]) => (Array.isArray(items) ? [{ group, items }] : []));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function sanitizePlan(raw: unknown, corpus: Corpus): ResumePlan {
|
||||
if (!raw || typeof raw !== "object") throw new Error("plan is not an object");
|
||||
|
||||
const title =
|
||||
"title" in raw && typeof raw.title === "string" && raw.title.trim().length > 0
|
||||
? raw.title.trim().slice(0, 80)
|
||||
: "Software Engineer";
|
||||
|
||||
const canon = new Map<string, string>();
|
||||
for (const s of corpus.skills) canon.set(s.name.toLowerCase(), s.name);
|
||||
for (const item of [...corpus.experience, ...corpus.projects]) {
|
||||
for (const s of item.skills) if (!canon.has(s.toLowerCase())) canon.set(s.toLowerCase(), s);
|
||||
}
|
||||
|
||||
const skills: PlanSkillGroup[] = [];
|
||||
const usedSkills = new Set<string>();
|
||||
for (const g of ("skills" in raw ? readSkillGroups(raw.skills) : []).slice(0, LIMITS.skillGroups)) {
|
||||
const items: string[] = [];
|
||||
for (const x of g.items) {
|
||||
if (items.length >= LIMITS.groupItems || typeof x !== "string") continue;
|
||||
const resolved = resolveSkill(x, canon);
|
||||
if (resolved && !usedSkills.has(resolved)) {
|
||||
usedSkills.add(resolved);
|
||||
items.push(resolved);
|
||||
}
|
||||
}
|
||||
if (items.length >= 2) skills.push({ group: g.group.trim().slice(0, 40), items });
|
||||
}
|
||||
|
||||
const experience: PlanExperience[] = [];
|
||||
if ("experience" in raw && Array.isArray(raw.experience)) {
|
||||
for (const e of raw.experience.slice(0, LIMITS.experiences)) {
|
||||
if (!e || typeof e !== "object" || !("slug" in e) || !("bullets" in e)) continue;
|
||||
if (typeof e.slug !== "string") continue;
|
||||
const row = corpus.experience.find((x) => x.slug === e.slug);
|
||||
if (!row) continue;
|
||||
const cap = experience.length === 0 ? LIMITS.expBullets : 2;
|
||||
const bullets = pickBullets(e.bullets, row.bullets, cap);
|
||||
if (bullets.length > 0) experience.push({ slug: e.slug, bullets });
|
||||
}
|
||||
}
|
||||
|
||||
const projects: PlanProject[] = [];
|
||||
if ("projects" in raw && Array.isArray(raw.projects)) {
|
||||
for (const pr of raw.projects.slice(0, LIMITS.projects)) {
|
||||
if (!pr || typeof pr !== "object" || !("slug" in pr) || !("bullets" in pr)) continue;
|
||||
if (typeof pr.slug !== "string") continue;
|
||||
const row = corpus.projects.find((p) => p.slug === pr.slug);
|
||||
if (!row) continue;
|
||||
const bullets = pickBullets(pr.bullets, row.bullets, LIMITS.projBullets);
|
||||
const heading =
|
||||
"heading" in pr && typeof pr.heading === "string" && pr.heading.trim().length > 0
|
||||
? pr.heading.trim().slice(0, 48)
|
||||
: row.title.split(/[—|]/)[0].trim().slice(0, 48);
|
||||
if (bullets.length > 0) projects.push({ slug: pr.slug, heading, bullets });
|
||||
}
|
||||
}
|
||||
|
||||
if (experience.length === 0 || skills.length === 0) throw new Error("plan missing required sections");
|
||||
return { title, skills, experience, projects };
|
||||
}
|
||||
|
||||
/** Deterministic plan used when Workers AI is unavailable or returns garbage. */
|
||||
export function fallbackPlan(corpus: Corpus, match: MatchResult): ResumePlan {
|
||||
const matched = new Set(match.skills.map((s) => s.name));
|
||||
const pick = (category: "language" | "framework" | "methodology", count: number): string[] => {
|
||||
const all = corpus.skills.filter((s) => s.category === category);
|
||||
const preferred = all.filter((s) => matched.has(s.name));
|
||||
const rest = all.filter((s) => !matched.has(s.name) && (category !== "language" || s.tier === "expert"));
|
||||
return [...preferred, ...rest].slice(0, count).map((s) => s.name);
|
||||
};
|
||||
|
||||
const experience: PlanExperience[] = match.items
|
||||
.filter((i) => i.type === "experience")
|
||||
.slice(0, LIMITS.experiences)
|
||||
.map((i, idx) => ({ slug: i.slug, bullets: i.bullets.slice(0, idx === 0 ? 3 : 2) }));
|
||||
|
||||
const projects: PlanProject[] = match.items
|
||||
.filter((i) => i.type === "project" && i.score > 0)
|
||||
.slice(0, LIMITS.projects)
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
heading: i.title.split(/[—|]/)[0].trim().slice(0, 48),
|
||||
bullets: i.bullets.slice(0, 2),
|
||||
}));
|
||||
|
||||
return {
|
||||
title: "Senior Software Engineer",
|
||||
skills: [
|
||||
{ group: "Languages", items: pick("language", 7) },
|
||||
{ group: "Frameworks", items: pick("framework", 8) },
|
||||
{ group: "Methodology", items: pick("methodology", 6) },
|
||||
],
|
||||
experience,
|
||||
projects,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlan(
|
||||
env: Env,
|
||||
corpus: Corpus,
|
||||
jd: JobExtract,
|
||||
match: MatchResult,
|
||||
chat: ChatMessage[],
|
||||
): Promise<PlanResponse> {
|
||||
const sections = [
|
||||
`JOB POSTING: ${jd.title}`,
|
||||
jd.text.slice(0, 6000),
|
||||
"",
|
||||
"MATCH REPORT",
|
||||
matchDigest(match),
|
||||
"",
|
||||
materialsDigest(corpus, match),
|
||||
];
|
||||
if (chat.length > 0) {
|
||||
sections.push("", "CONVERSATION WITH PRAD (honor explicit requests)");
|
||||
for (const m of chat.slice(-12)) sections.push(`${m.role}: ${m.content}`);
|
||||
}
|
||||
sections.push("", "Return the JSON plan now.");
|
||||
|
||||
try {
|
||||
const text = await runPlanModel(env, PLAN_SYSTEM, sections.join("\n"));
|
||||
return { plan: sanitizePlan(parseLooseJson(text), corpus), source: "ai" };
|
||||
} catch (err) {
|
||||
return {
|
||||
plan: fallbackPlan(corpus, match),
|
||||
source: "fallback",
|
||||
note: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function chatReply(
|
||||
env: Env,
|
||||
corpus: Corpus,
|
||||
jd: JobExtract,
|
||||
match: MatchResult,
|
||||
history: ChatMessage[],
|
||||
): Promise<string> {
|
||||
const system = `You are a resume-tailoring assistant helping Prad Nukala tailor his resume to a job posting. Be concise and concrete (2-5 sentences unless asked for more). You can discuss which experience, projects, skills, and phrasing to emphasize; the final resume is generated when Prad clicks Generate, and your conversation is taken into account. Never invent facts not present in the materials.
|
||||
|
||||
JOB POSTING: ${jd.title}
|
||||
${jd.text.slice(0, 3500)}
|
||||
|
||||
MATCH REPORT
|
||||
${matchDigest(match)}
|
||||
|
||||
${materialsDigest(corpus, match).slice(0, 6000)}`;
|
||||
|
||||
const text = await runChatModel(env, system, history);
|
||||
return text.trim();
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Row shapes and corpus loader for the cv-tailor D1 database. */
|
||||
|
||||
export interface ProfileRow {
|
||||
full_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
linkedin: string | null;
|
||||
github: string | null;
|
||||
twitter: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryRow {
|
||||
role: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ExperienceItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
company: string;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
location: string | null;
|
||||
profile_heading: string | null;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
export interface ProjectItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
url: string | null;
|
||||
associated_with: string | null;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
export interface SkillRow {
|
||||
name: string;
|
||||
category: "language" | "framework" | "methodology";
|
||||
grp: string | null;
|
||||
tier: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface EducationRow {
|
||||
school: string;
|
||||
degree: string | null;
|
||||
field: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
activities: string | null;
|
||||
}
|
||||
|
||||
export interface HonorRow {
|
||||
title: string;
|
||||
date: string | null;
|
||||
institution: string | null;
|
||||
}
|
||||
|
||||
export interface OrganizationRow {
|
||||
name: string;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface Corpus {
|
||||
profile: ProfileRow;
|
||||
summaries: SummaryRow[];
|
||||
experience: ExperienceItem[];
|
||||
projects: ProjectItem[];
|
||||
skills: SkillRow[];
|
||||
education: EducationRow | null;
|
||||
honors: HonorRow[];
|
||||
organizations: OrganizationRow[];
|
||||
}
|
||||
|
||||
interface BulletRow {
|
||||
parent_type: string;
|
||||
parent_slug: string;
|
||||
position: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ItemSkillRow {
|
||||
parent_type: string;
|
||||
parent_slug: string;
|
||||
skill: string;
|
||||
}
|
||||
|
||||
type ExperienceDbRow = Omit<ExperienceItem, "bullets" | "skills">;
|
||||
type ProjectDbRow = Omit<ProjectItem, "bullets" | "skills">;
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/**
|
||||
* "2021-03-01".."" -> "Mar 2021 – Present" (month style) or "2021 – Present" (year style).
|
||||
* Uses an en dash; the LaTeX escaper converts it to `--`.
|
||||
*/
|
||||
export function formatRange(
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
style: "month" | "year",
|
||||
): string {
|
||||
if (!start) return "";
|
||||
let from: string;
|
||||
let to = "Present";
|
||||
if (style === "month") {
|
||||
from = `${MONTHS[Number(start.slice(5, 7)) - 1]} ${start.slice(0, 4)}`;
|
||||
if (end) to = `${MONTHS[Number(end.slice(5, 7)) - 1]} ${end.slice(0, 4)}`;
|
||||
} else {
|
||||
from = start.slice(0, 4);
|
||||
if (end) to = end.slice(0, 4);
|
||||
}
|
||||
return `${from} – ${to}`;
|
||||
}
|
||||
|
||||
export async function loadCorpus(db: D1Database): Promise<Corpus> {
|
||||
const [profile, summaries, experience, projects, bullets, itemSkills, skills, education, honors, organizations] =
|
||||
await Promise.all([
|
||||
db.prepare("SELECT * FROM profile LIMIT 1").all<ProfileRow>(),
|
||||
db.prepare("SELECT role, summary FROM summaries").all<SummaryRow>(),
|
||||
db.prepare("SELECT * FROM experience ORDER BY start_date DESC").all<ExperienceDbRow>(),
|
||||
db.prepare("SELECT * FROM projects ORDER BY start_date DESC").all<ProjectDbRow>(),
|
||||
db
|
||||
.prepare("SELECT parent_type, parent_slug, position, text FROM bullets ORDER BY position")
|
||||
.all<BulletRow>(),
|
||||
db.prepare("SELECT parent_type, parent_slug, skill FROM item_skills").all<ItemSkillRow>(),
|
||||
db
|
||||
.prepare("SELECT name, category, grp, tier, position FROM skills ORDER BY position")
|
||||
.all<SkillRow>(),
|
||||
db.prepare("SELECT * FROM education LIMIT 1").all<EducationRow>(),
|
||||
db.prepare("SELECT title, date, institution FROM honors").all<HonorRow>(),
|
||||
db.prepare("SELECT name, url, title FROM organizations").all<OrganizationRow>(),
|
||||
]);
|
||||
|
||||
const profileRow = profile.results[0];
|
||||
if (!profileRow) throw new Error("D1 is empty — run `bun run sync` first.");
|
||||
|
||||
const experienceItems: ExperienceItem[] = experience.results.map((r) => ({ ...r, bullets: [], skills: [] }));
|
||||
const projectItems: ProjectItem[] = projects.results.map((r) => ({ ...r, bullets: [], skills: [] }));
|
||||
const bySlug = new Map<string, { bullets: string[]; skills: string[] }>();
|
||||
for (const item of experienceItems) bySlug.set(`experience:${item.slug}`, item);
|
||||
for (const item of projectItems) bySlug.set(`project:${item.slug}`, item);
|
||||
for (const b of bullets.results) bySlug.get(`${b.parent_type}:${b.parent_slug}`)?.bullets.push(b.text);
|
||||
for (const s of itemSkills.results) bySlug.get(`${s.parent_type}:${s.parent_slug}`)?.skills.push(s.skill);
|
||||
|
||||
return {
|
||||
profile: profileRow,
|
||||
summaries: summaries.results,
|
||||
experience: experienceItems,
|
||||
projects: projectItems,
|
||||
skills: skills.results,
|
||||
education: education.results[0] ?? null,
|
||||
honors: honors.results,
|
||||
organizations: organizations.results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { JobExtract } from "../shared/types";
|
||||
|
||||
const UA =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
||||
const MAX_BYTES = 4_000_000;
|
||||
const MAX_TEXT = 24_000;
|
||||
|
||||
/** Fetch a job posting URL and extract readable text, preferring JobPosting JSON-LD. */
|
||||
export async function extractJob(url: string): Promise<JobExtract> {
|
||||
const res = await fetch(url, {
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": UA,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`upstream responded ${res.status} ${res.statusText}`);
|
||||
|
||||
const raw = await readCapped(res);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("html")) {
|
||||
return { url, title: url, text: tidy(raw).slice(0, MAX_TEXT), source: "plain" };
|
||||
}
|
||||
|
||||
const posting = findJobPostingLd(raw);
|
||||
if (posting) return { url, title: posting.title, text: posting.text.slice(0, MAX_TEXT), source: "json-ld" };
|
||||
|
||||
const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(raw);
|
||||
const title = titleMatch ? tidy(decodeEntities(titleMatch[1])) : url;
|
||||
return { url, title: title || url, text: stripHtml(raw).slice(0, MAX_TEXT), source: "html" };
|
||||
}
|
||||
|
||||
/** Stream the body with a byte cap so an unbounded response cannot exhaust memory. */
|
||||
async function readCapped(res: Response): Promise<string> {
|
||||
if (!res.body) return "";
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.length;
|
||||
chunks.push(value);
|
||||
if (bytes >= MAX_BYTES) {
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const merged = new Uint8Array(Math.min(bytes, MAX_BYTES));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
const slice = chunk.subarray(0, Math.min(chunk.length, merged.length - offset));
|
||||
merged.set(slice, offset);
|
||||
offset += slice.length;
|
||||
if (offset >= merged.length) break;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: false, ignoreBOM: false }).decode(merged);
|
||||
}
|
||||
|
||||
/** Walk every <script type="application/ld+json"> looking for a schema.org JobPosting. */
|
||||
function findJobPostingLd(html: string): { title: string; text: string } | null {
|
||||
const scriptRe = /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
||||
for (let m = scriptRe.exec(html); m; m = scriptRe.exec(html)) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(m[1]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const posting = findPostingNode(parsed);
|
||||
if (!posting) continue;
|
||||
|
||||
const parts: string[] = [];
|
||||
const title = typeof posting.title === "string" ? tidy(posting.title) : "";
|
||||
if (title) parts.push(title);
|
||||
if (posting.hiringOrganization && typeof posting.hiringOrganization === "object") {
|
||||
const org = posting.hiringOrganization;
|
||||
if ("name" in org && typeof org.name === "string") parts.push(`Company: ${org.name}`);
|
||||
}
|
||||
if (typeof posting.description === "string") parts.push(stripHtml(posting.description));
|
||||
if (parts.length > (title ? 1 : 0)) return { title: title || "Job Posting", text: parts.join("\n\n") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface JobPostingNode {
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
hiringOrganization?: unknown;
|
||||
}
|
||||
|
||||
function findPostingNode(node: unknown): JobPostingNode | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findPostingNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== "object") return null;
|
||||
if ("@type" in node) {
|
||||
const t = node["@type"];
|
||||
const isPosting = t === "JobPosting" || (Array.isArray(t) && t.includes("JobPosting"));
|
||||
if (isPosting) {
|
||||
return {
|
||||
title: "title" in node ? node.title : undefined,
|
||||
description: "description" in node ? node.description : undefined,
|
||||
hiringOrganization: "hiringOrganization" in node ? node.hiringOrganization : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
if ("@graph" in node) return findPostingNode(node["@graph"]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strip an HTML document (or fragment) down to readable plain text. */
|
||||
export function stripHtml(html: string): string {
|
||||
let t = html
|
||||
.replace(/<!--[\s\S]*?-->/g, " ")
|
||||
.replace(/<(script|style|noscript|template|svg|head)\b[\s\S]*?<\/\1>/gi, " ")
|
||||
.replace(/<(br|\/p|\/div|\/li|\/tr|\/h[1-6]|\/section|\/article|\/ul|\/ol)[^>]*>/gi, "\n")
|
||||
.replace(/<li[^>]*>/gi, "\n- ")
|
||||
.replace(/<[^>]+>/g, " ");
|
||||
return tidy(decodeEntities(t));
|
||||
}
|
||||
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
ndash: "–",
|
||||
mdash: "—",
|
||||
bull: "•",
|
||||
hellip: "…",
|
||||
rsquo: "'",
|
||||
lsquo: "'",
|
||||
rdquo: '"',
|
||||
ldquo: '"',
|
||||
};
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => {
|
||||
if (code.startsWith("#x") || code.startsWith("#X"))
|
||||
return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
|
||||
if (code.startsWith("#")) return String.fromCodePoint(Number.parseInt(code.slice(1), 10));
|
||||
return NAMED_ENTITIES[code.toLowerCase()] ?? whole;
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize whitespace: collapse runs, trim lines, drop blank-line stacks. */
|
||||
function tidy(s: string): string {
|
||||
return s
|
||||
.replace(/\r/g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t\u00a0]+/g, " ").trim())
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ChatMessage, JobExtract, MatchResult, ResumePlan } from "../shared/types";
|
||||
import { buildPlan, chatReply } from "./ai";
|
||||
import { loadCorpus } from "./db";
|
||||
import { extractJob } from "./extract";
|
||||
import { renderLatex } from "./latex";
|
||||
import { matchCorpus } from "./match";
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error(JSON.stringify({ level: "error", path: c.req.path, message: err.message }));
|
||||
return c.json({ error: err.message }, 500);
|
||||
});
|
||||
|
||||
app.post("/api/extract", async (c) => {
|
||||
const { url } = await c.req.json<{ url?: string }>();
|
||||
const trimmed = url?.trim() ?? "";
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
return c.json({ error: "Enter a full http(s) URL to a job posting." }, 400);
|
||||
}
|
||||
let jd: JobExtract;
|
||||
try {
|
||||
jd = await extractJob(trimmed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Could not fetch the posting: ${message}` }, 502);
|
||||
}
|
||||
if (jd.text.length < 120) {
|
||||
return c.json(
|
||||
{ error: "The page yielded almost no text (likely rendered by JavaScript). Try the direct job-board URL (Greenhouse/Lever/Workable)." },
|
||||
422,
|
||||
);
|
||||
}
|
||||
return c.json(jd);
|
||||
});
|
||||
|
||||
app.post("/api/match", async (c) => {
|
||||
const { text } = await c.req.json<{ text?: string }>();
|
||||
if (!text) return c.json({ error: "Missing job text." }, 400);
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
return c.json(matchCorpus(corpus, text));
|
||||
});
|
||||
|
||||
app.post("/api/chat", async (c) => {
|
||||
const body = await c.req.json<{ jd: JobExtract; match: MatchResult; messages: ChatMessage[] }>();
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
try {
|
||||
const reply = await chatReply(c.env, corpus, body.jd, body.match, body.messages ?? []);
|
||||
return c.json({ reply });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Workers AI is unavailable (${message}). Check \`wrangler login\` / network.` }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/plan", async (c) => {
|
||||
const body = await c.req.json<{ jd: JobExtract; match?: MatchResult; chat?: ChatMessage[] }>();
|
||||
if (!body.jd?.text) return c.json({ error: "Missing job extract." }, 400);
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
const match = body.match ?? matchCorpus(corpus, body.jd.text);
|
||||
return c.json(await buildPlan(c.env, corpus, body.jd, match, body.chat ?? []));
|
||||
});
|
||||
|
||||
app.post("/api/latex", async (c) => {
|
||||
const { plan } = await c.req.json<{ plan?: ResumePlan }>();
|
||||
if (!plan) return c.json({ error: "Missing plan." }, 400);
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
return c.json({ latex: renderLatex(corpus, plan) });
|
||||
});
|
||||
|
||||
/** Production fallback compiler; local dev uses the Vite /local/compile middleware (real pdflatex). */
|
||||
app.post("/api/pdf", async (c) => {
|
||||
const { latex } = await c.req.json<{ latex?: string }>();
|
||||
if (!latex) return c.json({ error: "Missing latex source." }, 400);
|
||||
const res = await fetch("https://latex.ytotech.com/builds/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ compiler: "pdflatex", resources: [{ main: true, content: latex }] }),
|
||||
});
|
||||
if (res.ok && res.body) {
|
||||
return new Response(res.body, { headers: { "Content-Type": "application/pdf" } });
|
||||
}
|
||||
const log = (await res.text()).slice(0, 4000);
|
||||
return c.json({ error: `Remote LaTeX service responded ${res.status}`, log }, 502);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ResumePlan } from "../shared/types";
|
||||
import { formatRange, type Corpus } from "./db";
|
||||
|
||||
/** Escape dynamic text for pdflatex (inputenc utf8 + fontenc T1, Computer Modern). */
|
||||
export function escapeLatex(s: string): string {
|
||||
let t = s.replace(/\\/g, "\u0000");
|
||||
t = t.replace(/([&%$#_{}])/g, "\\$1");
|
||||
t = t.replace(/~/g, "\\textasciitilde{}").replace(/\^/g, "\\textasciicircum{}");
|
||||
t = t.replace(/\u0000/g, "\\textbackslash{}");
|
||||
t = t
|
||||
.replace(/[\u2018\u2019\u02bc]/g, "'")
|
||||
.replace(/\u201c/g, "``")
|
||||
.replace(/\u201d/g, "''")
|
||||
.replace(/"([^"]*)"/g, "``$1''")
|
||||
.replace(/"/g, "''")
|
||||
.replace(/\u2014/g, "---")
|
||||
.replace(/[\u2012\u2013]/g, "--")
|
||||
.replace(/[\u2010\u2011\u2212]/g, "-")
|
||||
.replace(/\u00b7/g, "\\textperiodcentered{}")
|
||||
.replace(/[\u2022\u25aa\u25cf]/g, "\\textbullet{}")
|
||||
.replace(/\u2026/g, "\\ldots{}")
|
||||
.replace(/[\u00a0\u2007\u202f]/g, "~");
|
||||
return t.replace(/[^\x20-\x7e]/g, "");
|
||||
}
|
||||
|
||||
function itemize(bullets: string[]): string {
|
||||
const items = bullets.map((b) => ` \\item \\small{${escapeLatex(b)}}`).join("\n");
|
||||
return `\\begin{itemize}[leftmargin=*, nosep, topsep=2pt]\n${items}\n\\end{itemize}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the shared résumé template (same preamble and layout as ../src/*.tex)
|
||||
* with a tailored plan plus the role-independent corpus blocks.
|
||||
*/
|
||||
export function renderLatex(corpus: Corpus, plan: ResumePlan): string {
|
||||
const p = corpus.profile;
|
||||
|
||||
const contact: string[] = [];
|
||||
if (p.email) contact.push(`\\href{mailto:${p.email}}{${escapeLatex(p.email)}}`);
|
||||
if (p.phone) contact.push(escapeLatex(p.phone));
|
||||
if (p.website) contact.push(`\\href{${p.website}}{Website}`);
|
||||
if (p.linkedin) contact.push(`\\href{${p.linkedin}}{LinkedIn}`);
|
||||
if (p.github) contact.push(`\\href{${p.github}}{GitHub}`);
|
||||
if (p.twitter) contact.push(`\\href{${p.twitter}}{Twitter}`);
|
||||
|
||||
const skillGroups = plan.skills
|
||||
.map((g) => `\\textbf{${escapeLatex(g.group)}}\\\\[1pt]\n${escapeLatex(g.items.join(", "))}\\\\[5pt]`)
|
||||
.join("\n");
|
||||
|
||||
const honors = corpus.honors
|
||||
.map(
|
||||
(h) =>
|
||||
`\\textbf{${escapeLatex(h.title)}}\\\\[1pt]\n${escapeLatex(h.institution ?? "")} \\hfill \\textit{${h.date?.slice(0, 4) ?? ""}}\\\\[4pt]`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const organizations = corpus.organizations
|
||||
.map((o) => `\\textbf{${escapeLatex(o.name)}}\\\\[1pt]\n${escapeLatex(o.title ?? "")}\\\\[4pt]`)
|
||||
.join("\n");
|
||||
|
||||
const experience = plan.experience
|
||||
.flatMap((sel) => {
|
||||
const row = corpus.experience.find((e) => e.slug === sel.slug);
|
||||
if (!row || sel.bullets.length === 0) return [];
|
||||
const dates = escapeLatex(formatRange(row.start_date, row.end_date, "month"));
|
||||
const where = row.location ? `${escapeLatex(row.company)} --- ${escapeLatex(row.location)}` : escapeLatex(row.company);
|
||||
return [
|
||||
`\\textbf{${escapeLatex(row.title)}} \\hfill \\textit{${dates}}\\\\\n\\textit{${where}}\n${itemize(sel.bullets)}\n\\vspace{6pt}`,
|
||||
];
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const projects = plan.projects
|
||||
.flatMap((sel) => {
|
||||
const row = corpus.projects.find((pr) => pr.slug === sel.slug);
|
||||
if (!row || sel.bullets.length === 0) return [];
|
||||
const dates = escapeLatex(formatRange(row.start_date, row.end_date, "year"));
|
||||
return [`\\textbf{${escapeLatex(sel.heading)}} \\hfill \\textit{${dates}}\n${itemize(sel.bullets)}\n\\vspace{4pt}`];
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const edu = corpus.education;
|
||||
const education = edu
|
||||
? `\\textbf{B.S. in ${escapeLatex(edu.field ?? "")}} \\hfill \\textit{${escapeLatex(formatRange(edu.start_date, edu.end_date, "year"))}}\\\\\n\\textit{${escapeLatex(edu.school)}}`
|
||||
: "";
|
||||
|
||||
return `\\documentclass[11pt]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{geometry}
|
||||
\\usepackage{enumitem}
|
||||
\\usepackage{paracol}
|
||||
\\usepackage[hidelinks]{hyperref}
|
||||
\\geometry{a4paper, margin=0.5in}
|
||||
\\setlength{\\parindent}{0pt}
|
||||
\\setlength{\\parskip}{2pt}
|
||||
\\columnratio{0.3}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
{\\centering
|
||||
{\\Huge \\textbf{${escapeLatex(p.full_name)}}}\\\\[2pt]
|
||||
{\\large ${escapeLatex(plan.title)}}
|
||||
\\par
|
||||
}
|
||||
\\vspace{14pt}
|
||||
|
||||
\\begin{paracol}{2}
|
||||
\\raggedright
|
||||
\\small
|
||||
{\\bfseries Contact}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${contact.join("\\\\[2pt]\n")}
|
||||
|
||||
\\vspace{8pt}
|
||||
|
||||
{\\bfseries Skills}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${skillGroups}
|
||||
|
||||
\\vspace{8pt}
|
||||
|
||||
{\\bfseries Honors}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${honors}
|
||||
|
||||
\\vspace{8pt}
|
||||
|
||||
{\\bfseries Organizations}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${organizations}
|
||||
|
||||
\\switchcolumn
|
||||
\\normalsize
|
||||
\\raggedright
|
||||
{\\bfseries Experience}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${experience}
|
||||
|
||||
{\\bfseries Projects}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${projects}
|
||||
|
||||
{\\bfseries Education}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${education}
|
||||
\\vspace{4pt}
|
||||
|
||||
\\end{paracol}
|
||||
|
||||
\\end{document}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { MatchResult, MatchedSkill, RankedItem } from "../shared/types";
|
||||
import { formatRange, type Corpus } from "./db";
|
||||
|
||||
/** Skill names that collide with common English words — match these case-sensitively. */
|
||||
const CASE_SENSITIVE = new Set(["Go", "R", "C", "Swift", "Dart", "Unity", "Agile", "Matrix"]);
|
||||
|
||||
/** Hand-curated aliases beyond the automatic parenthetical/slash splits. */
|
||||
const EXTRA_ALIASES: Record<string, string[]> = {
|
||||
Go: ["Golang"],
|
||||
Kubernetes: ["K8s"],
|
||||
"Google Cloud": ["GCP"],
|
||||
"Test-Driven Development": ["TDD"],
|
||||
"Continuous Integration": ["CI/CD", "CI"],
|
||||
"React.js": ["ReactJS"],
|
||||
"Protocol Buffers": ["Protobufs", "Protobuf"],
|
||||
Protobufs: ["Protocol Buffers", "Protobuf"],
|
||||
"Matrix Protocol": ["Matrix"],
|
||||
"Decentralized Identifiers (DIDs)": ["DID"],
|
||||
"Cloudflare Workers": ["Cloudflare"],
|
||||
PostgreSQL: ["Postgres"],
|
||||
};
|
||||
|
||||
/** "WebAssembly (WASM)" -> ["WebAssembly (WASM)", "WebAssembly", "WASM"]; "HTML/CSS" -> [..., "HTML", "CSS"]. */
|
||||
function aliasesFor(name: string): string[] {
|
||||
const out = [name];
|
||||
const paren = /^(.*?)\s*\((.*?)\)$/.exec(name);
|
||||
if (paren) {
|
||||
out.push(paren[1].trim());
|
||||
for (const part of paren[2].split(/\s*(?:[&,/·]|and)\s*/)) if (part.trim()) out.push(part.trim());
|
||||
} else if (name.includes("/") && name !== "CI/CD") {
|
||||
for (const part of name.split("/")) if (part.trim()) out.push(part.trim());
|
||||
}
|
||||
for (const extra of EXTRA_ALIASES[name] ?? []) out.push(extra);
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
function countHits(text: string, name: string): number {
|
||||
let hits = 0;
|
||||
for (const alias of aliasesFor(name)) {
|
||||
if (alias.length < 2 && !CASE_SENSITIVE.has(alias)) continue;
|
||||
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const flags = CASE_SENSITIVE.has(alias) ? "g" : "gi";
|
||||
const re = new RegExp(`(?<![A-Za-z0-9#+])${escaped}(?![A-Za-z0-9#+])`, flags);
|
||||
hits += text.match(re)?.length ?? 0;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function rankItem(
|
||||
type: RankedItem["type"],
|
||||
slug: string,
|
||||
title: string,
|
||||
company: string | undefined,
|
||||
dates: string,
|
||||
bullets: string[],
|
||||
itemSkills: string[],
|
||||
hitsByName: Map<string, number>,
|
||||
): RankedItem {
|
||||
const matchedSkills = itemSkills.filter((s) => hitsByName.has(s));
|
||||
let bulletHits = 0;
|
||||
for (const bullet of bullets) {
|
||||
const lower = bullet.toLowerCase();
|
||||
for (const s of matchedSkills) {
|
||||
const stem = s.replace(/\s*\(.*\)$/, "").toLowerCase();
|
||||
if (lower.includes(stem)) bulletHits++;
|
||||
}
|
||||
}
|
||||
const score = matchedSkills.length * 3 + Math.min(bulletHits, 6);
|
||||
return { type, slug, title, company, dates, score, matchedSkills, bullets };
|
||||
}
|
||||
|
||||
/** Deterministic skill-keyword match of the corpus against extracted job text. */
|
||||
export function matchCorpus(corpus: Corpus, jdText: string): MatchResult {
|
||||
const categories = new Map<string, MatchedSkill["category"]>();
|
||||
for (const s of corpus.skills) categories.set(s.name, s.category);
|
||||
|
||||
const universe = new Set<string>(corpus.skills.map((s) => s.name));
|
||||
for (const item of corpus.experience) for (const s of item.skills) universe.add(s);
|
||||
for (const item of corpus.projects) for (const s of item.skills) universe.add(s);
|
||||
|
||||
const hitsByName = new Map<string, number>();
|
||||
for (const name of universe) {
|
||||
const hits = countHits(jdText, name);
|
||||
if (hits > 0) hitsByName.set(name, hits);
|
||||
}
|
||||
|
||||
const skills: MatchedSkill[] = [...hitsByName.entries()]
|
||||
.map(([name, hits]) => ({ name, hits, category: categories.get(name) ?? ("domain" as const) }))
|
||||
.sort((a, b) => b.hits - a.hits);
|
||||
|
||||
const items: RankedItem[] = [];
|
||||
for (const e of corpus.experience) {
|
||||
items.push(
|
||||
rankItem("experience", e.slug, e.title, e.company, formatRange(e.start_date, e.end_date, "month"), e.bullets, e.skills, hitsByName),
|
||||
);
|
||||
}
|
||||
for (const p of corpus.projects) {
|
||||
items.push(
|
||||
rankItem("project", p.slug, p.title, undefined, formatRange(p.start_date, p.end_date, "year"), p.bullets, p.skills, hitsByName),
|
||||
);
|
||||
}
|
||||
items.sort((a, b) => b.score - a.score);
|
||||
return { skills, items };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "cv-tailor",
|
||||
"main": "worker/index.ts",
|
||||
"compatibility_date": "2026-06-20",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"assets": {
|
||||
"not_found_handling": "single-page-application"
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
// Frontier open-weight model for resume plan generation. The remote AI
|
||||
// binding has a ~60s gateway cap; kimi-k2.6 (1T) exceeds it on this prompt.
|
||||
"PLAN_MODEL": "@cf/openai/gpt-oss-120b",
|
||||
// Fast model for the interactive chat.
|
||||
"CHAT_MODEL": "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "cv-tailor",
|
||||
// Placeholder for local dev. Replace with the real ID from
|
||||
// `wrangler d1 create cv-tailor` before deploying.
|
||||
"database_id": "00000000-0000-4000-8000-0000c0ffee00",
|
||||
"migrations_dir": "migrations"
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"binding": "AI",
|
||||
"remote": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user