refactor: migrate code block rendering to Shiki with Prettier formatting

This commit is contained in:
Mauricio Siu 2025-03-02 16:53:42 -06:00
parent 01c75f6798
commit ebab22b263
4 changed files with 56 additions and 127 deletions

View File

@ -1,20 +1,20 @@
import { getPost, getPosts } from "@/lib/ghost";
import type { Post } from "@/lib/ghost";
import type { Metadata, ResolvingMetadata } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import * as prettier from "prettier";
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 { codeToHtml } from "shiki";
import type { BundledLanguage } from "shiki/bundle/web";
import TurndownService from "turndown";
import { CodeBlock } from "../components/CodeBlock";
import { ZoomableImage } from "./components/ZoomableImage";
type Props = {
params: { locale: string; slug: string };
};
@ -63,7 +63,53 @@ interface CodeProps
className?: string;
children?: React.ReactNode;
}
interface LanguageProps {
children: string;
lang: BundledLanguage;
}
const getParserForLanguage = (language: string): string => {
const languageMap: { [key: string]: string } = {
js: "babel",
jsx: "babel",
ts: "typescript",
tsx: "typescript",
json: "json",
css: "css",
scss: "scss",
less: "less",
html: "html",
xml: "xml",
markdown: "markdown",
md: "markdown",
yaml: "yaml",
yml: "yaml",
};
return languageMap[language.toLowerCase()] || "babel";
};
async function CodeBlock(props: LanguageProps) {
const format = await prettier.format(props.children, {
semi: true,
singleQuote: true,
tabWidth: 2,
useTabs: false,
printWidth: 120,
parser: getParserForLanguage(props.lang),
});
const out = await codeToHtml(format, {
lang: props.lang,
theme: "houston",
});
return (
<div
dangerouslySetInnerHTML={{ __html: out }}
className="text-sm p-4 rounded-lg bg-[#18191F]"
/>
);
}
export default async function BlogPostPage({ params }: Props) {
const { locale, slug } = params;
const t = await getTranslations({ locale, namespace: "blog" });
@ -163,11 +209,9 @@ export default async function BlogPostPage({ params }: Props) {
const match = /language-(\w+)/.exec(className || "");
return (
<CodeBlock
code={children?.toString() || ""}
language={match ? match[1] : "text"}
className="my-6"
/>
<CodeBlock lang={match ? (match[1] as BundledLanguage) : "ts"}>
{children?.toString() || ""}
</CodeBlock>
);
},
};

View File

@ -1,119 +0,0 @@
"use client";
import * as prettier from "prettier";
import * as prettierPluginBabel from "prettier/plugins/babel";
import * as prettierPluginEstree from "prettier/plugins/estree";
import { Highlight, themes } from "prism-react-renderer";
import { useEffect, useState } from "react";
interface CodeBlockProps {
code: string;
language: string;
className?: string;
}
const getParserForLanguage = (language: string): string => {
const languageMap: { [key: string]: string } = {
js: "babel",
jsx: "babel",
ts: "typescript",
tsx: "typescript",
json: "json",
css: "css",
scss: "scss",
less: "less",
html: "html",
xml: "xml",
markdown: "markdown",
md: "markdown",
yaml: "yaml",
yml: "yaml",
};
return languageMap[language.toLowerCase()] || "babel";
};
export function CodeBlock({ code, language, className = "" }: CodeBlockProps) {
const [formattedCode, setFormattedCode] = useState(code);
useEffect(() => {
const formatCode = async () => {
try {
const parser = getParserForLanguage(language);
// Eliminar espacios en blanco al inicio y final, pero mantener la indentación interna
const formatted = await prettier.format(formattedCode, {
semi: true,
singleQuote: false,
tabWidth: 2,
useTabs: false,
printWidth: 80,
parser,
plugins: [prettierPluginBabel, prettierPluginEstree],
});
setFormattedCode(formatted);
} catch (error) {
console.warn("Error formatting code:", error);
// Si falla el formateo, al menos limpiamos los espacios en blanco extra
const cleanCode = code.replace(/^\s+|\s+$/g, "");
setFormattedCode(cleanCode);
}
};
formatCode();
}, [code, language]);
return (
<Highlight
theme={themes.dracula}
code={formattedCode}
language={language.toLowerCase()}
>
{({
className: preClassName,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<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
}}
>
{i + 1}
</span>
<span className="table-cell">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
))}
</div>
)}
</Highlight>
);
}

View File

@ -38,6 +38,7 @@
"react-dom": "18.2.0",
"react-ga4": "^2.1.0",
"react-photo-view": "^1.2.7",
"shiki": "1.22.2",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",

View File

@ -182,6 +182,9 @@ importers:
react-photo-view:
specifier: ^1.2.7
version: 1.2.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
shiki:
specifier: 1.22.2
version: 1.22.2
tailwind-merge:
specifier: ^2.2.2
version: 2.4.0