forked from assafbar2/agentswitchboard.dev
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate-content.ts
More file actions
138 lines (125 loc) · 4.54 KB
/
Copy pathvalidate-content.ts
File metadata and controls
138 lines (125 loc) · 4.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/**
* validate-content.ts — the quality bar, enforced as code.
*
* Validates every file in content/ against the directory's editorial rules.
* Runs in CI on every PR; a bad entry physically cannot merge.
*
* Run: npx tsx scripts/validate-content.ts
* Exit 1 on any violation.
*/
import * as fs from 'fs';
import * as path from 'path';
import { z } from 'zod';
const CONTENT = path.resolve(process.cwd(), 'content');
const categories = JSON.parse(fs.readFileSync(path.join(CONTENT, 'categories.json'), 'utf8'));
const validCategorySlugs = new Set<string>(categories.map((c: { slug: string }) => c.slug));
const GENERIC_TAGS = new Set(['ai', 'tool', 'tools', 'automation', 'agent', 'agents']);
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const SkillSchema = z.object({
id: z.string().regex(SLUG, 'skill id must be kebab-case'),
name: z.string().min(2),
description: z.string().min(20).max(200),
inputSchema: z.unknown().optional(),
outputSchema: z.unknown().optional(),
});
const AgentSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
slug: z.string().regex(SLUG, 'slug must be kebab-case'),
description: z.string().min(30).max(200, 'description hard limit is 200 chars'),
longDescription: z.unknown().optional(),
providerName: z.string().min(1),
providerUrl: z.string().url().startsWith('http'),
version: z.string().optional(),
agentUrl: z.string().url().startsWith('http'),
wellKnownUrl: z.string().url().optional(),
agentCardJson: z.string().optional(),
categories: z
.array(z.string().refine((s) => validCategorySlugs.has(s), (s) => ({ message: `unknown category "${s}"` })))
.min(1)
.max(3),
tags: z.array(
z
.string()
.regex(SLUG, 'tags must be kebab-case')
.refine((t) => !GENERIC_TAGS.has(t), (t) => ({ message: `tag "${t}" is too generic` }))
),
skills: z.array(SkillSchema),
authType: z.enum(['apiKey', 'oauth2', 'bearer', 'none']),
authInstructions: z.unknown().optional(),
integrationGuide: z.unknown().optional(),
supportsStreaming: z.boolean(),
supportsPushNotifications: z.boolean(),
iconUrl: z.string().url().optional(),
status: z.enum(['published', 'draft', 'archived']),
featured: z.boolean(),
featuredUntil: z.string().optional(),
verified: z.boolean(),
referralUrl: z.string().optional(),
sponsorLabel: z.string().optional(),
tier: z.enum(['free', 'premium']),
discoveredBy: z.enum(['manual', 'worker']),
workerSource: z.string().optional(),
accessMethods: z.array(z.enum(['api', 'mcp', 'cli', 'browser-extension'])),
createdAt: z.string(),
updatedAt: z.string(),
})
.strict();
function main() {
const dir = path.join(CONTENT, 'agents');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
let errors = 0;
const slugs = new Set<string>();
// Auxiliary content files aren't schema-validated, but they ARE parsed at
// build time (e.g. /changelog does JSON.parse on changelog.json). A syntax
// error here used to pass CI and only fail `next build` on Vercel — guard it.
for (const aux of ['changelog.json', 'categories.json', 'site.json']) {
const p = path.join(CONTENT, aux);
if (!fs.existsSync(p)) continue;
try {
JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (e) {
console.log(`❌ content/${aux}: invalid JSON — ${(e as Error).message}`);
errors++;
}
}
for (const file of files) {
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
console.log(`❌ ${file}: invalid JSON`);
errors++;
continue;
}
const result = AgentSchema.safeParse(data);
if (!result.success) {
for (const issue of result.error.issues) {
console.log(`❌ ${file}: ${issue.path.join('.')}: ${issue.message}`);
errors++;
}
continue;
}
// filename must match slug; slugs must be unique
const slug = result.data.slug;
if (file !== `${slug}.json`) {
console.log(`❌ ${file}: filename does not match slug "${slug}"`);
errors++;
}
if (slugs.has(slug)) {
console.log(`❌ ${file}: duplicate slug "${slug}"`);
errors++;
}
slugs.add(slug);
}
const published = files.length;
if (errors === 0) {
console.log(`✅ content valid: ${published} agents, ${validCategorySlugs.size} categories, 0 violations`);
} else {
console.log(`\n❌ ${errors} violation(s) across content/ — fix before merging.`);
process.exit(1);
}
}
main();