@doccov/api 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/api/scan/detect.ts +34 -130
- package/api/scan-stream.ts +15 -25
- package/api/scan.ts +9 -10
- package/package.json +4 -3
- package/src/index.ts +12 -7
- package/src/middleware/rate-limit.ts +112 -0
- package/src/routes/scan.ts +20 -9
- package/src/sandbox-runner.ts +1 -1
- package/src/scan-worker.ts +9 -18
- package/src/schemas/scan.ts +16 -0
- package/src/stores/job-store.interface.ts +25 -0
- package/src/utils/github.ts +5 -25
- package/src/routes/leaderboard.ts +0 -214
- package/src/routes/widget.ts +0 -178
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @doccov/api
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- consolidate api routes to use unified SDK modules.
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @openpkg-ts/spec@0.5.0
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- add rate limiting middleware and job store abstractions
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies
|
|
20
|
+
- @openpkg-ts/spec@0.4.1
|
|
21
|
+
|
|
3
22
|
## 0.2.3
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/api/scan/detect.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Detect endpoint - uses SDK detection via SandboxFileSystem.
|
|
3
|
+
* Detects monorepo structure and package manager for a GitHub repository.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
detectMonorepo as sdkDetectMonorepo,
|
|
8
|
+
detectPackageManager,
|
|
9
|
+
SandboxFileSystem,
|
|
10
|
+
} from '@doccov/sdk';
|
|
2
11
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
3
12
|
import { Sandbox } from '@vercel/sandbox';
|
|
4
13
|
|
|
@@ -26,18 +35,6 @@ interface DetectResponse {
|
|
|
26
35
|
error?: string;
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
// Helper to capture stream output
|
|
30
|
-
function createCaptureStream(): { stream: Writable; getOutput: () => string } {
|
|
31
|
-
let output = '';
|
|
32
|
-
const stream = new Writable({
|
|
33
|
-
write(chunk, _encoding, callback) {
|
|
34
|
-
output += chunk.toString();
|
|
35
|
-
callback();
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
return { stream, getOutput: () => output };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
38
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
42
39
|
// CORS
|
|
43
40
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
@@ -59,7 +56,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
try {
|
|
62
|
-
const result = await
|
|
59
|
+
const result = await detectRepoStructure(body.url);
|
|
63
60
|
return res.status(200).json(result);
|
|
64
61
|
} catch (error) {
|
|
65
62
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -71,7 +68,10 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
71
68
|
}
|
|
72
69
|
}
|
|
73
70
|
|
|
74
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Detect repository structure using SDK utilities via SandboxFileSystem.
|
|
73
|
+
*/
|
|
74
|
+
async function detectRepoStructure(url: string): Promise<DetectResponse> {
|
|
75
75
|
const sandbox = await Sandbox.create({
|
|
76
76
|
source: {
|
|
77
77
|
url,
|
|
@@ -83,131 +83,35 @@ async function detectMonorepo(url: string, _ref: string): Promise<DetectResponse
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
try {
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
await sandbox.runCommand({
|
|
89
|
-
cmd: 'ls',
|
|
90
|
-
args: ['-1'],
|
|
91
|
-
stdout: lsCapture.stream,
|
|
92
|
-
});
|
|
93
|
-
const files = lsCapture.getOutput();
|
|
94
|
-
|
|
95
|
-
// Detect package manager
|
|
96
|
-
let packageManager: DetectResponse['packageManager'] = 'npm';
|
|
97
|
-
if (files.includes('pnpm-lock.yaml')) {
|
|
98
|
-
packageManager = 'pnpm';
|
|
99
|
-
} else if (files.includes('bun.lock') || files.includes('bun.lockb')) {
|
|
100
|
-
packageManager = 'bun';
|
|
101
|
-
} else if (files.includes('yarn.lock')) {
|
|
102
|
-
packageManager = 'yarn';
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Read root package.json
|
|
106
|
-
const pkgCapture = createCaptureStream();
|
|
107
|
-
await sandbox.runCommand({
|
|
108
|
-
cmd: 'cat',
|
|
109
|
-
args: ['package.json'],
|
|
110
|
-
stdout: pkgCapture.stream,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
let rootPkg: { workspaces?: string[] | { packages?: string[] }; name?: string } = {};
|
|
114
|
-
try {
|
|
115
|
-
rootPkg = JSON.parse(pkgCapture.getOutput());
|
|
116
|
-
} catch {
|
|
117
|
-
// Not a valid package.json
|
|
118
|
-
}
|
|
86
|
+
// Create SDK FileSystem abstraction for sandbox
|
|
87
|
+
const fs = new SandboxFileSystem(sandbox);
|
|
119
88
|
|
|
120
|
-
//
|
|
121
|
-
|
|
89
|
+
// Use SDK detection functions
|
|
90
|
+
const [monoInfo, pmInfo] = await Promise.all([
|
|
91
|
+
sdkDetectMonorepo(fs),
|
|
92
|
+
detectPackageManager(fs),
|
|
93
|
+
]);
|
|
122
94
|
|
|
123
|
-
if (
|
|
124
|
-
if (Array.isArray(rootPkg.workspaces)) {
|
|
125
|
-
workspacePatterns = rootPkg.workspaces;
|
|
126
|
-
} else if (rootPkg.workspaces.packages) {
|
|
127
|
-
workspacePatterns = rootPkg.workspaces.packages;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Check pnpm-workspace.yaml
|
|
132
|
-
if (files.includes('pnpm-workspace.yaml')) {
|
|
133
|
-
const wsCapture = createCaptureStream();
|
|
134
|
-
await sandbox.runCommand({
|
|
135
|
-
cmd: 'cat',
|
|
136
|
-
args: ['pnpm-workspace.yaml'],
|
|
137
|
-
stdout: wsCapture.stream,
|
|
138
|
-
});
|
|
139
|
-
const wsContent = wsCapture.getOutput();
|
|
140
|
-
// Simple YAML parsing for packages array
|
|
141
|
-
const packagesMatch = wsContent.match(/packages:\s*\n((?:\s+-\s*.+\n?)+)/);
|
|
142
|
-
if (packagesMatch) {
|
|
143
|
-
const lines = packagesMatch[1].split('\n');
|
|
144
|
-
for (const line of lines) {
|
|
145
|
-
const match = line.match(/^\s+-\s*['"]?([^'"]+)['"]?\s*$/);
|
|
146
|
-
if (match) {
|
|
147
|
-
workspacePatterns.push(match[1]);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Not a monorepo
|
|
154
|
-
if (workspacePatterns.length === 0) {
|
|
95
|
+
if (!monoInfo.isMonorepo) {
|
|
155
96
|
return {
|
|
156
97
|
isMonorepo: false,
|
|
157
|
-
packageManager,
|
|
98
|
+
packageManager: pmInfo.name,
|
|
158
99
|
};
|
|
159
100
|
}
|
|
160
101
|
|
|
161
|
-
//
|
|
162
|
-
const packages: PackageInfo[] =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const packagePaths = findCapture
|
|
173
|
-
.getOutput()
|
|
174
|
-
.trim()
|
|
175
|
-
.split('\n')
|
|
176
|
-
.filter((p) => p && p !== './package.json');
|
|
177
|
-
|
|
178
|
-
for (const pkgPath of packagePaths.slice(0, 30)) {
|
|
179
|
-
// Limit to 30 packages
|
|
180
|
-
const catCapture = createCaptureStream();
|
|
181
|
-
await sandbox.runCommand({
|
|
182
|
-
cmd: 'cat',
|
|
183
|
-
args: [pkgPath],
|
|
184
|
-
stdout: catCapture.stream,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const pkg = JSON.parse(catCapture.getOutput()) as {
|
|
189
|
-
name?: string;
|
|
190
|
-
description?: string;
|
|
191
|
-
private?: boolean;
|
|
192
|
-
};
|
|
193
|
-
if (pkg.name && !pkg.private) {
|
|
194
|
-
packages.push({
|
|
195
|
-
name: pkg.name,
|
|
196
|
-
path: pkgPath.replace('./package.json', '.').replace('/package.json', ''),
|
|
197
|
-
description: pkg.description,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
} catch {
|
|
201
|
-
// Skip invalid package.json
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Sort by name
|
|
206
|
-
packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
102
|
+
// Map SDK package info to API response format
|
|
103
|
+
const packages: PackageInfo[] = monoInfo.packages
|
|
104
|
+
.filter((p) => !p.private)
|
|
105
|
+
.map((p) => ({
|
|
106
|
+
name: p.name,
|
|
107
|
+
path: p.path,
|
|
108
|
+
description: p.description,
|
|
109
|
+
}))
|
|
110
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
207
111
|
|
|
208
112
|
return {
|
|
209
113
|
isMonorepo: true,
|
|
210
|
-
packageManager,
|
|
114
|
+
packageManager: pmInfo.name,
|
|
211
115
|
packages,
|
|
212
116
|
defaultPackage: packages[0]?.name,
|
|
213
117
|
};
|
package/api/scan-stream.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Writable } from 'node:stream';
|
|
2
|
-
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
3
|
-
import { Sandbox } from '@vercel/sandbox';
|
|
4
2
|
import {
|
|
5
|
-
SandboxFileSystem,
|
|
6
3
|
detectBuildInfo,
|
|
7
4
|
detectMonorepo,
|
|
8
5
|
detectPackageManager,
|
|
9
6
|
getInstallCommand,
|
|
10
7
|
getPrimaryBuildScript,
|
|
8
|
+
SandboxFileSystem,
|
|
9
|
+
type ScanResult,
|
|
11
10
|
} from '@doccov/sdk';
|
|
11
|
+
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
12
|
+
import { Sandbox } from '@vercel/sandbox';
|
|
12
13
|
|
|
13
14
|
export const config = {
|
|
14
15
|
runtime: 'nodejs',
|
|
@@ -24,23 +25,6 @@ interface JobEvent {
|
|
|
24
25
|
availablePackages?: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
interface ScanResult {
|
|
28
|
-
owner: string;
|
|
29
|
-
repo: string;
|
|
30
|
-
ref: string;
|
|
31
|
-
packageName?: string;
|
|
32
|
-
coverage: number;
|
|
33
|
-
exportCount: number;
|
|
34
|
-
typeCount: number;
|
|
35
|
-
driftCount: number;
|
|
36
|
-
undocumented: string[];
|
|
37
|
-
drift: Array<{
|
|
38
|
-
export: string;
|
|
39
|
-
type: string;
|
|
40
|
-
issue: string;
|
|
41
|
-
}>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
28
|
// Helper to capture stream output
|
|
45
29
|
function createCaptureStream(): { stream: Writable; getOutput: () => string } {
|
|
46
30
|
let output = '';
|
|
@@ -153,7 +137,13 @@ async function runScanWithProgress(
|
|
|
153
137
|
// Try fetching the ref first (might be a tag not fetched by shallow clone)
|
|
154
138
|
await sandbox.runCommand({
|
|
155
139
|
cmd: 'git',
|
|
156
|
-
args: [
|
|
140
|
+
args: [
|
|
141
|
+
'fetch',
|
|
142
|
+
'--depth',
|
|
143
|
+
'1',
|
|
144
|
+
'origin',
|
|
145
|
+
`refs/tags/${options.ref}:refs/tags/${options.ref}`,
|
|
146
|
+
],
|
|
157
147
|
});
|
|
158
148
|
const retryResult = await sandbox.runCommand({
|
|
159
149
|
cmd: 'git',
|
|
@@ -174,7 +164,9 @@ async function runScanWithProgress(
|
|
|
174
164
|
|
|
175
165
|
// Detect package manager using SDK
|
|
176
166
|
const pmInfo = await detectPackageManager(fs);
|
|
177
|
-
const pmMessage = pmInfo.lockfile
|
|
167
|
+
const pmMessage = pmInfo.lockfile
|
|
168
|
+
? `Detected ${pmInfo.name} project`
|
|
169
|
+
: 'No lockfile detected';
|
|
178
170
|
sendEvent({ type: 'progress', stage: 'detecting', message: pmMessage, progress: 15 });
|
|
179
171
|
|
|
180
172
|
// Early monorepo detection - fail fast if monorepo without package param
|
|
@@ -189,9 +181,7 @@ async function runScanWithProgress(
|
|
|
189
181
|
progress: 17,
|
|
190
182
|
});
|
|
191
183
|
|
|
192
|
-
const availablePackages = mono.packages
|
|
193
|
-
.filter((p) => !p.private)
|
|
194
|
-
.map((p) => p.name);
|
|
184
|
+
const availablePackages = mono.packages.filter((p) => !p.private).map((p) => p.name);
|
|
195
185
|
|
|
196
186
|
await sandbox.stop();
|
|
197
187
|
sendEvent({
|
package/api/scan.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseGitHubUrl } from '@doccov/sdk';
|
|
1
2
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
2
3
|
|
|
3
4
|
export const config = {
|
|
@@ -31,25 +32,23 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
31
32
|
return res.status(400).json({ error: 'url is required' });
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
// Parse GitHub URL
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Parse GitHub URL using SDK
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = parseGitHubUrl(body.url, body.ref ?? 'main');
|
|
39
|
+
} catch {
|
|
37
40
|
return res.status(400).json({ error: 'Invalid GitHub URL' });
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
const [, owner, repoWithExt] = urlMatch;
|
|
41
|
-
const repo = repoWithExt.replace(/\.git$/, '');
|
|
42
|
-
const ref = body.ref ?? 'main';
|
|
43
|
-
|
|
44
43
|
// Generate a job ID
|
|
45
44
|
const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
46
45
|
|
|
47
46
|
// Build stream URL with params encoded
|
|
48
47
|
const params = new URLSearchParams({
|
|
49
48
|
url: body.url,
|
|
50
|
-
ref,
|
|
51
|
-
owner,
|
|
52
|
-
repo,
|
|
49
|
+
ref: parsed.ref,
|
|
50
|
+
owner: parsed.owner,
|
|
51
|
+
repo: parsed.repo,
|
|
53
52
|
});
|
|
54
53
|
if (body.package) {
|
|
55
54
|
params.set('package', body.package);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doccov/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "DocCov API - Badge endpoint and coverage services",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"doccov",
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
"format": "biome format --write src/"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@openpkg-ts/spec": "^0.
|
|
30
|
+
"@openpkg-ts/spec": "^0.5.0",
|
|
31
31
|
"@vercel/sandbox": "^1.0.3",
|
|
32
32
|
"hono": "^4.0.0",
|
|
33
|
-
"ms": "^2.1.3"
|
|
33
|
+
"ms": "^2.1.3",
|
|
34
|
+
"zod": "^3.25.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@types/bun": "latest",
|
package/src/index.ts
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
|
+
import { rateLimit } from './middleware/rate-limit';
|
|
3
4
|
import { badgeRoute } from './routes/badge';
|
|
4
|
-
import { leaderboardRoute } from './routes/leaderboard';
|
|
5
5
|
import { scanRoute } from './routes/scan';
|
|
6
|
-
import { widgetRoute } from './routes/widget';
|
|
7
6
|
|
|
8
7
|
const app = new Hono();
|
|
9
8
|
|
|
10
9
|
// Middleware
|
|
11
10
|
app.use('*', cors());
|
|
12
11
|
|
|
12
|
+
// Rate limit /scan endpoint: 10 requests per minute per IP
|
|
13
|
+
app.use(
|
|
14
|
+
'/scan/*',
|
|
15
|
+
rateLimit({
|
|
16
|
+
windowMs: 60 * 1000,
|
|
17
|
+
max: 10,
|
|
18
|
+
message: 'Too many scan requests. Please try again in a minute.',
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
13
22
|
// Health check
|
|
14
23
|
app.get('/', (c) => {
|
|
15
24
|
return c.json({
|
|
16
25
|
name: 'DocCov API',
|
|
17
|
-
version: '0.
|
|
26
|
+
version: '0.3.0',
|
|
18
27
|
endpoints: {
|
|
19
28
|
badge: '/badge/:owner/:repo',
|
|
20
|
-
widget: '/widget/:owner/:repo',
|
|
21
|
-
leaderboard: '/leaderboard',
|
|
22
29
|
scan: '/scan',
|
|
23
30
|
health: '/health',
|
|
24
31
|
},
|
|
@@ -31,8 +38,6 @@ app.get('/health', (c) => {
|
|
|
31
38
|
|
|
32
39
|
// Routes
|
|
33
40
|
app.route('/badge', badgeRoute);
|
|
34
|
-
app.route('/widget', widgetRoute);
|
|
35
|
-
app.route('/leaderboard', leaderboardRoute);
|
|
36
41
|
app.route('/scan', scanRoute);
|
|
37
42
|
|
|
38
43
|
// Vercel serverless handler + Bun auto-serves this export
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
2
|
+
|
|
3
|
+
interface RateLimitOptions {
|
|
4
|
+
/** Time window in milliseconds */
|
|
5
|
+
windowMs: number;
|
|
6
|
+
/** Max requests per window */
|
|
7
|
+
max: number;
|
|
8
|
+
/** Message to return when rate limited */
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RateLimitEntry {
|
|
13
|
+
count: number;
|
|
14
|
+
resetAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory rate limiter store
|
|
19
|
+
* Can be swapped for Redis in production
|
|
20
|
+
*/
|
|
21
|
+
class RateLimitStore {
|
|
22
|
+
private store = new Map<string, RateLimitEntry>();
|
|
23
|
+
|
|
24
|
+
get(key: string): RateLimitEntry | undefined {
|
|
25
|
+
const entry = this.store.get(key);
|
|
26
|
+
if (entry && Date.now() > entry.resetAt) {
|
|
27
|
+
this.store.delete(key);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
increment(key: string, windowMs: number): RateLimitEntry {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const existing = this.get(key);
|
|
36
|
+
|
|
37
|
+
if (existing) {
|
|
38
|
+
existing.count++;
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entry: RateLimitEntry = {
|
|
43
|
+
count: 1,
|
|
44
|
+
resetAt: now + windowMs,
|
|
45
|
+
};
|
|
46
|
+
this.store.set(key, entry);
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cleanup(): void {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
for (const [key, entry] of this.store.entries()) {
|
|
53
|
+
if (now > entry.resetAt) {
|
|
54
|
+
this.store.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const store = new RateLimitStore();
|
|
61
|
+
|
|
62
|
+
// Cleanup expired entries every minute
|
|
63
|
+
setInterval(() => store.cleanup(), 60 * 1000).unref();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get client IP from request
|
|
67
|
+
*/
|
|
68
|
+
function getClientIp(c: Context): string {
|
|
69
|
+
// Check common proxy headers
|
|
70
|
+
const forwarded = c.req.header('x-forwarded-for');
|
|
71
|
+
if (forwarded) {
|
|
72
|
+
return forwarded.split(',')[0].trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const realIp = c.req.header('x-real-ip');
|
|
76
|
+
if (realIp) {
|
|
77
|
+
return realIp;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Vercel-specific
|
|
81
|
+
const vercelIp = c.req.header('x-vercel-forwarded-for');
|
|
82
|
+
if (vercelIp) {
|
|
83
|
+
return vercelIp.split(',')[0].trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return 'unknown';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rate limiting middleware for Hono
|
|
91
|
+
*/
|
|
92
|
+
export function rateLimit(options: RateLimitOptions): MiddlewareHandler {
|
|
93
|
+
const { windowMs, max, message = 'Too many requests, please try again later' } = options;
|
|
94
|
+
|
|
95
|
+
return async (c: Context, next: Next) => {
|
|
96
|
+
const ip = getClientIp(c);
|
|
97
|
+
const key = `ratelimit:${ip}`;
|
|
98
|
+
|
|
99
|
+
const entry = store.increment(key, windowMs);
|
|
100
|
+
|
|
101
|
+
// Set rate limit headers
|
|
102
|
+
c.header('X-RateLimit-Limit', String(max));
|
|
103
|
+
c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
|
|
104
|
+
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
|
|
105
|
+
|
|
106
|
+
if (entry.count > max) {
|
|
107
|
+
return c.json({ error: message }, 429);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await next();
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/routes/scan.ts
CHANGED
|
@@ -3,26 +3,37 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import { isSandboxAvailable, runScanInSandbox } from '../sandbox-runner';
|
|
5
5
|
import { type ScanJob, type ScanResult, scanJobStore } from '../scan-worker';
|
|
6
|
+
import { scanRequestSchema } from '../schemas/scan';
|
|
6
7
|
|
|
7
8
|
export const scanRoute = new Hono();
|
|
8
9
|
|
|
9
|
-
interface ScanRequestBody {
|
|
10
|
-
url: string;
|
|
11
|
-
ref?: string;
|
|
12
|
-
package?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
10
|
/**
|
|
16
11
|
* POST /scan
|
|
17
12
|
* Start a new scan job
|
|
18
13
|
*/
|
|
19
14
|
scanRoute.post('/', async (c) => {
|
|
20
|
-
|
|
15
|
+
// Parse JSON with error handling
|
|
16
|
+
let rawBody: unknown;
|
|
17
|
+
try {
|
|
18
|
+
rawBody = await c.req.json();
|
|
19
|
+
} catch {
|
|
20
|
+
return c.json({ error: 'Invalid JSON' }, 400);
|
|
21
|
+
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// Validate request body with Zod schema
|
|
24
|
+
const parsed = scanRequestSchema.safeParse(rawBody);
|
|
25
|
+
if (!parsed.success) {
|
|
26
|
+
return c.json(
|
|
27
|
+
{
|
|
28
|
+
error: 'Validation failed',
|
|
29
|
+
details: parsed.error.issues.map((i) => i.message),
|
|
30
|
+
},
|
|
31
|
+
400,
|
|
32
|
+
);
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
const body = parsed.data;
|
|
36
|
+
|
|
26
37
|
// Generate job ID
|
|
27
38
|
const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
28
39
|
|
package/src/sandbox-runner.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Vercel Sandbox runner for isolated repo scanning
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { ScanResult } from '@doccov/sdk';
|
|
5
6
|
import { Sandbox } from '@vercel/sandbox';
|
|
6
7
|
import ms from 'ms';
|
|
7
|
-
import type { ScanResult } from './scan-worker';
|
|
8
8
|
|
|
9
9
|
export interface ScanOptions {
|
|
10
10
|
url: string;
|
package/src/scan-worker.ts
CHANGED
|
@@ -2,22 +2,11 @@
|
|
|
2
2
|
* Scan job store and caching layer
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
coverage: number;
|
|
11
|
-
exportCount: number;
|
|
12
|
-
typeCount: number;
|
|
13
|
-
driftCount: number;
|
|
14
|
-
undocumented: string[];
|
|
15
|
-
drift: Array<{
|
|
16
|
-
export: string;
|
|
17
|
-
type: string;
|
|
18
|
-
issue: string;
|
|
19
|
-
}>;
|
|
20
|
-
}
|
|
5
|
+
import type { ScanResult } from '@doccov/sdk';
|
|
6
|
+
import type { JobStore } from './stores/job-store.interface';
|
|
7
|
+
|
|
8
|
+
// Re-export ScanResult for backwards compatibility
|
|
9
|
+
export type { ScanResult } from '@doccov/sdk';
|
|
21
10
|
|
|
22
11
|
export interface ScanJob {
|
|
23
12
|
id: string;
|
|
@@ -42,8 +31,9 @@ const JOB_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
42
31
|
|
|
43
32
|
/**
|
|
44
33
|
* In-memory job store with caching
|
|
34
|
+
* Implements JobStore interface for easy swap to Redis/KV
|
|
45
35
|
*/
|
|
46
|
-
class ScanJobStore {
|
|
36
|
+
class ScanJobStore implements JobStore {
|
|
47
37
|
private jobs = new Map<string, ScanJob>();
|
|
48
38
|
private cache = new Map<string, CacheEntry>();
|
|
49
39
|
|
|
@@ -123,9 +113,10 @@ class ScanJobStore {
|
|
|
123
113
|
export const scanJobStore = new ScanJobStore();
|
|
124
114
|
|
|
125
115
|
// Periodic cleanup every 5 minutes
|
|
116
|
+
// Use .unref() to prevent keeping the process alive
|
|
126
117
|
setInterval(
|
|
127
118
|
() => {
|
|
128
119
|
scanJobStore.cleanup();
|
|
129
120
|
},
|
|
130
121
|
5 * 60 * 1000,
|
|
131
|
-
);
|
|
122
|
+
).unref();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for POST /scan request body
|
|
5
|
+
* Validates and restricts URLs to GitHub only
|
|
6
|
+
*/
|
|
7
|
+
export const scanRequestSchema = z.object({
|
|
8
|
+
url: z
|
|
9
|
+
.string()
|
|
10
|
+
.url('Invalid URL format')
|
|
11
|
+
.regex(/^https:\/\/github\.com\//, 'Only GitHub URLs are allowed'),
|
|
12
|
+
ref: z.string().optional(),
|
|
13
|
+
package: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type ScanRequestBody = z.infer<typeof scanRequestSchema>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ScanJob, ScanResult } from '../scan-worker';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interface for scan job storage
|
|
5
|
+
* Allows swapping in-memory store for Redis/KV in production
|
|
6
|
+
*/
|
|
7
|
+
export interface JobStore {
|
|
8
|
+
/** Get a job by ID */
|
|
9
|
+
get(jobId: string): ScanJob | undefined | Promise<ScanJob | undefined>;
|
|
10
|
+
|
|
11
|
+
/** Set/update a job */
|
|
12
|
+
set(jobId: string, job: ScanJob): void | Promise<void>;
|
|
13
|
+
|
|
14
|
+
/** Get cached scan result */
|
|
15
|
+
getFromCache(key: string): ScanResult | undefined | Promise<ScanResult | undefined>;
|
|
16
|
+
|
|
17
|
+
/** Cache a scan result */
|
|
18
|
+
setCache(key: string, result: ScanResult): void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
/** Cleanup expired entries */
|
|
21
|
+
cleanup(): void | Promise<void>;
|
|
22
|
+
|
|
23
|
+
/** Get store statistics */
|
|
24
|
+
stats(): { jobs: number; cache: number } | Promise<{ jobs: number; cache: number }>;
|
|
25
|
+
}
|
package/src/utils/github.ts
CHANGED
|
@@ -1,25 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
branch = 'main',
|
|
7
|
-
): Promise<OpenPkg | null> {
|
|
8
|
-
const urls = [
|
|
9
|
-
`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/openpkg.json`,
|
|
10
|
-
`https://raw.githubusercontent.com/${owner}/${repo}/master/openpkg.json`,
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
for (const url of urls) {
|
|
14
|
-
try {
|
|
15
|
-
const response = await fetch(url);
|
|
16
|
-
if (response.ok) {
|
|
17
|
-
return (await response.json()) as OpenPkg;
|
|
18
|
-
}
|
|
19
|
-
} catch {
|
|
20
|
-
// Try next URL
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Re-export GitHub utilities from SDK for backwards compatibility.
|
|
3
|
+
* @deprecated Import directly from '@doccov/sdk' instead.
|
|
4
|
+
*/
|
|
5
|
+
export { fetchSpec as fetchSpecFromGitHub } from '@doccov/sdk';
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import type { OpenPkg } from '@openpkg-ts/spec';
|
|
2
|
-
import { Hono } from 'hono';
|
|
3
|
-
|
|
4
|
-
export const leaderboardRoute = new Hono();
|
|
5
|
-
|
|
6
|
-
interface LeaderboardEntry {
|
|
7
|
-
owner: string;
|
|
8
|
-
repo: string;
|
|
9
|
-
coverage: number;
|
|
10
|
-
exportCount: number;
|
|
11
|
-
driftCount: number;
|
|
12
|
-
lastUpdated: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// In-memory cache for demo purposes
|
|
16
|
-
// In production, this would be backed by a database
|
|
17
|
-
const leaderboardCache = new Map<string, LeaderboardEntry[]>();
|
|
18
|
-
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
19
|
-
let lastCacheUpdate = 0;
|
|
20
|
-
|
|
21
|
-
// Popular TypeScript libraries to track
|
|
22
|
-
const TRACKED_REPOS = [
|
|
23
|
-
{ owner: 'tanstack', repo: 'query' },
|
|
24
|
-
{ owner: 'trpc', repo: 'trpc' },
|
|
25
|
-
{ owner: 'colinhacks', repo: 'zod' },
|
|
26
|
-
{ owner: 'drizzle-team', repo: 'drizzle-orm' },
|
|
27
|
-
{ owner: 'pmndrs', repo: 'zustand' },
|
|
28
|
-
{ owner: 'jaredpalmer', repo: 'formik' },
|
|
29
|
-
{ owner: 'react-hook-form', repo: 'react-hook-form' },
|
|
30
|
-
{ owner: 'TanStack', repo: 'router' },
|
|
31
|
-
{ owner: 'TanStack', repo: 'table' },
|
|
32
|
-
{ owner: 'tailwindlabs', repo: 'headlessui' },
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
async function fetchSpecFromGitHub(owner: string, repo: string): Promise<OpenPkg | null> {
|
|
36
|
-
const urls = [
|
|
37
|
-
`https://raw.githubusercontent.com/${owner}/${repo}/main/openpkg.json`,
|
|
38
|
-
`https://raw.githubusercontent.com/${owner}/${repo}/master/openpkg.json`,
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
for (const url of urls) {
|
|
42
|
-
try {
|
|
43
|
-
const response = await fetch(url);
|
|
44
|
-
if (response.ok) {
|
|
45
|
-
return (await response.json()) as OpenPkg;
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
// Try next URL
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function buildLeaderboard(_category?: string): Promise<LeaderboardEntry[]> {
|
|
56
|
-
const entries: LeaderboardEntry[] = [];
|
|
57
|
-
|
|
58
|
-
// Fetch specs for all tracked repos
|
|
59
|
-
const fetchPromises = TRACKED_REPOS.map(async ({ owner, repo }) => {
|
|
60
|
-
const spec = await fetchSpecFromGitHub(owner, repo);
|
|
61
|
-
if (spec) {
|
|
62
|
-
const driftCount = spec.exports.reduce((count, exp) => {
|
|
63
|
-
return count + (exp.docs?.drift?.length ?? 0);
|
|
64
|
-
}, 0);
|
|
65
|
-
|
|
66
|
-
entries.push({
|
|
67
|
-
owner,
|
|
68
|
-
repo,
|
|
69
|
-
coverage: spec.docs?.coverageScore ?? 0,
|
|
70
|
-
exportCount: spec.exports.length,
|
|
71
|
-
driftCount,
|
|
72
|
-
lastUpdated: new Date().toISOString(),
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
await Promise.allSettled(fetchPromises);
|
|
78
|
-
|
|
79
|
-
// Sort by coverage descending
|
|
80
|
-
entries.sort((a, b) => b.coverage - a.coverage);
|
|
81
|
-
|
|
82
|
-
return entries;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// GET /leaderboard
|
|
86
|
-
leaderboardRoute.get('/', async (c) => {
|
|
87
|
-
const category = c.req.query('category');
|
|
88
|
-
const limit = Math.min(Number(c.req.query('limit')) || 100, 100);
|
|
89
|
-
|
|
90
|
-
const cacheKey = category ?? 'all';
|
|
91
|
-
const now = Date.now();
|
|
92
|
-
|
|
93
|
-
// Check cache
|
|
94
|
-
if (leaderboardCache.has(cacheKey) && now - lastCacheUpdate < CACHE_TTL) {
|
|
95
|
-
const cached = leaderboardCache.get(cacheKey) ?? [];
|
|
96
|
-
return c.json({
|
|
97
|
-
entries: cached.slice(0, limit),
|
|
98
|
-
total: cached.length,
|
|
99
|
-
category: category ?? 'all',
|
|
100
|
-
lastUpdated: new Date(lastCacheUpdate).toISOString(),
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const entries = await buildLeaderboard(category);
|
|
106
|
-
leaderboardCache.set(cacheKey, entries);
|
|
107
|
-
lastCacheUpdate = now;
|
|
108
|
-
|
|
109
|
-
return c.json({
|
|
110
|
-
entries: entries.slice(0, limit),
|
|
111
|
-
total: entries.length,
|
|
112
|
-
category: category ?? 'all',
|
|
113
|
-
lastUpdated: new Date().toISOString(),
|
|
114
|
-
});
|
|
115
|
-
} catch (error) {
|
|
116
|
-
return c.json(
|
|
117
|
-
{
|
|
118
|
-
error: 'Failed to fetch leaderboard',
|
|
119
|
-
message: error instanceof Error ? error.message : 'Unknown error',
|
|
120
|
-
},
|
|
121
|
-
500,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// GET /leaderboard/:owner/:repo
|
|
127
|
-
leaderboardRoute.get('/:owner/:repo', async (c) => {
|
|
128
|
-
const { owner, repo } = c.req.param();
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
const spec = await fetchSpecFromGitHub(owner, repo);
|
|
132
|
-
|
|
133
|
-
if (!spec) {
|
|
134
|
-
return c.json(
|
|
135
|
-
{
|
|
136
|
-
error: 'Not found',
|
|
137
|
-
message: `No openpkg.json found for ${owner}/${repo}`,
|
|
138
|
-
},
|
|
139
|
-
404,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const driftCount = spec.exports.reduce((count, exp) => {
|
|
144
|
-
return count + (exp.docs?.drift?.length ?? 0);
|
|
145
|
-
}, 0);
|
|
146
|
-
|
|
147
|
-
const missingDocs = spec.exports.filter((exp) => (exp.docs?.missing?.length ?? 0) > 0);
|
|
148
|
-
|
|
149
|
-
return c.json({
|
|
150
|
-
owner,
|
|
151
|
-
repo,
|
|
152
|
-
coverage: spec.docs?.coverageScore ?? 0,
|
|
153
|
-
exportCount: spec.exports.length,
|
|
154
|
-
driftCount,
|
|
155
|
-
missingDocsCount: missingDocs.length,
|
|
156
|
-
version: spec.meta.version,
|
|
157
|
-
name: spec.meta.name,
|
|
158
|
-
exports: spec.exports.map((exp) => ({
|
|
159
|
-
name: exp.name,
|
|
160
|
-
kind: exp.kind,
|
|
161
|
-
coverage: exp.docs?.coverageScore ?? 0,
|
|
162
|
-
driftCount: exp.docs?.drift?.length ?? 0,
|
|
163
|
-
missing: exp.docs?.missing ?? [],
|
|
164
|
-
})),
|
|
165
|
-
});
|
|
166
|
-
} catch (error) {
|
|
167
|
-
return c.json(
|
|
168
|
-
{
|
|
169
|
-
error: 'Failed to fetch repo',
|
|
170
|
-
message: error instanceof Error ? error.message : 'Unknown error',
|
|
171
|
-
},
|
|
172
|
-
500,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// POST /leaderboard/submit
|
|
178
|
-
// Allow repos to submit themselves to the leaderboard
|
|
179
|
-
leaderboardRoute.post('/submit', async (c) => {
|
|
180
|
-
try {
|
|
181
|
-
const body = await c.req.json<{ owner: string; repo: string }>();
|
|
182
|
-
const { owner, repo } = body;
|
|
183
|
-
|
|
184
|
-
if (!owner || !repo) {
|
|
185
|
-
return c.json({ error: 'Missing owner or repo' }, 400);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const spec = await fetchSpecFromGitHub(owner, repo);
|
|
189
|
-
|
|
190
|
-
if (!spec) {
|
|
191
|
-
return c.json(
|
|
192
|
-
{
|
|
193
|
-
error: 'Not found',
|
|
194
|
-
message: `No openpkg.json found for ${owner}/${repo}. Generate one with: doccov generate`,
|
|
195
|
-
},
|
|
196
|
-
404,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return c.json({
|
|
201
|
-
success: true,
|
|
202
|
-
message: `${owner}/${repo} added to leaderboard tracking`,
|
|
203
|
-
coverage: spec.docs?.coverageScore ?? 0,
|
|
204
|
-
});
|
|
205
|
-
} catch (error) {
|
|
206
|
-
return c.json(
|
|
207
|
-
{
|
|
208
|
-
error: 'Invalid request',
|
|
209
|
-
message: error instanceof Error ? error.message : 'Unknown error',
|
|
210
|
-
},
|
|
211
|
-
400,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
});
|
package/src/routes/widget.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import type { OpenPkg } from '@openpkg-ts/spec';
|
|
2
|
-
import { Hono } from 'hono';
|
|
3
|
-
import { fetchSpecFromGitHub } from '../utils/github';
|
|
4
|
-
|
|
5
|
-
export const widgetRoute = new Hono();
|
|
6
|
-
|
|
7
|
-
type SignalCoverage = {
|
|
8
|
-
description: number;
|
|
9
|
-
params: number;
|
|
10
|
-
returns: number;
|
|
11
|
-
examples: number;
|
|
12
|
-
overall: number;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function computeSignalCoverage(spec: OpenPkg): SignalCoverage {
|
|
16
|
-
const exports = spec.exports ?? [];
|
|
17
|
-
const signals = {
|
|
18
|
-
description: { covered: 0, total: 0 },
|
|
19
|
-
params: { covered: 0, total: 0 },
|
|
20
|
-
returns: { covered: 0, total: 0 },
|
|
21
|
-
examples: { covered: 0, total: 0 },
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
for (const exp of exports) {
|
|
25
|
-
const missing = exp.docs?.missing ?? [];
|
|
26
|
-
for (const sig of ['description', 'params', 'returns', 'examples'] as const) {
|
|
27
|
-
signals[sig].total++;
|
|
28
|
-
if (!missing.includes(sig)) signals[sig].covered++;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const pct = (s: { covered: number; total: number }) =>
|
|
33
|
-
s.total ? Math.round((s.covered / s.total) * 100) : 0;
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
description: pct(signals.description),
|
|
37
|
-
params: pct(signals.params),
|
|
38
|
-
returns: pct(signals.returns),
|
|
39
|
-
examples: pct(signals.examples),
|
|
40
|
-
overall: spec.docs?.coverageScore ?? 0,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function getColorForScore(score: number): string {
|
|
45
|
-
if (score >= 90) return '#4c1';
|
|
46
|
-
if (score >= 80) return '#97ca00';
|
|
47
|
-
if (score >= 70) return '#a4a61d';
|
|
48
|
-
if (score >= 60) return '#dfb317';
|
|
49
|
-
if (score >= 50) return '#fe7d37';
|
|
50
|
-
return '#e05d44';
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
type WidgetTheme = 'dark' | 'light';
|
|
54
|
-
|
|
55
|
-
interface WidgetOptions {
|
|
56
|
-
theme: WidgetTheme;
|
|
57
|
-
compact: boolean;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function generateWidgetSvg(stats: SignalCoverage, options: WidgetOptions): string {
|
|
61
|
-
const { theme, compact } = options;
|
|
62
|
-
const isDark = theme === 'dark';
|
|
63
|
-
|
|
64
|
-
const bg = isDark ? '#0d1117' : '#ffffff';
|
|
65
|
-
const fg = isDark ? '#c9d1d9' : '#24292f';
|
|
66
|
-
const border = isDark ? '#30363d' : '#d0d7de';
|
|
67
|
-
const barBg = isDark ? '#21262d' : '#eaeef2';
|
|
68
|
-
const accent = '#58a6ff';
|
|
69
|
-
|
|
70
|
-
const width = compact ? 160 : 200;
|
|
71
|
-
const rowHeight = 18;
|
|
72
|
-
const headerHeight = 28;
|
|
73
|
-
const padding = 8;
|
|
74
|
-
const barWidth = compact ? 60 : 80;
|
|
75
|
-
const labelWidth = compact ? 0 : 70;
|
|
76
|
-
|
|
77
|
-
const signals = [
|
|
78
|
-
{ key: 'description', label: 'desc', pct: stats.description },
|
|
79
|
-
{ key: 'params', label: 'params', pct: stats.params },
|
|
80
|
-
{ key: 'returns', label: 'returns', pct: stats.returns },
|
|
81
|
-
{ key: 'examples', label: 'examples', pct: stats.examples },
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
const height = headerHeight + signals.length * rowHeight + padding * 2;
|
|
85
|
-
|
|
86
|
-
const rows = signals
|
|
87
|
-
.map((s, i) => {
|
|
88
|
-
const y = headerHeight + padding + i * rowHeight;
|
|
89
|
-
const barFill = (s.pct / 100) * barWidth;
|
|
90
|
-
const color = getColorForScore(s.pct);
|
|
91
|
-
|
|
92
|
-
if (compact) {
|
|
93
|
-
return `
|
|
94
|
-
<g transform="translate(${padding}, ${y})">
|
|
95
|
-
<rect x="0" y="2" width="${barWidth}" height="12" rx="2" fill="${barBg}"/>
|
|
96
|
-
<rect x="0" y="2" width="${barFill}" height="12" rx="2" fill="${color}"/>
|
|
97
|
-
<text x="${barWidth + 6}" y="12" font-size="10" fill="${fg}">${s.pct}%</text>
|
|
98
|
-
</g>`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return `
|
|
102
|
-
<g transform="translate(${padding}, ${y})">
|
|
103
|
-
<text x="0" y="12" font-size="10" fill="${fg}">${s.label}</text>
|
|
104
|
-
<rect x="${labelWidth}" y="2" width="${barWidth}" height="12" rx="2" fill="${barBg}"/>
|
|
105
|
-
<rect x="${labelWidth}" y="2" width="${barFill}" height="12" rx="2" fill="${color}"/>
|
|
106
|
-
<text x="${labelWidth + barWidth + 6}" y="12" font-size="10" fill="${fg}">${s.pct}%</text>
|
|
107
|
-
</g>`;
|
|
108
|
-
})
|
|
109
|
-
.join('');
|
|
110
|
-
|
|
111
|
-
const overallColor = getColorForScore(stats.overall);
|
|
112
|
-
|
|
113
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
114
|
-
<rect width="${width}" height="${height}" rx="6" fill="${bg}" stroke="${border}" stroke-width="1"/>
|
|
115
|
-
<g transform="translate(${padding}, 0)">
|
|
116
|
-
<text x="0" y="18" font-size="12" font-weight="600" fill="${accent}">DocCov</text>
|
|
117
|
-
<text x="${width - padding * 2}" y="18" font-size="12" font-weight="600" fill="${overallColor}" text-anchor="end">${stats.overall}%</text>
|
|
118
|
-
</g>
|
|
119
|
-
<line x1="0" y1="${headerHeight}" x2="${width}" y2="${headerHeight}" stroke="${border}" stroke-width="1"/>
|
|
120
|
-
<style>text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }</style>
|
|
121
|
-
${rows}
|
|
122
|
-
</svg>`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function generateErrorSvg(message: string, theme: WidgetTheme): string {
|
|
126
|
-
const isDark = theme === 'dark';
|
|
127
|
-
const bg = isDark ? '#0d1117' : '#ffffff';
|
|
128
|
-
const fg = isDark ? '#c9d1d9' : '#24292f';
|
|
129
|
-
const border = isDark ? '#30363d' : '#d0d7de';
|
|
130
|
-
|
|
131
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="50" viewBox="0 0 160 50">
|
|
132
|
-
<rect width="160" height="50" rx="6" fill="${bg}" stroke="${border}" stroke-width="1"/>
|
|
133
|
-
<text x="80" y="20" font-size="12" font-weight="600" fill="#58a6ff" text-anchor="middle">DocCov</text>
|
|
134
|
-
<text x="80" y="36" font-size="10" fill="${fg}" text-anchor="middle">${message}</text>
|
|
135
|
-
<style>text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }</style>
|
|
136
|
-
</svg>`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// GET /widget/:owner/:repo
|
|
140
|
-
widgetRoute.get('/:owner/:repo', async (c) => {
|
|
141
|
-
const { owner, repo } = c.req.param();
|
|
142
|
-
const branch = c.req.query('branch') ?? 'main';
|
|
143
|
-
const theme = (c.req.query('theme') ?? 'dark') as WidgetTheme;
|
|
144
|
-
const compact = c.req.query('compact') === 'true';
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
const spec = await fetchSpecFromGitHub(owner, repo, branch);
|
|
148
|
-
|
|
149
|
-
if (!spec) {
|
|
150
|
-
const svg = generateErrorSvg('not found', theme);
|
|
151
|
-
return c.body(svg, 404, {
|
|
152
|
-
'Content-Type': 'image/svg+xml',
|
|
153
|
-
'Cache-Control': 'no-cache',
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const stats = computeSignalCoverage(spec);
|
|
158
|
-
const svg = generateWidgetSvg(stats, { theme, compact });
|
|
159
|
-
|
|
160
|
-
return c.body(svg, 200, {
|
|
161
|
-
'Content-Type': 'image/svg+xml',
|
|
162
|
-
'Cache-Control': 'public, max-age=300',
|
|
163
|
-
});
|
|
164
|
-
} catch {
|
|
165
|
-
const svg = generateErrorSvg('error', theme);
|
|
166
|
-
return c.body(svg, 500, {
|
|
167
|
-
'Content-Type': 'image/svg+xml',
|
|
168
|
-
'Cache-Control': 'no-cache',
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// GET /widget/:owner/:repo.svg (alias)
|
|
174
|
-
widgetRoute.get('/:owner/:repo.svg', async (c) => {
|
|
175
|
-
const { owner, repo } = c.req.param();
|
|
176
|
-
const repoName = repo.replace(/\.svg$/, '');
|
|
177
|
-
return c.redirect(`/widget/${owner}/${repoName}?${c.req.url.split('?')[1] ?? ''}`);
|
|
178
|
-
});
|