feat: add syntax highlighting and code formatting for blog posts

This commit is contained in:
Mauricio Siu
2025-03-02 16:12:26 -06:00
parent 2f6455027a
commit 5f66cfd234
4 changed files with 71 additions and 95 deletions

View File

@@ -11,6 +11,7 @@ import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import TurndownService from "turndown";
import { CodeBlock } from "../components/CodeBlock";
import { ZoomableImage } from "./components/ZoomableImage";
@@ -76,6 +77,13 @@ export default async function BlogPostPage({ params }: Props) {
notFound();
}
// Convertir HTML a Markdown
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
const markdown = turndownService.turndown(post.html);
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
year: "numeric",
month: "long",
@@ -152,60 +160,14 @@ export default async function BlogPostPage({ params }: Props) {
</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>
<CodeBlock
code={children?.toString() || ""}
language={match ? match[1] : "text"}
className="my-6"
/>
);
},
};
@@ -298,7 +260,7 @@ export default async function BlogPostPage({ params }: Props) {
rehypePlugins={[rehypeRaw]}
components={components}
>
{post.html}
{markdown}
</ReactMarkdown>
</div>

View File

@@ -75,18 +75,10 @@ export function CodeBlock({ code, language, className = "" }: CodeBlockProps) {
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}
code={formattedCode}
language={language.toLowerCase()}
>
{({
@@ -96,43 +88,40 @@ export function CodeBlock({ code, language, className = "" }: CodeBlockProps) {
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);
<div
className={`${preClassName} ${className} overflow-x-auto p-4 rounded-lg text-[14px] leading-[1.5] font-mono relative group`}
style={{
...style,
}}
>
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(formattedCode);
}}
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
}}
className="px-2 py-1 text-xs rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
Copy
</button>
{i + 1}
</span>
<span className="table-cell">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</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

@@ -25,6 +25,7 @@
"@tabler/icons-react": "3.21.0",
"@tryghost/content-api": "^1.11.21",
"@types/node": "20.4.6",
"@types/turndown": "^5.0.5",
"autoprefixer": "^10.4.12",
"axios": "^1.8.1",
"class-variance-authority": "^0.7.0",
@@ -40,6 +41,7 @@
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.0",
"typescript": "5.1.6"
},
"devDependencies": {

23
pnpm-lock.yaml generated
View File

@@ -143,6 +143,9 @@ importers:
'@types/node':
specifier: 20.4.6
version: 20.4.6
'@types/turndown':
specifier: ^5.0.5
version: 5.0.5
autoprefixer:
specifier: ^10.4.12
version: 10.4.19(postcss@8.4.47)
@@ -188,6 +191,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.7)
turndown:
specifier: ^7.2.0
version: 7.2.0
typescript:
specifier: 5.1.6
version: 5.1.6
@@ -968,6 +974,9 @@ packages:
'@mdx-js/mdx@3.1.0':
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
'@next/env@14.2.2':
resolution: {integrity: sha512-sk72qRfM1Q90XZWYRoJKu/UWlTgihrASiYw/scb15u+tyzcze3bOuJ/UV6TBOQEeUaxOkRqGeuGUdiiuxc5oqw==}
@@ -1660,6 +1669,9 @@ packages:
'@types/react@18.3.5':
resolution: {integrity: sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==}
'@types/turndown@5.0.5':
resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==}
'@types/unist@2.0.10':
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@@ -3517,6 +3529,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
turndown@7.2.0:
resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==}
typescript@5.1.6:
resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
engines: {node: '>=14.17'}
@@ -4322,6 +4337,8 @@ snapshots:
- acorn
- supports-color
'@mixmark-io/domino@2.2.0': {}
'@next/env@14.2.2': {}
'@next/env@15.0.3': {}
@@ -5279,6 +5296,8 @@ snapshots:
'@types/prop-types': 15.7.12
csstype: 3.1.3
'@types/turndown@5.0.5': {}
'@types/unist@2.0.10': {}
'@types/unist@3.0.2': {}
@@ -7576,6 +7595,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
turndown@7.2.0:
dependencies:
'@mixmark-io/domino': 2.2.0
typescript@5.1.6: {}
typescript@5.6.3: {}