Files
community-rule/app/(marketing)/blog/[slug]/page.tsx
T
adilalloandCursor c180e79c38 Give every public route its own description, and keep canonicals and structured data on the host that serves the page.
Decorative images on the marketing pages stay quiet, and every dialog close button is named Close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 11:04:52 -07:00

259 lines
7.2 KiB
TypeScript

import { notFound } from "next/navigation";
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import type { BlogPost } from "../../../../lib/content";
import {
getBlogPostBySlug,
getAllBlogPosts as getAllPosts,
getRelatedBlogPosts,
} from "../../../../lib/content";
import { logger } from "../../../../lib/logger";
import { getRequestSiteOrigin } from "../../../../lib/requestSiteOrigin";
import {
absoluteSiteUrl,
articleShareImages,
routeMetadata,
} from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer";
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
import "../blog.css";
// Code split RelatedArticles - blog-specific, below the fold
const RelatedArticles = dynamic(
() => import("../../../components/sections/RelatedArticles"),
{
loading: () => (
<section className="py-[var(--space-800)] min-h-[400px]" />
),
ssr: true,
},
);
// AskOrganizer data - same as index page
const askOrganizerData = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
};
interface PageProps {
params: Promise<{ slug: string }>;
}
/**
* Generate static params for all blog posts
* This enables static generation for all blog posts at build time
*/
export async function generateStaticParams() {
try {
const posts = getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
} catch (error) {
logger.error("Error generating static params:", error);
return [];
}
}
/**
* Generate metadata for each blog post
*/
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
try {
const { slug } = await params;
const post = getBlogPostBySlug(slug);
if (!post) {
return routeMetadata(`/blog/${slug}`, {
title: messages.metadata.blog.notFoundTitle,
description: messages.metadata.blog.fallbackDescription,
});
}
const articleImages = articleShareImages(
slug,
post.frontmatter.title,
);
return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title,
description: post.frontmatter.description,
authors: [{ name: post.frontmatter.author }],
openGraph: {
title: post.frontmatter.title,
description: post.frontmatter.description,
type: "article",
publishedTime: post.frontmatter.date,
authors: [post.frontmatter.author],
url: `/blog/${slug}`,
siteName: messages.metadata.siteName,
...(articleImages ? { images: articleImages } : {}),
},
twitter: {
card: "summary_large_image",
title: post.frontmatter.title,
description: post.frontmatter.description,
creator: "@communityrule",
},
});
} catch (error) {
logger.error("Error generating metadata:", error);
return {
title: messages.metadata.blog.fallbackTitle,
description: messages.metadata.blog.fallbackDescription,
};
}
}
/**
* Dynamic blog post page
*/
export default async function BlogPostPage({ params }: PageProps) {
// Get the blog post data
const { slug } = await params;
const post = getBlogPostBySlug(slug);
// If post doesn't exist, show 404
if (!post) {
notFound();
}
// Get related articles with improved algorithm
const allPosts = getAllPosts();
const slugOrder = allPosts.map((post) => post.slug);
const relatedArticles = getRelatedBlogPosts(
post.slug,
post.frontmatter.related,
3,
);
const origin = await getRequestSiteOrigin();
// Generate structured data for search engines
const structuredData = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.frontmatter.title,
description: post.frontmatter.description,
author: {
"@type": "Organization",
name: post.frontmatter.author,
},
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: absoluteSiteUrl("/", origin),
logo: {
"@type": "ImageObject",
url: absoluteSiteUrl("/assets/logos/community-rule.svg", origin),
},
},
datePublished: post.frontmatter.date,
dateModified: post.frontmatter.date,
mainEntityOfPage: {
"@type": "WebPage",
"@id": absoluteSiteUrl(`/blog/${post.slug}`, origin),
},
url: absoluteSiteUrl(`/blog/${post.slug}`, origin),
articleSection: "Community Building",
keywords: ["community", "governance", "decision making", "collaboration"],
};
// Breadcrumb structured data
const breadcrumbData = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: absoluteSiteUrl("/", origin),
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: absoluteSiteUrl("/blog", origin),
},
{
"@type": "ListItem",
position: 3,
name: post.frontmatter.title,
item: absoluteSiteUrl(`/blog/${post.slug}`, origin),
},
],
};
// Get article-specific background color from frontmatter
const getBackgroundColor = (post: BlogPost): string => {
if (post.frontmatter?.background?.color) {
return post.frontmatter.background.color;
}
return "#1F2937"; // Default fallback (dark gray)
};
const backgroundColor = getBackgroundColor(post);
return (
<>
{/* Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbData),
}}
/>
<div
className="relative min-h-screen overflow-x-clip"
style={{ backgroundColor }}
>
<ContentBanner post={post} />
<div className="relative w-full overflow-x-clip">
<ContentTemplateDecorativeShapes
variant="article"
slug={post.slug}
/>
<article
data-node-id="19031:10426"
className="
relative z-10 flex w-full justify-center
p-[var(--space-600)]
sm:px-0 sm:py-[var(--space-800)]
xl:px-[calc(268/1440*100%)]
"
>
<div className="post-body w-full text-[var(--color-content-invert-primary)] text-medium-paragraph sm:text-large-paragraph lg:text-x-large-paragraph xl:text-xx-large-paragraph sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-none">
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</div>
</article>
</div>
{/* Related Articles Section */}
<RelatedArticles
relatedPosts={relatedArticles}
currentPostSlug={post.slug}
slugOrder={slugOrder}
/>
{/* Ask Organizer Section */}
<AskOrganizer {...askOrganizerData} variant="inverse" />
</div>
</>
);
}