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

View File

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

View File

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

23
pnpm-lock.yaml generated
View File

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