@doccov/api 0.2.0 → 0.2.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 +19 -0
- package/README.md +17 -128
- package/api/examples/run.ts +252 -0
- package/api/index.ts +218 -0
- package/api/scan/detect.ts +217 -0
- package/api/scan-stream.ts +470 -0
- package/api/scan.ts +64 -0
- package/package.json +6 -4
- package/src/index.ts +5 -8
- package/src/routes/badge.ts +2 -26
- package/src/routes/leaderboard.ts +1 -1
- package/src/routes/widget.ts +178 -0
- package/src/utils/github.ts +25 -0
- package/tsconfig.json +5 -9
- package/vercel.json +9 -5
package/api/scan.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
2
|
+
|
|
3
|
+
export const config = {
|
|
4
|
+
runtime: 'nodejs',
|
|
5
|
+
maxDuration: 10,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
interface ScanRequestBody {
|
|
9
|
+
url: string;
|
|
10
|
+
ref?: string;
|
|
11
|
+
package?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
15
|
+
// CORS
|
|
16
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
17
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
18
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
19
|
+
|
|
20
|
+
if (req.method === 'OPTIONS') {
|
|
21
|
+
return res.status(200).end();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (req.method !== 'POST') {
|
|
25
|
+
return res.status(405).json({ error: 'Method not allowed' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const body = req.body as ScanRequestBody;
|
|
29
|
+
|
|
30
|
+
if (!body.url) {
|
|
31
|
+
return res.status(400).json({ error: 'url is required' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parse GitHub URL
|
|
35
|
+
const urlMatch = body.url.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
36
|
+
if (!urlMatch) {
|
|
37
|
+
return res.status(400).json({ error: 'Invalid GitHub URL' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const [, owner, repoWithExt] = urlMatch;
|
|
41
|
+
const repo = repoWithExt.replace(/\.git$/, '');
|
|
42
|
+
const ref = body.ref ?? 'main';
|
|
43
|
+
|
|
44
|
+
// Generate a job ID
|
|
45
|
+
const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
46
|
+
|
|
47
|
+
// Build stream URL with params encoded
|
|
48
|
+
const params = new URLSearchParams({
|
|
49
|
+
url: body.url,
|
|
50
|
+
ref,
|
|
51
|
+
owner,
|
|
52
|
+
repo,
|
|
53
|
+
});
|
|
54
|
+
if (body.package) {
|
|
55
|
+
params.set('package', body.package);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Return job ID and stream URL with all params
|
|
59
|
+
return res.status(202).json({
|
|
60
|
+
jobId,
|
|
61
|
+
status: 'pending',
|
|
62
|
+
streamUrl: `/scan-stream?${params.toString()}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doccov/api",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DocCov API - Badge endpoint and coverage services",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"doccov",
|
|
@@ -27,14 +27,16 @@
|
|
|
27
27
|
"format": "biome format --write src/"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@openpkg-ts/spec": "^0.
|
|
30
|
+
"@openpkg-ts/spec": "^0.3.0",
|
|
31
31
|
"@vercel/sandbox": "^1.0.3",
|
|
32
32
|
"hono": "^4.0.0",
|
|
33
33
|
"ms": "^2.1.3"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/bun": "latest",
|
|
37
|
-
"@types/ms": "^0.7.34"
|
|
37
|
+
"@types/ms": "^0.7.34",
|
|
38
|
+
"@types/node": "^20.0.0",
|
|
39
|
+
"@vercel/node": "^3.0.0",
|
|
40
|
+
"typescript": "^5.0.0"
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
|
-
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { cors } from 'hono/cors';
|
|
|
3
3
|
import { badgeRoute } from './routes/badge';
|
|
4
4
|
import { leaderboardRoute } from './routes/leaderboard';
|
|
5
5
|
import { scanRoute } from './routes/scan';
|
|
6
|
+
import { widgetRoute } from './routes/widget';
|
|
6
7
|
|
|
7
8
|
const app = new Hono();
|
|
8
9
|
|
|
@@ -16,6 +17,7 @@ app.get('/', (c) => {
|
|
|
16
17
|
version: '0.2.0',
|
|
17
18
|
endpoints: {
|
|
18
19
|
badge: '/badge/:owner/:repo',
|
|
20
|
+
widget: '/widget/:owner/:repo',
|
|
19
21
|
leaderboard: '/leaderboard',
|
|
20
22
|
scan: '/scan',
|
|
21
23
|
health: '/health',
|
|
@@ -29,14 +31,9 @@ app.get('/health', (c) => {
|
|
|
29
31
|
|
|
30
32
|
// Routes
|
|
31
33
|
app.route('/badge', badgeRoute);
|
|
34
|
+
app.route('/widget', widgetRoute);
|
|
32
35
|
app.route('/leaderboard', leaderboardRoute);
|
|
33
36
|
app.route('/scan', scanRoute);
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
console.log(`DocCov API running on http://localhost:${port}`);
|
|
38
|
-
|
|
39
|
-
export default {
|
|
40
|
-
port,
|
|
41
|
-
fetch: app.fetch,
|
|
42
|
-
};
|
|
38
|
+
// Vercel serverless handler + Bun auto-serves this export
|
|
39
|
+
export default app;
|
package/src/routes/badge.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OpenPkg } from '@openpkg-ts/spec';
|
|
2
1
|
import { Hono } from 'hono';
|
|
2
|
+
import { fetchSpecFromGitHub } from '../utils/github';
|
|
3
3
|
|
|
4
4
|
export const badgeRoute = new Hono();
|
|
5
5
|
|
|
@@ -70,30 +70,6 @@ function generateBadgeSvg(options: BadgeOptions): string {
|
|
|
70
70
|
</svg>`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async function fetchSpecFromGitHub(
|
|
74
|
-
owner: string,
|
|
75
|
-
repo: string,
|
|
76
|
-
branch = 'main',
|
|
77
|
-
): Promise<OpenPkg | null> {
|
|
78
|
-
const urls = [
|
|
79
|
-
`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/openpkg.json`,
|
|
80
|
-
`https://raw.githubusercontent.com/${owner}/${repo}/master/openpkg.json`,
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
for (const url of urls) {
|
|
84
|
-
try {
|
|
85
|
-
const response = await fetch(url);
|
|
86
|
-
if (response.ok) {
|
|
87
|
-
return (await response.json()) as OpenPkg;
|
|
88
|
-
}
|
|
89
|
-
} catch {
|
|
90
|
-
// Try next URL
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
73
|
// GET /badge/:owner/:repo
|
|
98
74
|
badgeRoute.get('/:owner/:repo', async (c) => {
|
|
99
75
|
const { owner, repo } = c.req.param();
|
|
@@ -126,7 +102,7 @@ badgeRoute.get('/:owner/:repo', async (c) => {
|
|
|
126
102
|
'Content-Type': 'image/svg+xml',
|
|
127
103
|
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
|
128
104
|
});
|
|
129
|
-
} catch
|
|
105
|
+
} catch {
|
|
130
106
|
const svg = generateBadgeSvg({
|
|
131
107
|
label: 'docs',
|
|
132
108
|
message: 'error',
|
|
@@ -52,7 +52,7 @@ async function fetchSpecFromGitHub(owner: string, repo: string): Promise<OpenPkg
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
async function buildLeaderboard(
|
|
55
|
+
async function buildLeaderboard(_category?: string): Promise<LeaderboardEntry[]> {
|
|
56
56
|
const entries: LeaderboardEntry[] = [];
|
|
57
57
|
|
|
58
58
|
// Fetch specs for all tracked repos
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OpenPkg } from '@openpkg-ts/spec';
|
|
2
|
+
|
|
3
|
+
export async function fetchSpecFromGitHub(
|
|
4
|
+
owner: string,
|
|
5
|
+
repo: string,
|
|
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
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -3,17 +3,13 @@
|
|
|
3
3
|
"target": "ES2022",
|
|
4
4
|
"module": "ESNext",
|
|
5
5
|
"moduleResolution": "bundler",
|
|
6
|
-
"strict": true,
|
|
7
6
|
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
8
|
"skipLibCheck": true,
|
|
9
|
-
"forceConsistentCasingInFileNames": true,
|
|
10
9
|
"resolveJsonModule": true,
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"outDir": "./dist",
|
|
14
|
-
"rootDir": "./src"
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"noEmit": true
|
|
15
12
|
},
|
|
16
|
-
"include": ["src/**/*"],
|
|
17
|
-
"exclude": ["node_modules"
|
|
13
|
+
"include": ["api/**/*", "src/**/*"],
|
|
14
|
+
"exclude": ["node_modules"]
|
|
18
15
|
}
|
|
19
|
-
|
package/vercel.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
2
|
+
"rewrites": [
|
|
3
|
+
{ "source": "/badge/:path*", "destination": "/api" },
|
|
4
|
+
{ "source": "/spec/:path*", "destination": "/api" },
|
|
5
|
+
{ "source": "/scan-stream", "destination": "/api/scan-stream" },
|
|
6
|
+
{ "source": "/scan/detect", "destination": "/api/scan/detect" },
|
|
7
|
+
{ "source": "/scan", "destination": "/api/scan" },
|
|
8
|
+
{ "source": "/(.*)", "destination": "/api" }
|
|
9
|
+
]
|
|
10
|
+
}
|