Skip to content

Commit d714591

Browse files
feat: add copy button functionality to code blocks in README (#636)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent d3b742a commit d714591

File tree

3 files changed

+143
-3
lines changed

3 files changed

+143
-3
lines changed

app/components/Readme.vue

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,43 @@
22
defineProps<{
33
html: string
44
}>()
5+
6+
const { copy } = useClipboard()
7+
8+
const handleCopy = async (e: MouseEvent) => {
9+
const target = (e.target as HTMLElement).closest('[data-copy]')
10+
if (!target) return
11+
12+
const wrapper = target.closest('.readme-code-block')
13+
if (!wrapper) return
14+
15+
const pre = wrapper.querySelector('pre')
16+
if (!pre?.textContent) return
17+
18+
await copy(pre.textContent)
19+
20+
const icon = target.querySelector('span')
21+
if (!icon) return
22+
23+
const originalIcon = 'i-carbon:copy'
24+
const successIcon = 'i-carbon:checkmark'
25+
26+
icon.classList.remove(originalIcon)
27+
icon.classList.add(successIcon)
28+
29+
setTimeout(() => {
30+
icon.classList.remove(successIcon)
31+
icon.classList.add(originalIcon)
32+
}, 2000)
33+
}
534
</script>
635

736
<template>
8-
<article class="readme prose prose-invert max-w-[70ch] lg:max-w-none" v-html="html" />
37+
<article
38+
class="readme prose prose-invert max-w-[70ch] lg:max-w-none"
39+
v-html="html"
40+
@click="handleCopy"
41+
/>
942
</template>
1043

1144
<style scoped>
@@ -99,6 +132,90 @@ defineProps<{
99132
box-sizing: border-box;
100133
}
101134
135+
.readme :deep(.readme-code-block) {
136+
display: block;
137+
width: 100%;
138+
position: relative;
139+
}
140+
141+
.readme :deep(.readme-copy-button) {
142+
position: absolute;
143+
top: 0.4rem;
144+
inset-inline-end: 0.4rem;
145+
display: inline-flex;
146+
align-items: center;
147+
justify-content: center;
148+
padding: 0.25rem;
149+
border-radius: 6px;
150+
background: color-mix(in srgb, var(--bg-subtle) 80%, transparent);
151+
border: 1px solid var(--border);
152+
color: var(--fg-subtle);
153+
opacity: 0;
154+
transition:
155+
opacity 0.2s ease,
156+
color 0.2s ease,
157+
border-color 0.2s ease;
158+
}
159+
160+
.readme :deep(.readme-code-block:hover .readme-copy-button),
161+
.readme :deep(.readme-copy-button:focus-visible) {
162+
opacity: 1;
163+
}
164+
165+
.readme :deep(.readme-copy-button:hover) {
166+
color: var(--fg);
167+
border-color: var(--border-hover);
168+
}
169+
170+
.readme :deep(.readme-copy-button > span) {
171+
width: 1rem;
172+
height: 1rem;
173+
display: inline-block;
174+
pointer-events: none;
175+
}
176+
177+
.readme :deep(.readme-code-block) {
178+
display: block;
179+
width: 100%;
180+
position: relative;
181+
}
182+
183+
.readme :deep(.readme-copy-button) {
184+
position: absolute;
185+
top: 0.4rem;
186+
inset-inline-end: 0.4rem;
187+
display: inline-flex;
188+
align-items: center;
189+
justify-content: center;
190+
padding: 0.25rem;
191+
border-radius: 6px;
192+
background: color-mix(in srgb, var(--bg-subtle) 80%, transparent);
193+
border: 1px solid var(--border);
194+
color: var(--fg-subtle);
195+
opacity: 0;
196+
transition:
197+
opacity 0.2s ease,
198+
color 0.2s ease,
199+
border-color 0.2s ease;
200+
}
201+
202+
.readme :deep(.readme-code-block:hover .readme-copy-button),
203+
.readme :deep(.readme-copy-button:focus-visible) {
204+
opacity: 1;
205+
}
206+
207+
.readme :deep(.readme-copy-button:hover) {
208+
color: var(--fg);
209+
border-color: var(--border-hover);
210+
}
211+
212+
.readme :deep(.readme-copy-button > span) {
213+
width: 1.05rem;
214+
height: 1.05rem;
215+
display: inline-block;
216+
pointer-events: none;
217+
}
218+
102219
.readme :deep(pre code),
103220
.readme :deep(.shiki code) {
104221
background: transparent !important;
@@ -308,4 +425,17 @@ defineProps<{
308425
margin: 0 0.25rem 0.25rem 0;
309426
border-radius: 4px;
310427
}
428+
429+
/* Screen reader only text */
430+
.readme :deep(.sr-only) {
431+
position: absolute;
432+
width: 1px;
433+
height: 1px;
434+
padding: 0;
435+
margin: -1px;
436+
overflow: hidden;
437+
clip: rect(0, 0, 0, 0);
438+
white-space: nowrap;
439+
border-width: 0;
440+
}
311441
</style>

server/api/registry/readme/[...pkg].get.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default defineCachedEventHandler(
126126
swr: true,
127127
getKey: event => {
128128
const pkg = getRouterParam(event, 'pkg') ?? ''
129-
return `readme:v5:${pkg.replace(/\/+$/, '').trim()}`
129+
return `readme:v6:${pkg.replace(/\/+$/, '').trim()}`
130130
},
131131
},
132132
)

server/utils/readme.ts

Lines changed: 11 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', 'align'],
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', 'align'],
@@ -306,7 +308,15 @@ export async function renderReadmeHtml(
306308

307309
// Syntax highlighting for code blocks (uses shared highlighter)
308310
renderer.code = ({ text, lang }: Tokens.Code) => {
309-
return highlightCodeSync(shiki, text, lang || 'text')
311+
const html = highlightCodeSync(shiki, text, lang || 'text')
312+
// Add copy button
313+
return `<div class="readme-code-block" >
314+
<button type="button" class="readme-copy-button" aria-label="Copy code" check-icon="i-carbon:checkmark" copy-icon="i-carbon:copy" data-copy>
315+
<span class="i-carbon:copy" aria-hidden="true"></span>
316+
<span class="sr-only">Copy code</span>
317+
</button>
318+
${html}
319+
</div>`
310320
}
311321

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

0 commit comments

Comments
 (0)