Skip to content

Commit fdabb6a

Browse files
committed
feat: add copy button functionality to code blocks in README
1 parent a2b6922 commit fdabb6a

2 files changed

Lines changed: 88 additions & 3 deletions

File tree

app/components/Readme.vue

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
1-
<!-- simple component only taking slot -->
1+
<script setup lang="ts">
2+
const { copy } = useClipboard()
3+
4+
const handleCopy = async (e: MouseEvent) => {
5+
const target = (e.target as HTMLElement).closest('[data-copy]')
6+
if (!target) return
7+
8+
// Find the code block sibling
9+
const wrapper = target.closest('.readme-code-block')
10+
if (!wrapper) return
11+
12+
const pre = wrapper.querySelector('pre')
13+
if (!pre?.textContent) return
14+
15+
await copy(pre.textContent)
16+
17+
// Visual feedback
18+
const icon = target.querySelector('span')
19+
if (icon) {
20+
const originalIcon = 'i-carbon:copy'
21+
const successIcon = 'i-carbon:checkmark'
22+
23+
icon.classList.remove(originalIcon)
24+
icon.classList.add(successIcon)
25+
26+
setTimeout(() => {
27+
icon.classList.remove(successIcon)
28+
icon.classList.add(originalIcon)
29+
}, 2000)
30+
}
31+
}
32+
</script>
33+
234
<template>
3-
<article class="readme prose prose-invert max-w-[70ch]">
35+
<article class="readme prose prose-invert max-w-[70ch]" @click="handleCopy">
36+
<!-- Hidden element to safelist icons for dynamic usage -->
37+
<div class="hidden i-carbon:copy i-carbon:checkmark"></div>
438
<slot />
539
</article>
640
</template>
@@ -96,6 +130,48 @@
96130
box-sizing: border-box;
97131
}
98132
133+
.readme :deep(.readme-code-block) {
134+
display: block;
135+
width: 100%;
136+
position: relative;
137+
}
138+
139+
.readme :deep(.readme-copy-button) {
140+
position: absolute;
141+
top: 0.4rem;
142+
right: 0.4rem;
143+
left: auto;
144+
display: inline-flex;
145+
align-items: center;
146+
justify-content: center;
147+
padding: 0.25rem;
148+
border-radius: 6px;
149+
background: color-mix(in srgb, var(--bg-subtle) 80%, transparent);
150+
border: 1px solid var(--border);
151+
color: var(--fg-subtle);
152+
opacity: 0;
153+
transition:
154+
opacity 0.2s ease,
155+
color 0.2s ease,
156+
border-color 0.2s ease;
157+
}
158+
159+
.readme :deep(.readme-code-block:hover .readme-copy-button) {
160+
opacity: 1;
161+
}
162+
163+
.readme :deep(.readme-copy-button:hover) {
164+
color: var(--fg);
165+
border-color: var(--border-hover);
166+
}
167+
168+
.readme :deep(.readme-copy-button > span) {
169+
width: 1.05rem;
170+
height: 1.05rem;
171+
display: inline-block;
172+
pointer-events: none;
173+
}
174+
99175
.readme :deep(pre code),
100176
.readme :deep(.shiki code) {
101177
background: transparent !important;

server/utils/readme.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,14 @@ const ALLOWED_TAGS = [
135135
'sub',
136136
'kbd',
137137
'mark',
138+
'button',
138139
]
139140

140141
const ALLOWED_ATTR: Record<string, string[]> = {
141142
a: ['href', 'title', 'target', 'rel'],
142143
img: ['src', 'alt', 'title', 'width', 'height'],
143144
source: ['src', 'srcset', 'type', 'media'],
145+
button: ['class', 'title', 'type', 'aria-label', 'data-copy'],
144146
th: ['colspan', 'rowspan', 'align'],
145147
td: ['colspan', 'rowspan', 'align'],
146148
h3: ['id', 'data-level'],
@@ -305,7 +307,14 @@ export async function renderReadmeHtml(
305307

306308
// Syntax highlighting for code blocks (uses shared highlighter)
307309
renderer.code = ({ text, lang }: Tokens.Code) => {
308-
return highlightCodeSync(shiki, text, lang || 'text')
310+
const html = highlightCodeSync(shiki, text, lang || 'text')
311+
// Add copy button
312+
return `<div class="readme-code-block" dir="ltr">
313+
<button type="button" class="readme-copy-button" title="Copy code" check-icon="i-carbon:checkmark" copy-icon="i-carbon:copy" data-copy>
314+
<span class="i-carbon:copy" aria-hidden="true"></span>
315+
</button>
316+
${html}
317+
</div>`
309318
}
310319

311320
// Resolve image URLs (with GitHub blob → raw conversion)

0 commit comments

Comments
 (0)