feat: re-enable blog post metadata and CodeBlock component

This commit is contained in:
Mauricio Siu
2025-03-02 21:22:14 -06:00
parent c9d02a98d4
commit 26cad40c68
3 changed files with 301 additions and 312 deletions

View File

@@ -14,7 +14,7 @@ import type { BundledLanguage } from "shiki/bundle/web";
import TurndownService from "turndown"; import TurndownService from "turndown";
// @ts-ignore // @ts-ignore
import * as turndownPluginGfm from "turndown-plugin-gfm"; import * as turndownPluginGfm from "turndown-plugin-gfm";
// import { CodeBlock } from "./components/CodeBlock"; import { CodeBlock } from "./components/CodeBlock";
import { H1, H2, H3 } from "./components/Headings"; import { H1, H2, H3 } from "./components/Headings";
import { TableOfContents } from "./components/TableOfContents"; import { TableOfContents } from "./components/TableOfContents";
import { ZoomableImage } from "./components/ZoomableImage"; import { ZoomableImage } from "./components/ZoomableImage";
@@ -22,50 +22,50 @@ type Props = {
params: { locale: string; slug: string }; params: { locale: string; slug: string };
}; };
// export async function generateMetadata( export async function generateMetadata(
// { params }: Props, { params }: Props,
// parent: ResolvingMetadata, parent: ResolvingMetadata,
// ): Promise<Metadata> { ): Promise<Metadata> {
// const { locale, slug } = await params; const { locale, slug } = await params;
// const post = await getPost(slug); const post = await getPost(slug);
// if (!post) { if (!post) {
// return { return {
// title: "Post Not Found", title: "Post Not Found",
// }; };
// } }
// const ogUrl = new URL( const ogUrl = new URL(
// `/${locale}/api/og`, `/${locale}/api/og`,
// process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
// ); );
// ogUrl.searchParams.set("slug", slug); ogUrl.searchParams.set("slug", slug);
// return { return {
// title: post.title, title: post.title,
// description: post.custom_excerpt || post.excerpt, description: post.custom_excerpt || post.excerpt,
// openGraph: { openGraph: {
// title: post.title, title: post.title,
// description: post.custom_excerpt || post.excerpt, description: post.custom_excerpt || post.excerpt,
// type: "article", type: "article",
// url: `${process.env.NEXT_PUBLIC_APP_URL}/blog/${post.slug}`, url: `${process.env.NEXT_PUBLIC_APP_URL}/blog/${post.slug}`,
// images: [ images: [
// { {
// url: ogUrl.toString(), url: ogUrl.toString(),
// width: 1200, width: 1200,
// height: 630, height: 630,
// alt: post.title, alt: post.title,
// }, },
// ], ],
// }, },
// twitter: { twitter: {
// card: "summary_large_image", card: "summary_large_image",
// title: post.title, title: post.title,
// description: post.custom_excerpt || post.excerpt, description: post.custom_excerpt || post.excerpt,
// images: [ogUrl.toString()], images: [ogUrl.toString()],
// }, },
// }; };
// } }
export async function generateStaticParams() { export async function generateStaticParams() {
const posts = await getPosts(); const posts = await getPosts();
@@ -80,291 +80,282 @@ export async function generateStaticParams() {
} }
export default async function BlogPostPage({ params }: Props) { export default async function BlogPostPage({ params }: Props) {
try { const { slug } = await params;
const { locale, slug } = await params; // setRequestLocale(locale);
// setRequestLocale(locale); const t = await getTranslations("blog");
const t = await getTranslations({ locale, namespace: "blog" }); const post = await getPost(slug);
const post = await getPost(slug); const allPosts = await getPosts();
const allPosts = await getPosts();
// Get related posts (excluding current post) // Get related posts (excluding current post)
const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3); // Show only 3 related posts const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3); // Show only 3 related posts
if (!post) { if (!post) {
notFound(); notFound();
} }
// Convertir HTML a Markdown // Convertir HTML a Markdown
const turndownService = new TurndownService({ const turndownService = new TurndownService({
headingStyle: "atx", headingStyle: "atx",
codeBlockStyle: "fenced", codeBlockStyle: "fenced",
}); });
const gfm = turndownPluginGfm.gfm; const gfm = turndownPluginGfm.gfm;
const tables = turndownPluginGfm.tables; const tables = turndownPluginGfm.tables;
const strikethrough = turndownPluginGfm.strikethrough; const strikethrough = turndownPluginGfm.strikethrough;
turndownService.use([tables, strikethrough, gfm, remarkToc]); turndownService.use([tables, strikethrough, gfm, remarkToc]);
const markdown = turndownService.turndown(post.html); const markdown = turndownService.turndown(post.html);
const formattedDate = new Date(post.published_at).toLocaleDateString( const formattedDate = new Date(post.published_at).toLocaleDateString("en", {
locale, year: "numeric",
{ month: "long",
year: "numeric", day: "numeric",
month: "long", });
day: "numeric",
},
);
const components: Partial<Components> = { const components: Partial<Components> = {
h1: H1, h1: H1,
h2: H2, h2: H2,
h3: H3, h3: H3,
p: ({ node, children, ...props }) => ( p: ({ node, children, ...props }) => (
<p <p
className="text-base text-muted-foreground leading-relaxed mb-4" className="text-base text-muted-foreground leading-relaxed mb-4"
{...props} {...props}
>
{children}
</p>
),
a: ({ node, href, ...props }) => (
<a
href={href}
className="text-blue-500 hover:text-blue-500/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside space-y-2 mb-4" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside space-y-2 mb-4" {...props} />
),
li: ({ node, ...props }) => (
<li className="text-base leading-relaxed" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-primary pl-4 py-2 my-4 bg-muted/50"
{...props}
/>
),
table: ({ node, ...props }) => (
<div className="my-6 w-full overflow-x-auto border rounded-lg">
<table className="w-full border-collapse" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-muted border-b border-border" {...props} />
),
tbody: ({ node, ...props }) => (
<tbody className="divide-y divide-border" {...props} />
),
tr: ({ node, ...props }) => (
<tr className="transition-colors hover:bg-muted/50" {...props} />
),
th: ({ node, ...props }) => (
<th className="p-4 text-left font-semibold" {...props} />
),
td: ({ node, ...props }) => (
<td className="p-4 text-muted-foreground" {...props} />
),
img: ({ node, src, alt }) => (
<ZoomableImage
src={src || ""}
alt={alt || ""}
className="object-cover max-w-lg mx-auto rounded-lg border max-lg:w-64 border-border overflow-hidden"
/>
),
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className || "");
return (
<CodeBlock
lang={match ? (match[1] as BundledLanguage) : "ts"}
code={children?.toString() || ""}
/>
);
},
};
return (
<article className="mx-auto px-4 sm:px-6 lg:px-8 pb-12 max-w-7xl w-full">
<Link
href="/blog"
className="inline-flex items-center mb-8 text-primary hover:text-primary/80 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
> >
{children} <path
</p> fillRule="evenodd"
), d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
a: ({ node, href, ...props }) => ( clipRule="evenodd"
<a />
href={href} </svg>
className="text-blue-500 hover:text-blue-500/80 transition-colors" {t("backToBlog")}
target="_blank" </Link>
rel="noopener noreferrer"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside space-y-2 mb-4" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside space-y-2 mb-4" {...props} />
),
li: ({ node, ...props }) => (
<li className="text-base leading-relaxed" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-primary pl-4 py-2 my-4 bg-muted/50"
{...props}
/>
),
table: ({ node, ...props }) => (
<div className="my-6 w-full overflow-x-auto border rounded-lg">
<table className="w-full border-collapse" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-muted border-b border-border" {...props} />
),
tbody: ({ node, ...props }) => (
<tbody className="divide-y divide-border" {...props} />
),
tr: ({ node, ...props }) => (
<tr className="transition-colors hover:bg-muted/50" {...props} />
),
th: ({ node, ...props }) => (
<th className="p-4 text-left font-semibold" {...props} />
),
td: ({ node, ...props }) => (
<td className="p-4 text-muted-foreground" {...props} />
),
img: ({ node, src, alt }) => (
<ZoomableImage
src={src || ""}
alt={alt || ""}
className="object-cover max-w-lg mx-auto rounded-lg border max-lg:w-64 border-border overflow-hidden"
/>
),
// code: ({ className, children }) => {
// const match = /language-(\w+)/.exec(className || "");
// return (
// <CodeBlock
// lang={match ? (match[1] as BundledLanguage) : "ts"}
// code={children?.toString() || ""}
// />
// );
// },
};
return ( <div className="grid grid-cols-1 lg:grid-cols-[1fr_250px] gap-8">
<article className="mx-auto px-4 sm:px-6 lg:px-8 pb-12 max-w-7xl w-full"> <div className="rounded-lg p-8 shadow-lg border border-border">
<Link <header className="mb-8">
href="/blog" <h1 className="text-xl md:text-2xl xl:text-3xl font-bold mb-4">
className="inline-flex items-center mb-8 text-primary hover:text-primary/80 transition-colors" {post.title}
> </h1>
<svg <div className="flex items-center mb-6">
xmlns="http://www.w3.org/2000/svg" {post.primary_author?.profile_image && (
className="h-5 w-5 mr-2" <div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
viewBox="0 0 20 20" {post.primary_author.twitter ? (
fill="currentColor" <a
> href={`https://twitter.com/${post.primary_author.twitter}`}
<path target="_blank"
fillRule="evenodd" rel="noopener noreferrer"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" className="block cursor-pointer transition-opacity hover:opacity-90"
clipRule="evenodd" >
/>
</svg>
{t("backToBlog")}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_250px] gap-8">
<div className="rounded-lg p-8 shadow-lg border border-border">
<header className="mb-8">
<h1 className="text-xl md:text-2xl xl:text-3xl font-bold mb-4">
{post.title}
</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
{post.primary_author.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer transition-opacity hover:opacity-90"
>
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</a>
) : (
<Image <Image
src={post.primary_author.profile_image} src={post.primary_author.profile_image}
alt={post.primary_author.name} alt={post.primary_author.name}
fill fill
className="object-cover" className="object-cover"
/> />
)} </a>
</div> ) : (
)} <Image
<div> src={post.primary_author.profile_image}
<p className="font-medium"> alt={post.primary_author.name}
{post.primary_author?.twitter ? ( fill
<a className="object-cover"
href={`https://twitter.com/${post.primary_author.twitter}`} />
target="_blank" )}
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{post.primary_author.name || "Unknown Author"}
</a>
) : (
post.primary_author?.name || "Unknown Author"
)}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</div>
{post.feature_image && (
<div className="relative w-full h-[400px] mb-8">
<ZoomableImage
src={post.feature_image}
alt={post.title}
className="rounded-lg h-full w-full object-cover"
/>
</div> </div>
)} )}
</header> <div>
<p className="font-medium">
<div className="prose prose-lg max-w-none"> {post.primary_author?.twitter ? (
<ReactMarkdown <a
remarkPlugins={[ href={`https://twitter.com/${post.primary_author.twitter}`}
remarkGfm, target="_blank"
[remarkToc, { tight: true, maxDepth: 3 }], rel="noopener noreferrer"
]} className="hover:text-primary transition-colors"
rehypePlugins={[rehypeRaw]}
components={components}
>
{markdown}
</ReactMarkdown>
</div>
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
> >
{tag.name} {post.primary_author.name || "Unknown Author"}
</Link> </a>
))} ) : (
</div> post.primary_author?.name || "Unknown Author"
)}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</div>
{post.feature_image && (
<div className="relative w-full h-[400px] mb-8">
<ZoomableImage
src={post.feature_image}
alt={post.title}
className="rounded-lg h-full w-full object-cover"
/>
</div> </div>
)} )}
</header>
<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[
remarkGfm,
[remarkToc, { tight: true, maxDepth: 3 }],
]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{markdown}
</ReactMarkdown>
</div> </div>
<div className="hidden lg:block max-w-[16rem]"> {post.tags && post.tags.length > 0 && (
<div className="sticky top-4"> <div className="mt-12 pt-6 border-t border-border">
<TableOfContents /> <h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
</div>
</div> </div>
</div> )}
</div> </div>
{relatedPosts.length > 0 && ( <div className="hidden lg:block max-w-[16rem]">
<div className="mt-12"> <div className="sticky top-4">
<h2 className="text-2xl font-bold mb-6">{t("relatedPosts")}</h2> <TableOfContents />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map((relatedPost) => {
const relatedPostDate = new Date(
relatedPost.published_at,
).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Link
key={relatedPost.id}
href={`/blog/${relatedPost.slug}`}
className="group"
>
<div className="bg-card rounded-lg overflow-hidden h-full shadow-lg transition-all duration-300 hover:shadow-xl border border-border">
{relatedPost.feature_image && (
<div className="relative h-48 w-full">
<Image
src={relatedPost.feature_image}
alt={relatedPost.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-lg font-semibold mb-2 group-hover:text-primary transition-colors line-clamp-2">
{relatedPost.title}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{relatedPostDate} {relatedPost.reading_time} min
read
</p>
<p className="text-muted-foreground line-clamp-2">
{relatedPost.excerpt}
</p>
</div>
</div>
</Link>
);
})}
</div>
</div> </div>
)} </div>
</article> </div>
);
} catch (error) { {relatedPosts.length > 0 && (
console.error("Error in BlogPostPage:", error); <div className="mt-12">
return <div>Error loading blog post</div>; <h2 className="text-2xl font-bold mb-6">{t("relatedPosts")}</h2>
} <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map((relatedPost) => {
const relatedPostDate = new Date(
relatedPost.published_at,
).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Link
key={relatedPost.id}
href={`/blog/${relatedPost.slug}`}
className="group"
>
<div className="bg-card rounded-lg overflow-hidden h-full shadow-lg transition-all duration-300 hover:shadow-xl border border-border">
{relatedPost.feature_image && (
<div className="relative h-48 w-full">
<Image
src={relatedPost.feature_image}
alt={relatedPost.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-lg font-semibold mb-2 group-hover:text-primary transition-colors line-clamp-2">
{relatedPost.title}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{relatedPostDate} {relatedPost.reading_time} min read
</p>
<p className="text-muted-foreground line-clamp-2">
{relatedPost.excerpt}
</p>
</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</article>
);
} }

View File

@@ -2,11 +2,10 @@ import { getPosts, getTags } from "@/lib/ghost";
import type { Post } from "@/lib/ghost"; import type { Post } from "@/lib/ghost";
import { RssIcon } from "lucide-react"; import { RssIcon } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { BlogPostCard } from "./components/BlogPostCard"; import { BlogPostCard } from "./components/BlogPostCard";
import { SearchAndFilter } from "./components/SearchAndFilter"; import { SearchAndFilter } from "./components/SearchAndFilter";
interface Tag { interface Tag {
id: string; id: string;
name: string; name: string;
@@ -27,8 +26,7 @@ export default async function BlogPage({
}) { }) {
const { locale } = await params; const { locale } = await params;
const searchParams2 = await searchParams; const searchParams2 = await searchParams;
setRequestLocale(locale); const t = await getTranslations("blog");
const t = await getTranslations({ locale, namespace: "blog" });
const posts = await getPosts(); const posts = await getPosts();
const tags = (await getTags()) as Tag[]; const tags = (await getTags()) as Tag[];
const search = const search =

View File

@@ -11,8 +11,8 @@ type Props = {
}; };
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { tag, locale } = params; const { tag } = await params;
const t = await getTranslations({ locale, namespace: "blog" }); const t = await getTranslations("blog");
return { return {
title: `${t("tagTitle", { tag })}`, title: `${t("tagTitle", { tag })}`,
@@ -29,8 +29,8 @@ export async function generateStaticParams() {
} }
export default async function TagPage({ params }: Props) { export default async function TagPage({ params }: Props) {
const { locale, tag } = params; const { tag } = await params;
const t = await getTranslations({ locale, namespace: "blog" }); const t = await getTranslations("blog");
const posts = await getPostsByTag(tag); const posts = await getPostsByTag(tag);
if (!posts || posts.length === 0) { if (!posts || posts.length === 0) {