Shiki Syntax Highlighting: More features for markdown
Shiki's been used on this blog for some time. But it was missing several features that Shiki provides, including language badges, custom transformers for enhanced metadata, and more granular control over code block styling.
TL;DR
https://jeffry.in is updated with Shiki codeblocks, including:
- Shiki Transformers for titles, diffs, and tooltips
- Simplified Giscus integration using built-in themes
- Performance optimizations with Next.js dynamic imports
Syntax Highlighting Overview
Here's a simple example. In the code block below, observe a title, the language badge, a copy button, and Shiki syntax highlighting.
Basic JavaScript Exampleconst greeting = "Hello, Shiki!"; const numbers = [1, 2, 3, 4, 5]; function calculateSum(arr) { return arr.reduce((sum, num) => sum + num, 0); } console.log(greeting); console.log(`Sum: ${calculateSum(numbers)}`);
Language Support
Shiki supports multiple programming languages. However, I wanted to add the language as a badge which was harder than I had expected.
I found I could view language information from Shiki but I wasn't able to hoist it to code block headers.
Shiki's architecture transforms and executes at different stages of the code rendering pipeline, and language metadata isn't always accessible in the pre-processing phase where it can be injected into header elements. code() and preprocess() hooks run at different times, and getting data to flow between them requires a little state management through the options object. See below.
utils.ts - Language Badge Transformerfunction transformerLanguageBadge() { return { name: "language-badge", root(node) { // Store language from options in root data attribute const lang = this.options.lang || "text"; node.properties["data-language"] = lang; }, pre(node) { // Hoist the language from options to create badge in header const lang = this.options.lang || "text"; // Find or create header let header = node.children.find( (child) => child.type === "element" && child.properties?.className?.includes("code-header"), ); if (!header) { header = { type: "element", tagName: "div", properties: { className: ["code-header"] }, children: [], }; node.children.unshift(header); } // Add language badge to header header.children.push({ type: "element", tagName: "span", properties: { className: ["language-badge"], "data-language": lang, }, children: [{ type: "text", value: lang.toUpperCase() }], }); }, }; }
I created a custom transformer that stores language information in the root node's data attributes during the root() phase, then injects the badge element during the pre() phase when the parent container is available. This approach ensures the language badge appears in the header alongside titles and other metadata.
Advanced Features
Diff Notation
Show additions and deletions in code:
Code Changesfunction calculateTotal(items) { let total = 0; - for (let i = 0; i < items.length; i++) { - total += items[i].price; - } + total = items.reduce((sum, item) => sum + item.price, 0); return total; }
Line Highlighting
Highlight specific lines to draw attention:
Important Linesconst config = { apiUrl: "https://api.example.com", timeout: 5000, // These lines are highlighted retryAttempts: 3, // to show important config enableLogging: true, // values headers: { "Content-Type": "application/json", // This line too! }, };
Word Highlighting
Highlight specific words or patterns:
Security Considerationsconst password = "secret123"; const apiKey = process.env.API_KEY; function authenticate(user, pass) { // Never store passwords in plain text const hashedPassword = hash(pass); return validateCredentials(user, hashedPassword); }
Focus Lines
Focus on specific sections while dimming others:
function processUserData(userData) {
// Validation
if (!userData || !userData.email) {
throw new Error("Invalid user data");
}
// This is the important part to focus on
const normalizedEmail = userData.email.toLowerCase().trim();
const username = normalizedEmail.split("@")[0];
// Save to database
return saveUser({
...userData,
email: normalizedEmail,
username,
});
}Error Level Annotations
Show warnings and errors in code:
function divideNumbers(a, b) {
if (b === 0) {
throw new Error("Division by zero!");
}
if (typeof a !== "number" || typeof b !== "number") {
console.warn("Non-numeric input detected");
}
return a / b;
}Interactive Tooltips
Add helpful tooltips to explain code concepts:
Tooltips Exampleconst API_URL = 'https://api.example.com'; function fetchUser(id) { const endpoint = `${API_URL}/users/${id}`; return fetch(endpoint).then(res => res.json()); }
Long Code with Horizontal Scrolling
Here's an example with long lines to demonstrate the copy button staying visible:
Long Lines Exampleconst veryLongConfigurationObject = { apiEndpoint: "https://api.example.com/v1/users", timeout: 5000, retryAttempts: 3, headers: { "Content-Type": "application/json", Authorization: "Bearer token_goes_here", }, options: { enableCache: true, cacheTimeout: 3600, enableLogging: true, logLevel: "debug", }, }; const anotherLongLine = "This is a very long string that extends far beyond the typical viewport width to demonstrate how the syntax highlighting handles horizontal scrolling while keeping the copy button accessible and visible at all times.";
Implementation Details: What Changed
1. React-Based Copy Button with Dynamic Mounting
Replaced the inline SVG copy button with a React component that gets dynamically mounted:
hooks/useCodeBlocks.tsx - Dynamic Copy Button Mountingexport function useCodeBlocks() { const rootsRef = useRef<Map<HTMLElement, Root>>(new Map()); const mountCopyButtons = useCallback(() => { const placeholders = document.querySelectorAll('.copy-button-placeholder'); placeholders.forEach((placeholder) => { const element = placeholder as HTMLElement; const codeId = element.getAttribute('data-code-id'); if (!codeId || rootsRef.current.has(element)) return; const root = createRoot(element); root.render(<CopyButton codeId={codeId} />); rootsRef.current.set(element, root); }); }, []); // MutationObserver watches for new code blocks added dynamically // ... observer setup code }
components/CopyButton.tsx - React Copy Componentexport default function CopyButton({ codeId }: CopyButtonProps) { const [copied, setCopied] = useState(false); const handleCopy = async () => { const codeElement = document.querySelector(`#${codeId} code`); if (!codeElement) return; const code = codeElement.textContent || ""; await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( <button className="shiki-copy-button" onClick={handleCopy}> {copied ? <Check size={16} /> : <Copy size={16} />} </button> ); }
2. Custom Transformers for Enhanced Features
Created custom Shiki transformers to handle titles and diff lines:
utils.ts - Custom Title Transformerfunction transformerTitle() { return { name: "title", preprocess(code, options) { // Extract title from comments like: // [title: My Title] if (!code.includes("[title:")) return code; const lines = code.split("\n"); const firstLine = lines[0]; const titleStart = firstLine.indexOf("[title:"); const titleEnd = firstLine.indexOf("]", titleStart); if (titleStart !== -1 && titleEnd > titleStart) { const title = firstLine.slice(titleStart + 7, titleEnd).trim(); options.meta = { ...options.meta, title }; return lines.slice(1).join("\n"); // Remove title line from code } return code; }, }; }
utils.ts - Diff Lines Transformerfunction transformerDiffLines() { return { name: "diff-lines", code(node) { node.children?.forEach((line) => { const firstText = getFirstText(line.children[0]); if (firstText.startsWith("+")) { // Add classes for additions line.properties.class.push("diff", "add"); } else if (firstText.startsWith("-")) { // Add classes for deletions line.properties.class.push("diff", "remove"); } }); }, }; }
utils.ts - Tooltip Transformerfunction transformerTooltip() { return { name: "tooltip", code(node) { node.children.forEach((line) => { line.children = line.children.flatMap((child) => { if (child.type !== "text") return [child]; const tooltipPattern = /(\S+)\]+)\]/g; const parts = parseTooltips(child.value, tooltipPattern); return parts.length > 0 ? parts : [child]; }); }); }, }; }
3. Improved CSS Architecture
Rewrote the code block styles with:
- Table-based layout for proper line numbering
- Sticky positioning for line numbers during scroll
- Dark mode support with CSS custom properties
- Optimized Sass using scalable variable system (
$m-*sizing,$radius-*for borders)
4. Giscus Theme & Dynamic Import Improvements
- Switched from custom-hosted CSS to built-in Giscus themes (
dark/light) - Replaced lazy loading with Next.js dynamic imports for better SSR handling
- Added proper loading states and error boundaries
Conclusion
A few challenges came up during this implementation:
Transformer Execution Order - Understanding when each transformer hook executes was crucial. The preprocess() hook runs before tokenization, code() during rendering, and pre() after the code block is assembled. Getting data to flow between these stages required careful use of the options object and node properties.
React Hydration Issues - Server-rendered copy buttons failed to hydrate properly in Next.js. I solved this by using placeholders during SSR and dynamically mounting React components client-side with a MutationObserver to detect new code blocks.
Sticky Positioning in Tables - Achieving sticky line numbers while maintaining proper code alignment required switching from a flex layout to a table-based layout. This keeps line numbers visible during horizontal scrolling while the code content scrolls naturally.
Copy Button State Management - Managing the copied state across multiple code blocks required generating unique IDs for each block and ensuring the React components tracked which block was just copied from. The data-code-id attribute system solved this cleanly.