@doccov/api 0.3.0 → 0.3.2
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 +15 -0
- package/api/scan/detect.ts +34 -130
- package/api/scan-stream.ts +1 -17
- package/api/scan.ts +9 -10
- package/package.json +2 -2
- package/src/index.ts +1 -7
- package/src/sandbox-runner.ts +1 -1
- package/src/scan-worker.ts +3 -16
- 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,20 @@
|
|
|
1
1
|
# @doccov/api
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- @openpkg-ts/spec@0.6.0
|
|
9
|
+
|
|
10
|
+
## 0.3.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- consolidate api routes to use unified SDK modules.
|
|
15
|
+
- Updated dependencies
|
|
16
|
+
- @openpkg-ts/spec@0.5.0
|
|
17
|
+
|
|
3
18
|
## 0.3.0
|
|
4
19
|
|
|
5
20
|
### Minor 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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getInstallCommand,
|
|
7
7
|
getPrimaryBuildScript,
|
|
8
8
|
SandboxFileSystem,
|
|
9
|
+
type ScanResult,
|
|
9
10
|
} from '@doccov/sdk';
|
|
10
11
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
11
12
|
import { Sandbox } from '@vercel/sandbox';
|
|
@@ -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 = '';
|
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.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "DocCov API - Badge endpoint and coverage services",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"doccov",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"format": "biome format --write src/"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@openpkg-ts/spec": "^0.
|
|
30
|
+
"@openpkg-ts/spec": "^0.6.0",
|
|
31
31
|
"@vercel/sandbox": "^1.0.3",
|
|
32
32
|
"hono": "^4.0.0",
|
|
33
33
|
"ms": "^2.1.3",
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,7 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
3
|
import { rateLimit } from './middleware/rate-limit';
|
|
4
4
|
import { badgeRoute } from './routes/badge';
|
|
5
|
-
import { leaderboardRoute } from './routes/leaderboard';
|
|
6
5
|
import { scanRoute } from './routes/scan';
|
|
7
|
-
import { widgetRoute } from './routes/widget';
|
|
8
6
|
|
|
9
7
|
const app = new Hono();
|
|
10
8
|
|
|
@@ -25,11 +23,9 @@ app.use(
|
|
|
25
23
|
app.get('/', (c) => {
|
|
26
24
|
return c.json({
|
|
27
25
|
name: 'DocCov API',
|
|
28
|
-
version: '0.
|
|
26
|
+
version: '0.3.0',
|
|
29
27
|
endpoints: {
|
|
30
28
|
badge: '/badge/:owner/:repo',
|
|
31
|
-
widget: '/widget/:owner/:repo',
|
|
32
|
-
leaderboard: '/leaderboard',
|
|
33
29
|
scan: '/scan',
|
|
34
30
|
health: '/health',
|
|
35
31
|
},
|
|
@@ -42,8 +38,6 @@ app.get('/health', (c) => {
|
|
|
42
38
|
|
|
43
39
|
// Routes
|
|
44
40
|
app.route('/badge', badgeRoute);
|
|
45
|
-
app.route('/widget', widgetRoute);
|
|
46
|
-
app.route('/leaderboard', leaderboardRoute);
|
|
47
41
|
app.route('/scan', scanRoute);
|
|
48
42
|
|
|
49
43
|
// Vercel serverless handler + Bun auto-serves this export
|
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,24 +2,11 @@
|
|
|
2
2
|
* Scan job store and caching layer
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { ScanResult } from '@doccov/sdk';
|
|
5
6
|
import type { JobStore } from './stores/job-store.interface';
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
repo: string;
|
|
10
|
-
ref: string;
|
|
11
|
-
packageName?: string;
|
|
12
|
-
coverage: number;
|
|
13
|
-
exportCount: number;
|
|
14
|
-
typeCount: number;
|
|
15
|
-
driftCount: number;
|
|
16
|
-
undocumented: string[];
|
|
17
|
-
drift: Array<{
|
|
18
|
-
export: string;
|
|
19
|
-
type: string;
|
|
20
|
-
issue: string;
|
|
21
|
-
}>;
|
|
22
|
-
}
|
|
8
|
+
// Re-export ScanResult for backwards compatibility
|
|
9
|
+
export type { ScanResult } from '@doccov/sdk';
|
|
23
10
|
|
|
24
11
|
export interface ScanJob {
|
|
25
12
|
id: string;
|
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
|
-
});
|