Skip to content

Commit 465eba6

Browse files
committed
feat: add 'Copy for AI' button to package page
- Adds button to copy package info (name, version, description, install command, README) as markdown for AI context - Button positioned right of description on desktop, hidden on mobile - Secondary button in README section for mobile access - Uses theme-consistent btn/btn-ghost shortcuts - Fixed width (min-w-32) to prevent size change on copy feedback
1 parent bf55ec9 commit 465eba6

1 file changed

Lines changed: 117 additions & 28 deletions

File tree

app/pages/[...package].vue

Lines changed: 117 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,68 @@ async function copyInstallCommand() {
334334
setTimeout(() => (copied.value = false), 2000)
335335
}
336336
337+
// Copy for AI context
338+
const copiedForAI = ref(false)
339+
const aiContextText = computed(() => {
340+
if (!pkg.value || !displayVersion.value) return ''
341+
342+
const parts: string[] = []
343+
344+
// Package header
345+
parts.push(`# ${pkg.value.name}@${displayVersion.value.version}`)
346+
parts.push('')
347+
348+
// Description
349+
if (pkg.value.description) {
350+
parts.push(`> ${pkg.value.description}`)
351+
parts.push('')
352+
}
353+
354+
// Key info
355+
parts.push('## Package Info')
356+
parts.push(`- **Install:** \`${installCommand.value}\``)
357+
if (pkg.value.license) parts.push(`- **License:** ${pkg.value.license}`)
358+
if (repositoryUrl.value) parts.push(`- **Repository:** ${repositoryUrl.value}`)
359+
if (homepageUrl.value) parts.push(`- **Homepage:** ${homepageUrl.value}`)
360+
parts.push('')
361+
362+
// Dependencies summary
363+
// const depCount = getDependencyCount(displayVersion.value)
364+
// if (depCount > 0) {
365+
// parts.push(`## Dependencies (${depCount})`)
366+
// const deps = displayVersion.value.dependencies
367+
// if (deps) {
368+
// parts.push(Object.entries(deps).map(([name, version]) => `- ${name}: ${version}`).join('\n'))
369+
// }
370+
// parts.push('')
371+
// }
372+
373+
// README content (extract text from HTML)
374+
if (readmeData.value?.html) {
375+
parts.push('## README')
376+
parts.push('')
377+
// Convert HTML to plain text (client-side only)
378+
if (import.meta.client) {
379+
const tempDiv = document.createElement('div')
380+
tempDiv.innerHTML = readmeData.value.html
381+
// Get text content, preserving some structure
382+
const textContent = tempDiv.innerText || tempDiv.textContent || ''
383+
parts.push(textContent.trim())
384+
} else {
385+
parts.push('[README content available - copy from browser]')
386+
}
387+
}
388+
389+
return parts.join('\n')
390+
})
391+
392+
async function copyForAI() {
393+
if (!aiContextText.value) return
394+
await navigator.clipboard.writeText(aiContextText.value)
395+
copiedForAI.value = true
396+
setTimeout(() => (copiedForAI.value = false), 2000)
397+
}
398+
337399
// Expandable description
338400
const descriptionExpanded = ref(false)
339401
const descriptionRef = ref<HTMLDivElement>()
@@ -489,33 +551,47 @@ defineOgImageComponent('Package', {
489551
</a>
490552
</div>
491553

492-
<!-- Fixed height description container to prevent CLS -->
493-
<div ref="descriptionRef" class="relative max-w-2xl min-h-[4.5rem]">
494-
<p
495-
v-if="pkg.description"
496-
class="text-fg-muted text-base m-0 overflow-hidden"
497-
:class="descriptionExpanded ? '' : 'max-h-[4.5rem]'"
498-
>
499-
<MarkdownText :text="pkg.description" />
500-
</p>
501-
<p v-else class="text-fg-subtle text-base m-0 italic">No description provided</p>
502-
<!-- Fade overlay with show more button - only when collapsed and overflowing -->
503-
<div
504-
v-if="pkg.description && descriptionOverflows && !descriptionExpanded"
505-
class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-bg via-bg/90 to-transparent flex items-end justify-end"
506-
>
554+
<!-- Description with Copy for AI button on right (desktop only) -->
555+
<div class="flex flex-col sm:flex-row sm:items-start gap-4 sm:gap-6">
556+
<!-- Fixed height description container to prevent CLS -->
557+
<div ref="descriptionRef" class="relative flex-1 min-h-[4.5rem]">
558+
<p
559+
v-if="pkg.description"
560+
class="text-fg-muted text-base m-0 overflow-hidden"
561+
:class="descriptionExpanded ? '' : 'max-h-[4.5rem]'"
562+
>
563+
<MarkdownText :text="pkg.description" />
564+
</p>
565+
<p v-else class="text-fg-subtle text-base m-0 italic">No description provided</p>
566+
<!-- Fade overlay with show more button - only when collapsed and overflowing -->
567+
<div
568+
v-if="pkg.description && descriptionOverflows && !descriptionExpanded"
569+
class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-bg via-bg/90 to-transparent flex items-end justify-end"
570+
>
571+
<button
572+
type="button"
573+
class="font-mono text-xs text-fg-muted hover:text-fg bg-bg px-1 transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
574+
aria-label="Show full description"
575+
@click="descriptionExpanded = true"
576+
>
577+
show more
578+
</button>
579+
</div>
580+
</div>
581+
<!-- Copy for AI button (hidden on mobile, visible on desktop) -->
582+
<ClientOnly>
507583
<button
508584
type="button"
509-
class="font-mono text-xs text-fg-muted hover:text-fg bg-bg px-1 transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
510-
aria-label="Show full description"
511-
@click="descriptionExpanded = true"
585+
class="btn shrink-0 gap-2 min-w-32 hidden sm:inline-flex"
586+
title="Copy package info and README for AI context"
587+
@click="copyForAI"
512588
>
513-
show more
589+
<span class="i-carbon-machine-learning w-4 h-4" aria-hidden="true" />
590+
<span aria-live="polite">{{ copiedForAI ? 'copied!' : 'copy for ai' }}</span>
514591
</button>
515-
</div>
592+
</ClientOnly>
516593
</div>
517594
</div>
518-
519595
<div
520596
v-if="deprecationNotice"
521597
class="border border-red-400 bg-red-400/10 rounded-lg px-3 py-2 text-base text-red-400"
@@ -809,8 +885,8 @@ defineOgImageComponent('Package', {
809885
<!-- Main package install -->
810886
<div class="flex items-center gap-2">
811887
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
812-
<code class="font-mono text-sm"
813-
><ClientOnly
888+
<code class="font-mono text-sm">
889+
<ClientOnly
814890
><span
815891
v-for="(part, i) in installCommandParts"
816892
:key="i"
@@ -820,8 +896,8 @@ defineOgImageComponent('Package', {
820896
><span class="text-fg">npm</span
821897
><span class="text-fg-muted"> install {{ pkg.name }}</span></template
822898
></ClientOnly
823-
></code
824-
>
899+
>
900+
</code>
825901
</div>
826902
<!-- @types package install (when enabled) -->
827903
<div v-if="showTypesInInstall" class="flex items-center gap-2">
@@ -862,9 +938,22 @@ defineOgImageComponent('Package', {
862938
<!-- Main content (README) -->
863939
<div class="lg:col-span-2 order-2 lg:order-1 min-w-0">
864940
<section aria-labelledby="readme-heading">
865-
<h2 id="readme-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-4">
866-
Readme
867-
</h2>
941+
<div class="flex items-center justify-between mb-4">
942+
<h2 id="readme-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
943+
Readme
944+
</h2>
945+
<ClientOnly>
946+
<button
947+
type="button"
948+
class="btn-ghost gap-1.5 text-xs"
949+
title="Copy package info and README for AI context"
950+
@click="copyForAI"
951+
>
952+
<span class="i-carbon-machine-learning w-3.5 h-3.5" aria-hidden="true" />
953+
<span aria-live="polite">{{ copiedForAI ? 'copied!' : 'copy for ai' }}</span>
954+
</button>
955+
</ClientOnly>
956+
</div>
868957
<!-- eslint-disable vue/no-v-html -- HTML is sanitized server-side -->
869958
<div
870959
v-if="readmeData?.html"

0 commit comments

Comments
 (0)