Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions data/contributions.json
Original file line number Diff line number Diff line change
@@ -1,42 +1,72 @@
[
{
"id": "contrib-001",
"contributorName": "Ari Kim",
"type": "docs",
"title": "Clarified bootstrap troubleshooting section",
"type": "docs",
"description": "Expanded the bootstrap troubleshooting guide with specific fix paths for missing environment variables and package manager mismatch errors.",
"author": "Ari Kim",
"status": "merged",
"submittedAt": "2026-02-05T18:30:00.000Z"
"url": "https://github.com/reflectt/foragents.dev/pull/412",
"createdAt": "2026-02-05T18:30:00.000Z"
},
{
"id": "contrib-002",
"contributorName": "Mateo Singh",
"type": "testing",
"title": "Added route-level tests for API error handling",
"type": "testing",
"description": "Introduced integration tests for 400/500 edge cases across submission routes and standardized response snapshots.",
"author": "Mateo Singh",
"status": "approved",
"submittedAt": "2026-02-06T09:15:00.000Z"
"url": "https://github.com/reflectt/foragents.dev/pull/419",
"createdAt": "2026-02-06T09:15:00.000Z"
},
{
"id": "contrib-003",
"contributorName": "Lina Park",
"type": "design",
"title": "Updated contribution cards for mobile readability",
"type": "design",
"description": "Adjusted card spacing, heading sizes, and badge wrapping behavior for narrow viewports under 380px width.",
"author": "Lina Park",
"status": "pending",
"submittedAt": "2026-02-06T22:05:00.000Z"
"url": "https://github.com/reflectt/foragents.dev/pull/423",
"createdAt": "2026-02-06T22:05:00.000Z"
},
{
"id": "contrib-004",
"contributorName": "Owen Patel",
"type": "skills",
"title": "Published skill template for CLI-based workflows",
"type": "skills",
"description": "Created a reusable skill template with strict input/output contracts for command-line execution and recovery paths.",
"author": "Owen Patel",
"status": "merged",
"submittedAt": "2026-02-07T14:42:00.000Z"
"url": "https://github.com/reflectt/foragents.dev/pull/427",
"createdAt": "2026-02-07T14:42:00.000Z"
},
{
"id": "contrib-005",
"contributorName": "Nora Alvarez",
"type": "community",
"title": "Triaged onboarding questions into common-answer pack",
"type": "community",
"description": "Collected top onboarding questions from community channels and converted them into curated, link-backed answers.",
"author": "Nora Alvarez",
"status": "pending",
"submittedAt": "2026-02-08T01:20:00.000Z"
"url": "https://github.com/reflectt/foragents.dev/issues/430",
"createdAt": "2026-02-08T01:20:00.000Z"
},
{
"id": "contrib-006",
"title": "Localized quickstart into Portuguese (pt-BR)",
"type": "translations",
"description": "Translated quickstart and submission docs into pt-BR and added terminology notes for maintainers.",
"author": "Camila Souza",
"status": "approved",
"url": "https://github.com/reflectt/foragents.dev/pull/434",
"createdAt": "2026-02-08T16:48:00.000Z"
},
{
"id": "contrib-007",
"title": "Improved docs linking for contribution paths",
"type": "docs",
"description": "Connected contribution guide cards to related docs pages and removed outdated references from onboarding copy.",
"author": "Devon Clarke",
"status": "merged",
"url": "https://github.com/reflectt/foragents.dev/pull/438",
"createdAt": "2026-02-09T11:12:00.000Z"
}
]
153 changes: 43 additions & 110 deletions src/app/api/contribute/route.ts
Original file line number Diff line number Diff line change
@@ -1,105 +1,57 @@
import { promises as fs } from "fs";
import path from "path";
import { NextRequest, NextResponse } from "next/server";

type GuideCategory = "skills" | "docs" | "testing" | "design" | "translations" | "community";
type GuideDifficulty = "beginner" | "intermediate" | "advanced";
type ContributionStatus = "pending" | "approved" | "merged";

type ContributionGuide = {
id: string;
title: string;
description: string;
category: GuideCategory;
difficulty: GuideDifficulty;
estimatedTime: string;
steps: string[];
};

type Contribution = {
id: string;
contributorName: string;
type: string;
title: string;
status: ContributionStatus;
submittedAt: string;
};

const GUIDES_PATH = path.join(process.cwd(), "data", "contribution-guides.json");
const CONTRIBUTIONS_PATH = path.join(process.cwd(), "data", "contributions.json");
const VALID_TYPES: GuideCategory[] = ["skills", "docs", "testing", "design", "translations", "community"];

async function readGuides(): Promise<ContributionGuide[]> {
const raw = await fs.readFile(GUIDES_PATH, "utf-8");
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as ContributionGuide[]) : [];
}

async function readContributions(): Promise<Contribution[]> {
try {
const raw = await fs.readFile(CONTRIBUTIONS_PATH, "utf-8");
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as Contribution[]) : [];
} catch {
return [];
}
}

async function writeContributions(contributions: Contribution[]) {
await fs.mkdir(path.dirname(CONTRIBUTIONS_PATH), { recursive: true });
const tmpPath = `${CONTRIBUTIONS_PATH}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(contributions, null, 2), "utf-8");
await fs.rename(tmpPath, CONTRIBUTIONS_PATH);
}

function isValidType(type: string): type is GuideCategory {
return VALID_TYPES.includes(type as GuideCategory);
}

function normalizeTitle(title: string, description: string) {
if (title.trim()) {
return title.trim().slice(0, 120);
}

const fallback = description.trim().slice(0, 80);
return fallback.length > 0 ? fallback : "New contribution";
}

export async function GET() {
import {
createContribution,
filterContributions,
getValidFilters,
readContributionGuides,
readContributions,
writeContributions,
} from "@/lib/contribute";

export async function GET(request: NextRequest) {
try {
const [guides, contributions] = await Promise.all([readGuides(), readContributions()]);

const recentContributions = [...contributions]
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.slice(0, 12);
const params = request.nextUrl.searchParams;
const filters = getValidFilters({
type: params.get("type") ?? undefined,
status: params.get("status") ?? undefined,
author: params.get("author") ?? undefined,
search: params.get("search") ?? undefined,
limit: params.get("limit") ? Number(params.get("limit")) : undefined,
});

const [guides, contributions] = await Promise.all([readContributionGuides(), readContributions()]);
const filtered = filterContributions(contributions, filters);

return NextResponse.json(
{
guides,
recentContributions,
contributions: filtered,
recentContributions: filtered,
},
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch {
return NextResponse.json({ error: "Failed to load contribution data." }, { status: 500 });
} catch (error) {
const details = error instanceof Error ? error.message : "Failed to load contribution data.";
return NextResponse.json({ error: "Failed to load contribution data.", details }, { status: 400 });
}
}

export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as Record<string, unknown>;

const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim() : "";
const typeRaw = typeof body.type === "string" ? body.type.trim().toLowerCase() : "";
const description = typeof body.description === "string" ? body.description.trim() : "";
const name = typeof body.name === "string" ? body.name : "";
const email = typeof body.email === "string" ? body.email : "";
const type = typeof body.type === "string" ? body.type : "";
const title = typeof body.title === "string" ? body.title : "";
const description = typeof body.description === "string" ? body.description : "";
const url = typeof body.url === "string" ? body.url : "";

if (!name || !email || !typeRaw || !description) {
if (!name.trim() || !email.trim() || !type.trim() || !description.trim()) {
return NextResponse.json(
{
error: "Validation failed",
Expand All @@ -109,34 +61,14 @@ export async function POST(request: NextRequest) {
);
}

if (!/^\S+@\S+\.\S+$/.test(email)) {
return NextResponse.json(
{
error: "Validation failed",
details: "email must be a valid email address.",
},
{ status: 400 }
);
}

if (!isValidType(typeRaw)) {
return NextResponse.json(
{
error: "Validation failed",
details: `type must be one of: ${VALID_TYPES.join(", ")}`,
},
{ status: 400 }
);
}

const contribution: Contribution = {
id: `contrib_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
contributorName: name,
type: typeRaw,
title: normalizeTitle(title, description),
status: "pending",
submittedAt: new Date().toISOString(),
};
const contribution = createContribution({
title,
type,
description,
author: name,
email,
url,
});

const existing = await readContributions();
await writeContributions([contribution, ...existing]);
Expand All @@ -148,7 +80,8 @@ export async function POST(request: NextRequest) {
},
{ status: 201 }
);
} catch {
return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 });
} catch (error) {
const details = error instanceof Error ? error.message : "Invalid request body. Expected JSON.";
return NextResponse.json({ error: "Validation failed", details }, { status: 400 });
}
}
Loading
Loading