Skip to content

Commit 08e70b0

Browse files
committed
feat: add code-block for markdown
1 parent d9a8085 commit 08e70b0

File tree

2 files changed

+184
-62
lines changed

2 files changed

+184
-62
lines changed

src/utils/copy-code.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Initialize copy functionality for code blocks
3+
*/
4+
export function initializeCodeCopy() {
5+
document.addEventListener('click', (e) => {
6+
const target = e.target as HTMLElement;
7+
const button = target.closest('.copy-code-button');
8+
9+
if (button) {
10+
const preElement = button.parentElement?.querySelector('pre');
11+
if (preElement) {
12+
const codeContent = preElement.textContent || '';
13+
navigator.clipboard.writeText(codeContent).then(() => {
14+
// Change button text temporarily after copying
15+
button.innerHTML = `
16+
<span class="flex items-center">
17+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
18+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
19+
</svg>
20+
Copied!
21+
</span>
22+
`;
23+
button.classList.add('text-green-400');
24+
25+
// Reset button after 2 seconds
26+
setTimeout(() => {
27+
button.innerHTML = 'Copy';
28+
button.classList.remove('text-green-400');
29+
}, 2000);
30+
}).catch(() => {
31+
// Show error state
32+
button.innerHTML = `
33+
<span class="flex items-center">
34+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
35+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
36+
</svg>
37+
Error!
38+
</span>
39+
`;
40+
button.classList.add('text-red-400');
41+
42+
// Reset button after 2 seconds
43+
setTimeout(() => {
44+
button.innerHTML = 'Copy';
45+
button.classList.remove('text-red-400');
46+
}, 2000);
47+
});
48+
}
49+
}
50+
});
51+
}

src/utils/mdparser-utils.ts

Lines changed: 133 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,84 @@ export function escapeHtml(unsafe: string): string {
1515
.replace(/'/g, '&#039;');
1616
}
1717

18+
/**
19+
* Escape special characters in a string for use in a regular expression
20+
*/
21+
function escapeRegExp(string: string): string {
22+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23+
}
24+
25+
/**
26+
* Process code blocks in markdown text
27+
*/
28+
function processCodeBlocks(markdown: string): { html: string; codeBlocks: { [key: string]: string } } {
29+
const codeBlocks: { [key: string]: string } = {};
30+
let codeBlockCounter = 0;
31+
32+
// Replace code blocks with placeholders
33+
const processedHtml = markdown.replace(
34+
/```([\w-]*)\n([\s\S]*?)```/gm,
35+
(match, language, code) => {
36+
// Create a unique placeholder that won't appear in normal text
37+
const placeholder = `__CODEBLOCK_${Math.random().toString(36).substring(2)}_${codeBlockCounter}__`;
38+
const escapedCode = escapeHtml(code.trim());
39+
const langClass = language ? `language-${language}` : '';
40+
const codeId = `code-${Math.random().toString(36).substring(2, 9)}`;
41+
42+
// Only add the language label if a language is specified
43+
const languageLabel = language ? `<span class="text-xs font-mono">${language}</span>` : '';
44+
45+
// Log the code content being set in the data-code attribute
46+
console.log('Setting data-code attribute with code:', code);
47+
48+
codeBlocks[placeholder] = `<div class="relative my-6 rounded-lg overflow-hidden bg-gray-800 shadow-lg">
49+
<button class="copy-code-button absolute top-2 right-2 text-gray-400 hover:text-white transition-colors px-2 py-1 rounded text-sm flex items-center" data-code="${code.replace(/"/g, '&quot;')}" aria-label="Copy code to clipboard">
50+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
51+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
52+
</svg>
53+
</button>
54+
<pre id="${codeId}" class="p-4 overflow-x-auto text-gray-300 text-sm"><code class="${langClass}">${escapedCode}</code></pre>
55+
</div>`;
56+
codeBlockCounter++;
57+
return placeholder;
58+
}
59+
);
60+
61+
return { html: processedHtml, codeBlocks };
62+
}
63+
64+
/**
65+
* Process inline code in markdown text
66+
*/
67+
function processInlineCode(markdown: string): { html: string; inlineCode: { [key: string]: string } } {
68+
const inlineCode: { [key: string]: string } = {};
69+
let inlineCounter = 0;
70+
71+
// Replace inline code with placeholders
72+
const processedHtml = markdown.replace(/(?<!`)`([^`\n]+?)`(?!`)/g, (match, code) => {
73+
const placeholder = `__INLINE_${Math.random().toString(36).substring(2)}_${inlineCounter}__`;
74+
inlineCode[placeholder] = `<code class="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">${escapeHtml(code)}</code>`;
75+
inlineCounter++;
76+
return placeholder;
77+
});
78+
79+
return { html: processedHtml, inlineCode };
80+
}
81+
1882
/**
1983
* Renders markdown text into styled HTML
2084
*/
2185
export const renderMarkdown = (markdown: string): string => {
22-
let html = markdown;
86+
// Normalize line endings to \n
87+
markdown = markdown.replace(/\r\n/g, '\n');
88+
89+
// First, process code blocks and store them safely
90+
const { html: htmlWithCodeBlockPlaceholders, codeBlocks } = processCodeBlocks(markdown);
91+
92+
// Then, process inline code and store them safely
93+
const { html: htmlWithAllPlaceholders, inlineCode } = processInlineCode(htmlWithCodeBlockPlaceholders);
94+
95+
let html = htmlWithAllPlaceholders;
2396

2497
// Create IDs for headers to enable anchor links
2598
const headerToId = (text: string) => {
@@ -29,60 +102,7 @@ export const renderMarkdown = (markdown: string): string => {
29102
.replace(/(^-|-$)/g, '');
30103
};
31104

32-
// Process blockquotes
33-
html = html.replace(/(^>.*(\n>.*)*)/gm, function (match) {
34-
const lines = match.split('\n');
35-
let content = '';
36-
37-
lines.forEach((line) => {
38-
const match = line.match(/^>(>*)\s?(.*)$/);
39-
if (match) {
40-
const nestedChars = match[1];
41-
const lineContent = match[2];
42-
const nestLevel = nestedChars.length + 1;
43-
44-
const borderColorClasses = [
45-
'border-blue-300 bg-blue-50',
46-
'border-purple-300 bg-purple-50',
47-
'border-green-300 bg-green-50',
48-
'border-orange-300 bg-orange-50',
49-
];
50-
51-
const borderClass =
52-
borderColorClasses[(nestLevel - 1) % borderColorClasses.length];
53-
const marginClass = nestLevel > 1 ? 'ml-4' : '';
54-
const contentToAdd = lineContent.trim() === '' ? '<br>' : lineContent;
55-
if (content === '') {
56-
content = `<blockquote class="border-l-4 ${borderClass} ${marginClass} pl-4 py-2 my-4 italic text-gray-700 rounded-r-md">${contentToAdd}`;
57-
} else {
58-
content += `<br>${contentToAdd}`;
59-
}
60-
}
61-
});
62-
if (content !== '') {
63-
content += '</blockquote>';
64-
}
65-
66-
return content;
67-
});
68-
69-
html = html.replace(
70-
/!\[(.*?)\]\((.*?)(?:\s"(.*?)")?\)/gim,
71-
function (_, alt, src, title) {
72-
const caption = title
73-
? `<figcaption class="text-center text-sm text-gray-600 mt-2">${title}</figcaption>`
74-
: '';
75-
76-
return `<figure class="flex flex-col items-center my-6">
77-
<div class="overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300">
78-
<img src="${src}" alt="${alt}" class="max-w-full sm:max-w-md md:max-w-lg rounded-lg object-contain max-h-80 hover:scale-105 transition-transform duration-300" loading="lazy" data-zoomable="true" />
79-
</div>
80-
${caption}
81-
</figure>`;
82-
},
83-
);
84-
85-
// Convert headers with anchor links
105+
// Process headers
86106
html = html.replace(/^### (.*$)/gim, (_, title) => {
87107
const id = headerToId(title);
88108
return `<h3 id="${id}" class="text-xl font-bold my-4 text-gray-800 group flex items-center">
@@ -116,7 +136,7 @@ export const renderMarkdown = (markdown: string): string => {
116136
<a aria-hidden="true" tabindex="-1" href="#${id}" class="ml-2 text-blue-500 opacity-0 group-hover:opacity-100 transition-opacity">
117137
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
118138
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
119-
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
139+
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83-2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
120140
</svg>
121141
</a>
122142
</h1>`;
@@ -159,12 +179,6 @@ export const renderMarkdown = (markdown: string): string => {
159179
'<a href="$2" class="text-blue-600 hover:underline transition-colors duration-200">$1</a>',
160180
);
161181

162-
// Convert inline code
163-
html = html.replace(
164-
/`(.*?)`/gim,
165-
'<code class="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">$1</code>',
166-
);
167-
168182
// Improved table handling
169183
const tableRegex = /^\|(.+)\|(\r?\n\|[-|\s]+\|)(\r?\n\|.+\|)+/gm;
170184
html = html.replace(tableRegex, function (match) {
@@ -203,6 +217,7 @@ export const renderMarkdown = (markdown: string): string => {
203217
tableHtml += '</tbody></table></div>';
204218
return tableHtml;
205219
});
220+
206221
html = html.replace(
207222
/^\s*- \[([ xX])\] (.+)$/gim,
208223
function (_, checked, content) {
@@ -296,6 +311,15 @@ export const renderMarkdown = (markdown: string): string => {
296311
'<p class="my-4 text-gray-700 leading-relaxed">$1</p>',
297312
);
298313

314+
// After processing all other elements, restore code blocks and inline code
315+
Object.entries(codeBlocks).forEach(([placeholder, codeHtml]) => {
316+
html = html.replace(new RegExp(escapeRegExp(placeholder), 'g'), codeHtml);
317+
});
318+
319+
Object.entries(inlineCode).forEach(([placeholder, codeHtml]) => {
320+
html = html.replace(new RegExp(escapeRegExp(placeholder), 'g'), codeHtml);
321+
});
322+
299323
// Style first paragraph's first letter (if it exists)
300324
html = html.replace(
301325
/<p class="my-4 text-gray-700 leading-relaxed">(\w)/,
@@ -329,3 +353,50 @@ export const renderMarkdown = (markdown: string): string => {
329353

330354
return html;
331355
};
356+
357+
/**
358+
* Initialize syntax highlighting for code blocks if a syntax highlighting library is available
359+
* This function should be called after the DOM is loaded
360+
*/
361+
export function initializeSyntaxHighlighting(): void {
362+
// Check if Prism or Highlight.js is available
363+
const hasPrism = typeof (window as any).Prism !== 'undefined';
364+
const hasHighlightJs = typeof (window as any).hljs !== 'undefined';
365+
366+
if (hasPrism) {
367+
// If Prism is available, let it automatically highlight all code blocks
368+
// Prism does this automatically for elements with language-* classes
369+
try {
370+
(window as any).Prism.highlightAll();
371+
} catch (error) {
372+
console.error('Error initializing Prism syntax highlighting:', error);
373+
}
374+
} else if (hasHighlightJs) {
375+
// If Highlight.js is available, manually highlight each code block
376+
try {
377+
document.querySelectorAll('pre code').forEach((block) => {
378+
(window as any).hljs.highlightBlock(block);
379+
});
380+
} catch (error) {
381+
console.error('Error initializing Highlight.js syntax highlighting:', error);
382+
}
383+
}
384+
}
385+
386+
/**
387+
* Initialize all markdown-related features
388+
* This function should be called after the DOM is loaded
389+
*/
390+
export function initializeMarkdownFeatures(): void {
391+
// Import and initialize code copy functionality
392+
document.addEventListener('DOMContentLoaded', () => {
393+
import('./copy-code').then(({ initializeCodeCopy }) => {
394+
initializeCodeCopy();
395+
}).catch(error => {
396+
console.error('Error initializing code copy functionality:', error);
397+
});
398+
399+
// Initialize syntax highlighting
400+
initializeSyntaxHighlighting();
401+
});
402+
}

0 commit comments

Comments
 (0)