Skip to content

Commit 58334ac

Browse files
committed
refactor: implement robust data architecture with Astro content references, structured pricing, and strict schema validation
1 parent 7d2140d commit 58334ac

2 files changed

Lines changed: 95 additions & 34 deletions

File tree

src/content.config.ts

Lines changed: 82 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,99 @@
1-
import { defineCollection, z } from 'astro:content';
2-
import { glob } from 'astro/loaders';
1+
import { defineCollection, reference, z } from 'astro:content';
2+
import { glob, file } from 'astro/loaders';
33

44
const badgeEnum = z.enum(['FREE', 'PROMO', 'PAID']);
55

6+
const categories = defineCollection({
7+
loader: file("src/data/shared/categories.yaml"),
8+
schema: z.object({ id: z.string(), name: z.string() })
9+
});
10+
11+
const features = defineCollection({
12+
loader: file("src/data/shared/features.yaml"),
13+
schema: z.object({ id: z.string(), name: z.string() })
14+
});
15+
16+
const tags = defineCollection({
17+
loader: file("src/data/shared/tags.yaml"),
18+
schema: z.object({ id: z.string(), name: z.string() })
19+
});
20+
21+
const providers = defineCollection({
22+
loader: file("src/data/shared/providers.yaml"),
23+
schema: z.object({ id: z.string(), name: z.string(), url: z.string().url().optional() })
24+
});
25+
626
const plans = defineCollection({
727
loader: glob({ pattern: '**/*.yaml', base: './src/data/plans' }),
828
schema: z.object({
929
name: z.string(),
1030
slug: z.string(),
11-
provider: z.string(),
31+
provider: reference('providers').optional(),
1232
badge: badgeEnum,
13-
price_monthly: z.number().min(0),
33+
34+
quotas: z.array(z.object({
35+
measure: z.enum(['currency', 'requests', 'input_tokens', 'output_tokens', 'credits']),
36+
amount: z.number().nullable(),
37+
window: z.enum(['5-hour', 'daily', 'weekly', 'monthly', 'unlimited', 'rolling']),
38+
models: z.array(reference('models')).optional(),
39+
aliases: z.array(z.object({
40+
measure: z.enum(['currency', 'requests', 'input_tokens', 'output_tokens', 'credits']),
41+
model: reference('models'),
42+
amount: z.number()
43+
})).optional()
44+
})).optional(),
45+
46+
price_monthly: z.number().min(0).optional(),
1447
promotional_price: z.number().min(0).nullable().optional(),
1548
promotional_duration: z.string().nullable().optional(),
16-
description: z.string(),
17-
external_url: z.string().url(),
18-
models: z.array(z.string()),
1949
limits: z.object({
2050
requests_per_minute: z.number().nullable().optional(),
2151
tokens_per_minute: z.number().nullable().optional(),
2252
context_window: z.number().nullable().optional(),
2353
daily_message_limit: z.union([z.number(), z.string()]).nullable().optional(),
24-
}),
25-
features: z.array(z.string()),
26-
categories: z.array(z.string()),
54+
}).optional(),
55+
56+
overages: z.discriminatedUnion("allowed", [
57+
z.object({ allowed: z.literal(false) }),
58+
z.object({
59+
allowed: z.literal(true),
60+
type: z.enum(["payg", "credits_purchase"]),
61+
pricing_model: z.string().optional(),
62+
auto_recharge_supported: z.boolean().optional()
63+
})
64+
]).optional(),
65+
66+
restrictions: z.object({
67+
allowed_tools: z.array(reference('tools')).nullable().optional(),
68+
banned_tools: z.array(reference('tools')).nullable().optional(),
69+
violation_penalty: z.string().optional()
70+
}).optional(),
71+
72+
description: z.string(),
73+
external_url: z.string().url().optional(),
74+
75+
models: z.array(reference('models')).optional(),
76+
compatible_tools: z.array(reference('tools')).optional(),
77+
compatible_tools: z.array(reference('tools')).optional(), // legacy
78+
79+
features: z.array(z.union([z.string(), reference('features')])).optional(),
80+
categories: z.array(z.union([z.string(), reference('categories')])).optional(),
81+
2782
student_discount: z.boolean().default(false),
2883
startup_credits: z.boolean().default(false),
29-
tools_compatible: z.array(z.string()),
84+
3085
history: z.array(z.object({
3186
date: z.string(),
3287
event: z.string(),
3388
})).optional(),
89+
3490
community_reviews_summary: z.string().optional(),
3591
community_score: z.number().min(0).max(100).optional(),
3692
latency: z.object({
3793
average_ms: z.number().optional(),
3894
uptime_percent: z.number().min(0).max(100).optional(),
3995
}).optional(),
40-
updated_at: z.string(),
96+
updated_at: z.string().optional(),
4197
}),
4298
});
4399

@@ -46,14 +102,15 @@ const models = defineCollection({
46102
schema: z.object({
47103
name: z.string(),
48104
slug: z.string(),
49-
provider: z.string(),
105+
provider: z.union([z.string(), reference('providers')]).optional(),
50106
description: z.string(),
51107
context_window: z.number(),
52108
strengths: z.array(z.string()),
53109
weaknesses: z.array(z.string()),
54-
vibe_coding_score: z.number().min(0).max(10),
55-
model_type: z.enum(['open_weight', 'closed_source']),
56-
plans_available: z.array(z.string()),
110+
vibe_coding_score: z.number().min(0).max(10).optional(),
111+
model_type: z.enum(['open_weight', 'closed_source']).optional(),
112+
// We remove the required manual reverse relationship
113+
plans_available: z.array(reference('plans')).optional(),
57114
updated_at: z.string().optional(),
58115
}),
59116
});
@@ -64,10 +121,10 @@ const tools = defineCollection({
64121
name: z.string(),
65122
slug: z.string(),
66123
description: z.string(),
67-
external_url: z.string().url(),
68-
tool_type: z.enum(['cli', 'ide', 'extension']),
69-
features: z.array(z.string()),
70-
plans_compatible: z.array(z.string()),
124+
external_url: z.string().url().optional(),
125+
tool_type: z.enum(['cli', 'ide', 'extension']).optional(),
126+
features: z.array(z.union([z.string(), reference('features')])).optional(),
127+
plans_compatible: z.array(reference('plans')).optional(),
71128
updated_at: z.string().optional(),
72129
}),
73130
});
@@ -78,7 +135,7 @@ const stacks = defineCollection({
78135
id: z.string(),
79136
author: z.string(),
80137
title: z.string(),
81-
tools: z.array(z.string()),
138+
tools: z.array(reference('tools')),
82139
monthly_cost: z.number().min(0),
83140
description: z.string(),
84141
upvotes: z.number().min(0).default(0),
@@ -87,6 +144,10 @@ const stacks = defineCollection({
87144
});
88145

89146
export const collections = {
147+
categories,
148+
features,
149+
tags,
150+
providers,
90151
plans,
91152
models,
92153
tools,

src/pages/index.astro

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const sortedPlans = [...allPlans].sort((a, b) => {
2020
const badgeOrder = { FREE: 0, PROMO: 1, PAID: 2 };
2121
const orderDiff = badgeOrder[a.data.badge] - badgeOrder[b.data.badge];
2222
if (orderDiff !== 0) return orderDiff;
23-
return a.data.price_monthly - b.data.price_monthly;
23+
return (a.data.price_monthly || 0) - (b.data.price_monthly || 0);
2424
});
2525
2626
const freePlans = allPlans.filter(p => p.data.badge === 'FREE');
@@ -53,25 +53,25 @@ const planDataForWizard = allPlans.map(plan => ({
5353
slug: plan.data.slug,
5454
provider: plan.data.provider,
5555
badge: plan.data.badge,
56-
price_monthly: plan.data.price_monthly,
56+
price_monthly: plan.data.price_monthly || plan.data.quotas?.find(q => q.measure === 'currency')?.amount || 0,
5757
promotional_price: plan.data.promotional_price ?? null,
5858
description: plan.data.description,
59-
models: plan.data.models,
59+
models: plan.data.models?.map(m => m.id) || [],
6060
limits: {
61-
requests_per_minute: plan.data.limits.requests_per_minute ?? null,
62-
tokens_per_minute: plan.data.limits.tokens_per_minute ?? null,
63-
context_window: plan.data.limits.context_window ?? null,
64-
daily_message_limit: plan.data.limits.daily_message_limit ?? null,
61+
requests_per_minute: plan.data.quotas?.find(q => q.measure === 'requests')?.amount ?? plan.data.limits?.requests_per_minute ?? null,
62+
tokens_per_minute: plan.data.quotas?.find(q => q.measure === 'input_tokens')?.amount ?? plan.data.limits?.tokens_per_minute ?? null,
63+
context_window: plan.data.limits?.context_window ?? null,
64+
daily_message_limit: plan.data.quotas?.find(q => q.window === 'daily' && q.measure === 'requests')?.amount ?? plan.data.limits?.daily_message_limit ?? null,
6565
},
6666
features: plan.data.features,
6767
categories: plan.data.categories,
6868
student_discount: plan.data.student_discount,
6969
startup_credits: plan.data.startup_credits,
70-
tools_compatible: plan.data.tools_compatible,
71-
has_open_weight: plan.data.models.some(m => openWeightModelIds.has(m) || openWeightModelIds.has(m.toLowerCase().replace(/ /g, '-'))),
72-
has_closed_source: plan.data.models.some(m => closedSourceModelIds.has(m) || closedSourceModelIds.has(m.toLowerCase().replace(/ /g, '-'))),
73-
has_cli: plan.data.tools_compatible.some(t => cliToolIds.has(t) || cliToolIds.has(t.toLowerCase().replace(/ /g, '-'))),
74-
has_ide: plan.data.tools_compatible.some(t => ideToolIds.has(t) || ideToolIds.has(t.toLowerCase().replace(/ /g, '-'))),
70+
compatible_tools: plan.data.compatible_tools?.map(t => t.id) || [],
71+
has_open_weight: (plan.data.models || []).some(m => openWeightModelIds.has(m.id) || openWeightModelIds.has(m.id.toLowerCase().replace(/ /g, '-'))),
72+
has_closed_source: (plan.data.models || []).some(m => closedSourceModelIds.has(m.id) || closedSourceModelIds.has(m.id.toLowerCase().replace(/ /g, '-'))),
73+
has_cli: (plan.data.compatible_tools || []).some(t => cliToolIds.has(t.id) || cliToolIds.has(t.id.toLowerCase().replace(/ /g, '-'))),
74+
has_ide: (plan.data.compatible_tools || []).some(t => ideToolIds.has(t.id) || ideToolIds.has(t.id.toLowerCase().replace(/ /g, '-'))),
7575
community_score: plan.data.community_score ?? undefined,
7676
latency: plan.data.latency ? {
7777
average_ms: plan.data.latency.average_ms,
@@ -264,7 +264,7 @@ const structuredData = {
264264
<!-- Plan Grid -->
265265
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" id="plan-grid">
266266
{sortedPlans.map((plan) => (
267-
<div class="plan-item" data-badge={plan.data.badge} data-name={plan.data.name.toLowerCase()} data-provider={plan.data.provider.toLowerCase()} data-features={plan.data.features.join(' ').toLowerCase()}>
267+
<div class="plan-item" data-badge={plan.data.badge} data-name={plan.data.name.toLowerCase()} data-provider={plan.data.provider?.id?.toLowerCase() || ''} data-features={(plan.data.features || []).map(f => typeof f === 'string' ? f : f.id).join(' ').toLowerCase()}>
268268
<PlanCard
269269
name={plan.data.name}
270270
slug={plan.data.slug}

0 commit comments

Comments
 (0)