@
feat(web): SEO — server-rendered template pages + /guides articles - templates/[slug] converted from client to server component: per-template generateMetadata (title/description/canonical/OG) + SoftwareApplication JSON-LD; code-audit toggle split into a client island; missing/non-public templates now return a real 404. - sitemap.ts pulls public template slugs live from the API (best-effort) + the new /guides routes. - new /guides section: 3 server-rendered SEO articles (host MCP with OAuth, hosted-platforms comparison, MintMCP alternative) with TechArticle JSON-LD; Guides link added to the marketing nav. - lib/seo.ts: articleJsonLd + templateJsonLd builders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
@@ -184,6 +184,58 @@ export function faqJsonLd(items: FaqItem[] = FAQ): object {
|
||||
};
|
||||
}
|
||||
|
||||
/** TechArticle structured data for /guides/* SEO articles. */
|
||||
export function articleJsonLd(opts: {
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
datePublished: string;
|
||||
dateModified?: string;
|
||||
}): object {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TechArticle',
|
||||
headline: opts.title,
|
||||
description: opts.description,
|
||||
url: `${SITE_URL}${opts.path}`,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${SITE_URL}${opts.path}` },
|
||||
datePublished: opts.datePublished,
|
||||
dateModified: opts.dateModified ?? opts.datePublished,
|
||||
inLanguage: 'en',
|
||||
author: { '@id': `${SITE_URL}/#organization` },
|
||||
publisher: { '@id': `${SITE_URL}/#organization` },
|
||||
};
|
||||
}
|
||||
|
||||
/** SoftwareApplication structured data for a published marketplace template. */
|
||||
export function templateJsonLd(opts: {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tools: string[];
|
||||
author: string | null;
|
||||
}): object {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': `${SITE_URL}/templates/${opts.slug}#software`,
|
||||
name: opts.title,
|
||||
description: opts.description,
|
||||
url: `${SITE_URL}/templates/${opts.slug}`,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'MCP server',
|
||||
operatingSystem: 'Web Browser',
|
||||
inLanguage: 'en',
|
||||
keywords: ['MCP server', 'Model Context Protocol', opts.category],
|
||||
featureList: opts.tools,
|
||||
isAccessibleForFree: true,
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
||||
...(opts.author ? { author: { '@type': 'Person', name: opts.author } } : {}),
|
||||
publisher: { '@id': `${SITE_URL}/#organization` },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-page metadata. `title` is a bare string so the root layout's
|
||||
* "%s | BuildMyMCPServer" template appends the brand exactly once.
|
||||
|
||||
76
apps/web/lib/templates-server.ts
Normal file
76
apps/web/lib/templates-server.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Server-only fetchers for the public template marketplace. Used by the
|
||||
// server-rendered template detail page (SEO metadata + JSON-LD) and the
|
||||
// sitemap. Never import this into a client component.
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
||||
|
||||
export interface TemplateTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TemplateSecretHint {
|
||||
key: string;
|
||||
description: string;
|
||||
howToGetUrl?: string;
|
||||
}
|
||||
|
||||
export interface TemplateDetail {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
longDescription: string | null;
|
||||
category: string;
|
||||
status: 'draft' | 'public' | 'hidden' | 'takedown';
|
||||
verified: boolean;
|
||||
forkCount: number;
|
||||
activeDeployments: number;
|
||||
toolsSchema: TemplateTool[];
|
||||
generatedCode: string;
|
||||
requiredSecrets: TemplateSecretHint[];
|
||||
scopes: string[];
|
||||
ownerName: string | null;
|
||||
ownerOrgName: string | null;
|
||||
sourceServerId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single public template by slug for server rendering. Returns null
|
||||
* for missing / non-public templates so the page can `notFound()` — we only
|
||||
* want `public` templates indexed.
|
||||
*/
|
||||
export async function fetchTemplate(slug: string): Promise<TemplateDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/templates/${encodeURIComponent(slug)}`, {
|
||||
// Cache server-side for 5 min so crawler hits don't hammer the API.
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { template?: TemplateDetail };
|
||||
const t = data.template;
|
||||
if (!t || t.status !== 'public') return null;
|
||||
return t;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Slugs of public templates, for the sitemap. Best-effort: returns [] on error. */
|
||||
export async function fetchPublicTemplateSlugs(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/v1/templates?limit=100&sort=newest`, {
|
||||
next: { revalidate: 600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as
|
||||
| { templates?: Array<{ slug?: string }> }
|
||||
| Array<{ slug?: string }>;
|
||||
const list = Array.isArray(data) ? data : (data.templates ?? []);
|
||||
return list.map((t) => t.slug).filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user