@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 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
@@ -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
  };
@@ -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: ['fetch', '--depth', '1', 'origin', `refs/tags/${options.ref}:refs/tags/${options.ref}`],
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 ? `Detected ${pmInfo.name} project` : 'No lockfile detected';
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
- 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.2.3",
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.4.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.2.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
+ }
@@ -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
- const body = await c.req.json<ScanRequestBody>();
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
- if (!body.url) {
23
- return c.json({ error: 'url is required' }, 400);
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
 
@@ -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,22 +2,11 @@
2
2
  * Scan job store and caching layer
3
3
  */
4
4
 
5
- export interface ScanResult {
6
- owner: string;
7
- repo: string;
8
- ref: string;
9
- packageName?: string;
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
+ }
@@ -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
- });