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
9 changes: 2 additions & 7 deletions app/categories/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AgentCard } from "@/components/AgentCard";
import { CategoryNav } from "@/components/CategoryNav";
import { CatalogState } from "@/components/CatalogState";
import { getAgents, getCategories, getCategory, getCategoryAgents } from "@/lib/data";
import { getCategoryMetadata } from "@/lib/category-metadata";
import { serializeJsonLd } from "@/lib/json-ld";
import { createPublicPageMetadata } from "@/lib/seo";
import { SITE_URL } from "@/lib/site";
Expand All @@ -20,13 +21,7 @@ export async function generateStaticParams(): Promise<{ slug: string }[]> {
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const category = await getCategory(slug);
const title = category?.name
? `Open-source ${category.name.toLowerCase()} AI agents: setup guides`
: "Open-source AI agent category";
const description = category?.description
? `Find open-source ${category.name.toLowerCase()} AI agents. Review project sources, setup requirements, and documented access. ${category.description}`
: "Explore open-source AI agents by category, with source repositories, setup guidance, and documented system access.";
return createPublicPageMetadata({ title, description, path: `/categories/${slug}` });
return createPublicPageMetadata({ ...getCategoryMetadata(category), path: `/categories/${slug}` });
}

export default async function CategoryPage({ params }: { params: Promise<{ slug: string }> }) {
Expand Down
13 changes: 8 additions & 5 deletions app/feedback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ export default function FeedbackPage() {
);
}

export const metadata = createPublicPageMetadata({
title: "Share feedback",
description: "Send feedback about using AgentNine. Public display requires your permission and team approval.",
path: "/feedback",
});
export const metadata = {
...createPublicPageMetadata({
title: "Share feedback",
description: "Send feedback about using AgentNine. Public display requires your permission and team approval.",
path: "/feedback",
}),
robots: { index: false, follow: true },
};
13 changes: 11 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { serializeJsonLd } from "@/lib/json-ld";
import { createPublicPageMetadata } from "@/lib/seo";
import { SITE_URL } from "@/lib/site";

const homepageDescription =
"Explore open-source AI agents. Compare setup requirements, source repositories, and documented access across coding, research, content, and automation projects.";

export default function Home() {
const jsonLd = {
"@context": "https://schema.org",
Expand All @@ -19,7 +22,7 @@ export default function Home() {
"@id": `${SITE_URL}/#website`,
url: SITE_URL,
name: "AgentNine",
description: "Find open-source AI agents in AgentNine's directory. Browse by category, compare listings, and review setup, source, and access details.",
description: homepageDescription,
publisher: { "@id": `${SITE_URL}/#organization` },
},
{
Expand All @@ -28,6 +31,12 @@ export default function Home() {
url: SITE_URL,
name: "AgentNine",
logo: { "@type": "ImageObject", url: `${SITE_URL}/icon.svg` },
sameAs: [
"https://github.com/Nilesh1735/AgentNine",
"https://www.linkedin.com/company/agentnine/",
"https://x.com/agentninepro",
"https://www.instagram.com/agentnine.pro",
],
},
],
};
Expand Down Expand Up @@ -89,6 +98,6 @@ export default function Home() {

export const metadata = createPublicPageMetadata({
title: "Open-Source AI Agent Directory",
description: "Find open-source AI agents in AgentNine's directory. Browse by category, compare listings, and review setup guides, source links, and access details.",
description: homepageDescription,
path: "/",
});
3 changes: 0 additions & 3 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { MetadataRoute } from "next";
import { getAgents, getCategories } from "@/lib/data";
import { getPublishedNewsPosts } from "@/lib/news";
import {
FEEDBACK_PAGE_LAST_MODIFIED,
getLatestSitemapLastModified,
getSitemapLastModified,
STATIC_PAGE_LAST_MODIFIED,
Expand Down Expand Up @@ -47,7 +46,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
newsPosts.map((post) => post.updated_at ?? post.published_at ?? post.created_at),
);
const legalLastModified = getSitemapLastModified(legalConfig.lastUpdated);
const feedbackPageLastModified = getSitemapLastModified(FEEDBACK_PAGE_LAST_MODIFIED);
const staticPageLastModified = {
about: getSitemapLastModified(STATIC_PAGE_LAST_MODIFIED.about),
faq: getSitemapLastModified(STATIC_PAGE_LAST_MODIFIED.faq),
Expand All @@ -65,7 +63,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ url: `${base}/join`, ...(staticPageLastModified.join ? { lastModified: staticPageLastModified.join } : {}), changeFrequency: "monthly", priority: 0.4 },
{ url: `${base}/about`, ...(staticPageLastModified.about ? { lastModified: staticPageLastModified.about } : {}), changeFrequency: "monthly", priority: 0.5 },
{ url: `${base}/faq`, ...(staticPageLastModified.faq ? { lastModified: staticPageLastModified.faq } : {}), changeFrequency: "monthly", priority: 0.5 },
{ url: `${base}/feedback`, ...(feedbackPageLastModified ? { lastModified: feedbackPageLastModified } : {}), changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/contact`, ...(staticPageLastModified.contact ? { lastModified: staticPageLastModified.contact } : {}), changeFrequency: "monthly", priority: 0.3 },
{ url: `${base}/privacy`, ...(legalLastModified ? { lastModified: legalLastModified } : {}), changeFrequency: "yearly", priority: 0.2 },
{ url: `${base}/cookies`, ...(legalLastModified ? { lastModified: legalLastModified } : {}), changeFrequency: "yearly", priority: 0.2 },
Expand Down
2 changes: 1 addition & 1 deletion components/DirectoryPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type PreviewCategory = Pick<Category, "id" | "name" | "slug">;

export function DirectoryPreview({ agents, categories }: { agents: PreviewAgent[]; categories: PreviewCategory[] }) {
const categoryById = new Map(categories.map((category) => [category.id, category]));
const collection = agents.slice(0, 40);
const collection = agents;
const ringCollection = [...collection, ...collection];
const loopDuration = Math.max(120, collection.length * 2);
const controls = useAnimationControls();
Expand Down
6 changes: 3 additions & 3 deletions components/FeyCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,12 @@ export function FeyCards() {

return (
<div className="fey-hero">
<p className="eyebrow fey-hero-kicker">OPEN-SOURCE AI AGENT DIRECTORY</p>
<h1 className="fey-hero-title">
<h1 className="eyebrow fey-hero-kicker">OPEN-SOURCE AI AGENT DIRECTORY</h1>
<p className="fey-hero-title">
<span>Find Your</span>
{" "}
<span>Next Agent</span>
</h1>
</p>
<div
className="fey-deck-scroll"
role="region"
Expand Down
3 changes: 2 additions & 1 deletion components/HomeCatalogSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getPublishedNewsPosts } from "@/lib/news";
import { serializeJsonLd } from "@/lib/json-ld";
import { SITE_URL } from "@/lib/site";
import { getAgentTrustState } from "@/lib/trust";
import { selectHomepagePreviewAgents } from "@/lib/homepage-preview";

export async function HomeCatalogSections() {
const [agents, categories, news] = await Promise.all([
Expand All @@ -22,7 +23,7 @@ export async function HomeCatalogSections() {
]);
const featured = agents.data.slice(0, 3);
const latestNews = news.posts.slice(0, 3);
const previewAgents = agents.data.slice(0, 40).map((agent) => {
const previewAgents = selectHomepagePreviewAgents(agents.data, categories.data).map((agent) => {
const trust = getAgentTrustState(agent);
return {
id: agent.id,
Expand Down
12 changes: 4 additions & 8 deletions components/ProfileAvatars.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { MaskedAvatars } from "@/components/ui/masked-avatars";

const emptyAvatar =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3C/svg%3E";

export function ProfileAvatars({
size = 88,
className,
Expand All @@ -13,11 +10,10 @@ export function ProfileAvatars({
return (
<MaskedAvatars
avatars={[
{ avatar: "/nilesh-profile.jpeg", name: "Nilesh", href: "/about" },
{ avatar: "/ali-sibtain-profile.webp", name: "Ali", href: "/about" },
{ avatar: "/armaan-profile.jpeg", name: "Armaan", href: "/about" },
{ avatar: "/ronit-profile.jpeg", name: "Ronit", href: "/about" },
{ avatar: emptyAvatar, name: "Join us", href: "/join" },
{ avatar: "/nilesh-profile.jpeg", name: "Nilesh Raj", href: "/about" },
{ avatar: "/ali-sibtain-profile.webp", name: "Ali Sibtain", href: "/about" },
{ avatar: "/armaan-profile.jpeg", name: "Armaan Amba", href: "/about" },
{ avatar: "/ronit-profile.jpeg", name: "Ronit Jaiswal", href: "/about" },
]}
size={size}
column={Math.round(size * 0.68)}
Expand Down
21 changes: 21 additions & 0 deletions lib/category-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Category } from "./types";

const FALLBACK_TITLE = "Open-source AI agent category";
const FALLBACK_DESCRIPTION =
"Explore open-source AI agents by category, with source repositories, setup guidance, and documented system access.";

export function getCategoryMetadata(category: Pick<Category, "name" | "description"> | undefined) {
const name = category?.name.trim().replace(/\s+/g, " ");
if (!name) return { title: FALLBACK_TITLE, description: FALLBACK_DESCRIPTION };

const categoryType = name.replace(/\s+agents?$/i, "").trim() || name;
const subject = `${categoryType.toLowerCase().replace(/\bai\b/g, "AI")} agents`;
const description = category?.description?.trim();

return {
title: `Open-source ${subject}: setup guides`,
description: description
? `Explore ${subject} by source, setup requirements, and documented access. ${description}`
: `Explore ${subject} by source, setup requirements, and documented access.`,
};
}
10 changes: 10 additions & 0 deletions lib/homepage-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Agent, Category } from "./types";

export function selectHomepagePreviewAgents<T extends Pick<Agent, "category_id">>(
agents: readonly T[],
categories: readonly Pick<Category, "id">[],
): T[] {
return categories.flatMap(({ id }) =>
agents.filter((agent) => agent.category_id === id).slice(0, 6),
);
}
2 changes: 0 additions & 2 deletions lib/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export const FEEDBACK_PAGE_LAST_MODIFIED = "2026-10-19";

export const STATIC_PAGE_LAST_MODIFIED = {
about: "2026-10-19",
faq: "2026-10-17",
Expand Down
30 changes: 30 additions & 0 deletions tests/category-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { getCategoryMetadata } from "@/lib/category-metadata";

describe("getCategoryMetadata", () => {
it.each([
["Automation agents", "automation agents"],
["Coding agents", "coding agents"],
["Content agents", "content agents"],
["Research agents", "research agents"],
])("normalizes the %s category without repeating agents", (name, subject) => {
expect(getCategoryMetadata({ name, description: `${name} description.` })).toEqual({
title: `Open-source ${subject}: setup guides`,
description: `Explore ${subject} by source, setup requirements, and documented access. ${name} description.`,
});
});

it("keeps agent in category labels singular or already included in a longer name", () => {
expect(getCategoryMetadata({ name: "AI research agents", description: "Research." }).title)
.toBe("Open-source AI research agents: setup guides");
expect(getCategoryMetadata({ name: "Research agent", description: "Research." }).title)
.toBe("Open-source research agents: setup guides");
});

it("uses the generic fallback when category data is unavailable", () => {
expect(getCategoryMetadata(undefined)).toEqual({
title: "Open-source AI agent category",
description: "Explore open-source AI agents by category, with source repositories, setup guidance, and documented system access.",
});
});
});
30 changes: 30 additions & 0 deletions tests/homepage-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { selectHomepagePreviewAgents } from "@/lib/homepage-preview";

describe("selectHomepagePreviewAgents", () => {
it("selects up to six agents from each category in category order", () => {
const categories = ["automation", "coding", "content", "research"].map((id) => ({ id }));
const agents = categories.flatMap(({ id }) =>
Array.from({ length: 8 }, (_, index) => ({ category_id: id, name: `${id}-${index + 1}` })),
);

const selected = selectHomepagePreviewAgents(agents, categories);

expect(selected).toHaveLength(24);
expect(categories.map(({ id }) => selected.filter((agent) => agent.category_id === id))).toEqual(
categories.map(({ id }) => agents.filter((agent) => agent.category_id === id).slice(0, 6)),
);
expect(selected.map((agent) => agent.name)).not.toContain("automation-7");
});

it("keeps a category below six agents without filling from another category", () => {
const categories = [{ id: "automation" }, { id: "coding" }];
const agents = [
{ category_id: "automation", name: "agent-a" },
{ category_id: "coding", name: "agent-b" },
{ category_id: "coding", name: "agent-c" },
];

expect(selectHomepagePreviewAgents(agents, categories)).toEqual(agents);
});
});
8 changes: 2 additions & 6 deletions tests/sitemap-generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,9 @@ describe("sitemap generation", () => {
mocks.getLatestPublishedSiteFeedbackUpdate.mockResolvedValue(new Date("2026-10-19T09:00:00.000Z"));
});

it("sets the requested feedback priority", async () => {
it("does not include the noindex feedback form", async () => {
const entries = await sitemap();
expect(entries.find((entry) => entry.url.endsWith("/feedback"))).toMatchObject({
changeFrequency: "monthly",
priority: 0.7,
lastModified: new Date("2026-10-19T00:00:00.000Z"),
});
expect(entries.find((entry) => entry.url.endsWith("/feedback"))).toBeUndefined();
});

it("uses the newest underlying content date for collection pages", async () => {
Expand Down
Loading