feat: add syntax highlighting for code blocks in blog posts

This commit is contained in:
Mauricio Siu
2025-03-02 15:50:36 -06:00
parent 01d974688e
commit 2f6455027a
6 changed files with 533 additions and 27 deletions

View File

@@ -6,10 +6,12 @@ import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import type React from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { CodeBlock } from "../components/CodeBlock";
import { ZoomableImage } from "./components/ZoomableImage";
type Props = {
@@ -54,6 +56,13 @@ export async function generateStaticParams() {
}));
}
interface CodeProps
extends DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> {
inline?: boolean;
className?: string;
children?: React.ReactNode;
}
export default async function BlogPostPage({ params }: Props) {
const { locale, slug } = params;
const t = await getTranslations({ locale, namespace: "blog" });
@@ -83,12 +92,16 @@ export default async function BlogPostPage({ params }: Props) {
h3: ({ node, ...props }) => (
<h3 className="text-xl text-primary/90 font-bold mt-4 mb-2" {...props} />
),
p: ({ node, ...props }) => (
<p
className="text-base text-muted-foreground leading-relaxed mb-4"
{...props}
/>
),
p: ({ node, children, ...props }) => {
return (
<p
className="text-base text-muted-foreground leading-relaxed mb-4"
{...props}
>
{children}
</p>
);
},
a: ({ node, href, ...props }) => (
<a
href={href}
@@ -138,6 +151,63 @@ export default async function BlogPostPage({ params }: Props) {
{src && <ZoomableImage src={src} alt={alt || ""} />}
</div>
),
code: ({ inline, className, children, ...props }: CodeProps) => {
if (inline) {
return (
<code
className="px-1.5 py-0.5 rounded-md bg-muted font-mono text-sm"
{...props}
>
{children}
</code>
);
}
const match = /language-(\w+)/.exec(className || "");
// Extraer el contenido del código de la estructura anidada
const extractCodeContent = (children: React.ReactNode): string => {
if (typeof children === "string") {
return children;
}
if (Array.isArray(children)) {
return children
.map((child) => {
if (typeof child === "string") {
return child;
}
if (child && typeof child === "object" && "props" in child) {
return extractCodeContent(child.props.children);
}
return "";
})
.join("");
}
if (children && typeof children === "object" && "props" in children) {
return extractCodeContent(children.props.children);
}
return "";
};
const codeContent = extractCodeContent(children)
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.trim();
// Wrap CodeBlock in a div to prevent it from being inside a p tag
return (
<div className="not-prose my-6">
<CodeBlock
code={codeContent}
language={match ? match[1] : "text"}
className="my-6"
/>
</div>
);
},
};
return (

View File

@@ -0,0 +1,140 @@
"use client";
import * as xmlPlugin from "@prettier/plugin-xml";
import * as prettier from "prettier";
import { Highlight, themes } from "prism-react-renderer";
import { useEffect, useState } from "react";
interface CodeBlockProps {
code: string;
language: string;
className?: string;
}
export function CodeBlock({ code, language, className = "" }: CodeBlockProps) {
const [formattedCode, setFormattedCode] = useState(code);
useEffect(() => {
const formatCode = async () => {
try {
// Determine the parser based on the language
let parser = language;
let plugins: any[] = [];
// Map common languages to their appropriate parsers
switch (language.toLowerCase()) {
case "tsx":
case "ts":
case "typescript":
parser = "babel-ts";
plugins = ["@babel/plugin-syntax-typescript"];
break;
case "jsx":
case "js":
case "javascript":
parser = "babel";
break;
case "html":
case "xml":
case "svg":
parser = "html";
plugins = [xmlPlugin];
break;
case "json":
parser = "json";
break;
case "css":
case "scss":
case "less":
parser = "css";
break;
default:
// For unknown languages, just clean up the whitespace
setFormattedCode(code.trim());
return;
}
const formatted = await prettier.format(code, {
parser,
plugins,
semi: true,
singleQuote: false,
tabWidth: 2,
useTabs: false,
printWidth: 80,
});
setFormattedCode(formatted.trim());
} catch (error) {
console.warn("Error formatting code:", error);
// If formatting fails, just clean up the whitespace
setFormattedCode(code.trim());
}
};
formatCode();
}, [code, language]);
// Ensure we're working with a string and remove any potential duplicate line breaks
const processedCode =
typeof formattedCode === "string"
? formattedCode.replace(/\n+/g, "\n")
: typeof formattedCode === "object"
? JSON.stringify(formattedCode, null, 2)
: String(formattedCode);
return (
<Highlight
theme={themes.dracula}
code={processedCode}
language={language.toLowerCase()}
>
{({
className: preClassName,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<div className="relative group">
<pre
className={`${preClassName} ${className} overflow-x-auto p-4 rounded-lg text-[13px] leading-[1.5] font-mono`}
style={{
...style,
backgroundColor: "rgb(40, 42, 54)", // Dracula background
}}
>
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(processedCode);
}}
className="px-2 py-1 text-xs rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
Copy
</button>
</div>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} className="table-row">
<span
className="table-cell text-right pr-4 select-none w-[2.5em] text-zinc-500 text-xs"
style={{
color: "rgb(98, 114, 164)", // Dracula comment color
}}
>
{i + 1}
</span>
<span className="table-cell">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
))}
</pre>
</div>
)}
</Highlight>
);
}

View File

@@ -97,6 +97,7 @@ export async function getPost(slug: string): Promise<Post | null> {
slug,
include: ["authors"],
})) as Post;
return result;
} catch (error) {
console.error("Error fetching post:", error);

View File

@@ -12,7 +12,6 @@
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
"react-photo-view": "^1.2.7",
"@headlessui/react": "^1.7.17",
"@headlessui/tailwindcss": "^0.2.0",
"@radix-ui/react-accordion": "^1.2.1",
@@ -37,16 +36,21 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-ga4": "^2.1.0",
"react-photo-view": "^1.2.7",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.1.6"
},
"devDependencies": {
"@babel/core": "^7.26.9",
"@babel/parser": "^7.26.9",
"@babel/plugin-syntax-typescript": "^7.25.9",
"@biomejs/biome": "1.7.0",
"@prettier/plugin-xml": "^3.4.1",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"prettier": "^3.0.1",
"prettier-plugin-tailwindcss": "^0.5.2"
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.5.14"
}
}