@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 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
@@ -1,4 +1,13 @@
1
- import { Writable } from 'node:stream';
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 detectMonorepo(body.url, body.ref ?? 'main');
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
- async function detectMonorepo(url: string, _ref: string): Promise<DetectResponse> {
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
- // List root files
87
- const lsCapture = createCaptureStream();
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
- // Check for workspaces (npm/yarn/bun) or pnpm-workspace.yaml
121
- let workspacePatterns: string[] = [];
89
+ // Use SDK detection functions
90
+ const [monoInfo, pmInfo] = await Promise.all([
91
+ sdkDetectMonorepo(fs),
92
+ detectPackageManager(fs),
93
+ ]);
122
94
 
123
- if (rootPkg.workspaces) {
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
- // Find all packages
162
- const packages: PackageInfo[] = [];
163
-
164
- // Use find to locate package.json files in workspace dirs
165
- const findCapture = createCaptureStream();
166
- await sandbox.runCommand({
167
- cmd: 'find',
168
- args: ['.', '-name', 'package.json', '-maxdepth', '3', '-type', 'f'],
169
- stdout: findCapture.stream,
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
  };
@@ -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
- const urlMatch = body.url.match(/github\.com\/([^/]+)\/([^/]+)/);
36
- if (!urlMatch) {
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.0",
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.4.1",
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.2.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
@@ -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;
@@ -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 interface ScanResult {
8
- owner: string;
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;
@@ -1,25 +1,5 @@
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
- }
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
- });
@@ -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
- });