Skip to content

Commit 990526b

Browse files
authored
Implement Code Block component with syntax highlighting
This web component provides syntax highlighting and a copy button for code blocks. It supports multiple programming languages and includes styles for better presentation.
1 parent 50fd0f7 commit 990526b

1 file changed

Lines changed: 328 additions & 0 deletions

File tree

code-block.js

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/**
2+
* Code Block Web Component with Syntax Highlighting and Copy Button
3+
* Based on syntax-highlight-element but with added copy functionality
4+
*/
5+
export class CodeBlock extends HTMLElement {
6+
constructor() {
7+
super();
8+
this.attachShadow({ mode: 'open' });
9+
}
10+
11+
connectedCallback() {
12+
this.render();
13+
}
14+
15+
static get observedAttributes() {
16+
return ['language', 'label'];
17+
}
18+
19+
attributeChangedCallback() {
20+
if (this.shadowRoot) {
21+
this.render();
22+
}
23+
}
24+
25+
get language() {
26+
return this.getAttribute('language') || 'css';
27+
}
28+
29+
get label() {
30+
return this.getAttribute('label') || this.language.toUpperCase();
31+
}
32+
33+
async copyCode() {
34+
// Get raw text content
35+
const rawCode = this.textContent.trim();
36+
37+
// Unescape HTML entities (convert &lt; to <, &gt; to >, etc.)
38+
const tempDiv = document.createElement('div');
39+
tempDiv.innerHTML = rawCode;
40+
const unescapedCode = tempDiv.textContent;
41+
42+
try {
43+
await navigator.clipboard.writeText(unescapedCode);
44+
const button = this.shadowRoot.querySelector('.copy-button');
45+
const originalText = button.textContent;
46+
button.textContent = 'Copied';
47+
button.classList.add('copied');
48+
setTimeout(() => {
49+
button.textContent = originalText;
50+
button.classList.remove('copied');
51+
}, 2000);
52+
} catch (err) {
53+
console.error('Failed to copy code:', err);
54+
}
55+
}
56+
57+
render() {
58+
const code = this.textContent.trim();
59+
const escapedCode = this.escapeHtml(code);
60+
61+
this.shadowRoot.innerHTML = `
62+
<style>
63+
:host {
64+
display: block;
65+
margin: 1rem 0;
66+
border-radius: 8px;
67+
overflow: hidden;
68+
border: 1px solid #e1e4e8;
69+
background: #f6f8fa;
70+
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
71+
}
72+
73+
.header {
74+
display: flex;
75+
justify-content: space-between;
76+
align-items: center;
77+
padding: 0.5rem 1rem;
78+
background: #e1e4e8;
79+
border-bottom: 1px solid #d1d5da;
80+
}
81+
82+
.label {
83+
font-size: 0.75rem;
84+
font-weight: 600;
85+
color: #586069;
86+
text-transform: uppercase;
87+
letter-spacing: 0.5px;
88+
}
89+
90+
.copy-button {
91+
background: #fff;
92+
border: 1px solid #d1d5da;
93+
border-radius: 4px;
94+
padding: 4px 12px;
95+
font-size: 0.75rem;
96+
font-weight: 500;
97+
color: #24292e;
98+
cursor: pointer;
99+
transition: all 0.2s ease;
100+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
101+
}
102+
103+
.copy-button:hover {
104+
background: #f3f4f6;
105+
border-color: #959da5;
106+
}
107+
108+
.copy-button:active {
109+
background: #e1e4e8;
110+
transform: scale(0.98);
111+
}
112+
113+
.copy-button.copied {
114+
background: #28a745;
115+
color: white;
116+
border-color: #28a745;
117+
}
118+
119+
.code-container {
120+
padding: 1rem;
121+
overflow-x: auto;
122+
background: #fff;
123+
}
124+
125+
pre {
126+
margin: 0;
127+
padding: 0;
128+
font-size: 0.875rem;
129+
line-height: 1.6;
130+
}
131+
132+
code {
133+
display: block;
134+
font-family: inherit;
135+
color: #24292e;
136+
}
137+
138+
/* Syntax highlighting styles */
139+
.token.comment,
140+
.token.prolog,
141+
.token.doctype,
142+
.token.cdata {
143+
color: #6a737d;
144+
font-style: italic;
145+
}
146+
147+
.token.property,
148+
.token.tag,
149+
.token.boolean,
150+
.token.number,
151+
.token.constant,
152+
.token.symbol,
153+
.token.deleted {
154+
color: #005cc5;
155+
}
156+
157+
.token.selector,
158+
.token.attr-name,
159+
.token.string,
160+
.token.char,
161+
.token.builtin,
162+
.token.inserted {
163+
color: #22863a;
164+
}
165+
166+
.token.operator,
167+
.token.entity,
168+
.token.url,
169+
.language-css .token.string,
170+
.style .token.string {
171+
color: #d73a49;
172+
}
173+
174+
.token.atrule,
175+
.token.attr-value,
176+
.token.keyword {
177+
color: #d73a49;
178+
}
179+
180+
.token.function,
181+
.token.class-name {
182+
color: #6f42c1;
183+
}
184+
185+
.token.regex,
186+
.token.important,
187+
.token.variable {
188+
color: #e36209;
189+
}
190+
</style>
191+
<div class="header">
192+
<span class="label">${this.escapeHtml(this.label)}</span>
193+
<button class="copy-button" title="Copy code">Copy</button>
194+
</div>
195+
<div class="code-container">
196+
<pre><code class="language-${this.language}">${escapedCode}</code></pre>
197+
</div>
198+
`;
199+
200+
// Add copy button event listener
201+
const button = this.shadowRoot.querySelector('.copy-button');
202+
button.addEventListener('click', () => this.copyCode());
203+
204+
// Apply basic syntax highlighting with the escaped code
205+
this.applySyntaxHighlighting(escapedCode);
206+
}
207+
208+
escapeHtml(text) {
209+
const div = document.createElement('div');
210+
div.textContent = text;
211+
return div.innerHTML;
212+
}
213+
214+
applySyntaxHighlighting(escapedText) {
215+
const code = this.shadowRoot.querySelector('code');
216+
let highlighted = escapedText;
217+
218+
if (this.language === 'css') {
219+
// Simple CSS highlighting - process in order to avoid regex conflicts
220+
// Use placeholders to protect highlighted content from subsequent replacements
221+
const tokens = [];
222+
let tokenIndex = 0;
223+
224+
// Comments first
225+
highlighted = escapedText.replace(/(\/\*[\s\S]*?\*\/)/g, (match) => {
226+
const placeholder = `__TOKEN_${tokenIndex}__`;
227+
tokens[tokenIndex] = `<span class="token comment">${match}</span>`;
228+
tokenIndex++;
229+
return placeholder;
230+
});
231+
232+
// Selectors (before opening brace)
233+
highlighted = highlighted.replace(/^([^{]+)(?={)/gm, (match) => {
234+
// Skip if this line only contains placeholders
235+
if (match.trim().startsWith('__TOKEN_')) return match;
236+
return match.replace(/([.#]?[\w-]+)/g, (selectorMatch) => {
237+
// Don't highlight placeholder tokens
238+
if (selectorMatch.startsWith('__TOKEN_')) return selectorMatch;
239+
return `<span class="token selector">${selectorMatch}</span>`;
240+
});
241+
});
242+
243+
// Properties
244+
highlighted = highlighted.replace(/(\w[\w-]*)\s*:/g, (match, prop) => {
245+
// Don't highlight placeholder tokens
246+
if (prop.startsWith('__TOKEN_')) return match;
247+
return `<span class="token property">${prop}</span>:`;
248+
});
249+
250+
// Values
251+
highlighted = highlighted.replace(/:\s*([^;{]+)/g, (match, value) => {
252+
// Don't process values containing placeholder tokens
253+
if (value.includes('__TOKEN_')) return match;
254+
// Colors
255+
value = value.replace(/(#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|rgba\([^)]+\))/g, '<span class="token number">$1</span>');
256+
// Numbers with units
257+
value = value.replace(/(\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms)?)/g, '<span class="token number">$1</span>');
258+
// Keywords
259+
value = value.replace(/\b(important|inherit|initial|unset|auto|none|solid|dashed|dotted|bold|italic|normal|flex|grid|block|inline|inline-block)\b/g, '<span class="token keyword">$1</span>');
260+
return ': ' + value;
261+
});
262+
263+
// Restore comment tokens
264+
highlighted = highlighted.replace(/__TOKEN_(\d+)__/g, (match, index) => tokens[index]);
265+
} else if (this.language === 'html' || this.language === 'markup') {
266+
// Simple HTML highlighting with placeholder protection
267+
const htmlTokens = [];
268+
let htmlTokenIndex = 0;
269+
270+
// First, protect comments
271+
highlighted = escapedText.replace(/(&lt;!--[\s\S]*?--&gt;)/g, (match) => {
272+
const placeholder = `__HTMLTOKEN_${htmlTokenIndex}__`;
273+
htmlTokens[htmlTokenIndex] = `<span class="token comment">${match}</span>`;
274+
htmlTokenIndex++;
275+
return placeholder;
276+
});
277+
278+
// Protect and highlight complete tags with their attributes
279+
highlighted = highlighted.replace(/(&lt;\/?[\w-]+[^&]*?&gt;)/g, (match) => {
280+
// First, unescape to work with actual < > characters
281+
let unescaped = match.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
282+
let tagHighlighted = '';
283+
284+
// Match opening tag with optional attributes
285+
const tagMatch = unescaped.match(/^<(\/?)([^\s>]+)([^>]*)>$/);
286+
if (tagMatch) {
287+
const [, slash, tagName, attrs] = tagMatch;
288+
tagHighlighted += '<span class="token tag">&lt;' + slash + tagName;
289+
290+
if (attrs) {
291+
// Process attributes
292+
let processedAttrs = attrs.replace(/\s+([\w-]+)="([^"]*)"/g, (m, attrName, attrValue) => {
293+
return ' <span class="token attr-name">' + attrName + '</span>=<span class="token attr-value">"' + attrValue + '"</span>';
294+
});
295+
tagHighlighted += processedAttrs;
296+
}
297+
298+
tagHighlighted += '&gt;</span>';
299+
} else {
300+
tagHighlighted = match;
301+
}
302+
303+
const placeholder = `__HTMLTOKEN_${htmlTokenIndex}__`;
304+
htmlTokens[htmlTokenIndex] = tagHighlighted;
305+
htmlTokenIndex++;
306+
return placeholder;
307+
});
308+
309+
// Restore all tokens
310+
highlighted = highlighted.replace(/__HTMLTOKEN_(\d+)__/g, (match, index) => htmlTokens[index]);
311+
} else if (this.language === 'javascript' || this.language === 'js') {
312+
// Simple JavaScript highlighting
313+
highlighted = escapedText
314+
// Comments
315+
.replace(/(\/\/.*$|\/\*[\s\S]*?\*\/)/gm, '<span class="token comment">$1</span>')
316+
// Strings
317+
.replace(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g, '<span class="token string">$1$2$1</span>')
318+
// Keywords
319+
.replace(/\b(const|let|var|function|return|if|else|for|while|class|import|export|from|default)\b/g, '<span class="token keyword">$1</span>')
320+
// Functions
321+
.replace(/\b([a-zA-Z_$][\w$]*)\s*\(/g, '<span class="token function">$1</span>(');
322+
}
323+
324+
code.innerHTML = highlighted;
325+
}
326+
}
327+
328+
customElements.define('code-block', CodeBlock);

0 commit comments

Comments
 (0)