@@ -8,6 +8,8 @@ definePageMeta({
88
99const router = useRouter ()
1010const canGoBack = useCanGoBack ()
11+ const { copied, copy } = useClipboard ({ copiedDuring: 2000 })
12+ const gridRef = useTemplateRef <HTMLDivElement >(' gridRef' )
1113
1214// Sync packages with URL query param (stable ref - doesn't change on other query changes)
1315const packagesParam = useRouteQuery <string >(' packages' , ' ' , { mode: ' replace' })
@@ -79,6 +81,57 @@ const gridHeaders = computed(() =>
7981 gridColumns .value .map (col => (col .version ? ` ${col .name }@${col .version } ` : col .name )),
8082)
8183
84+ function copyComparisonGridAsMd() {
85+ const grid = gridRef .value ?.querySelector (' .comparison-grid' )
86+ if (! grid ) return
87+ const md = gridToMarkdown (grid as HTMLElement )
88+ copy (md )
89+ }
90+
91+ /*
92+ * Convert the comparison grid DOM to a Markdown table.
93+ * We build a proper HTML <table> from the grid structure and delegate to `htmlToMarkdown` for the actual conversion.
94+ */
95+ function gridToMarkdown(gridEl : HTMLElement ): string {
96+ const children = Array .from (gridEl .children )
97+ const headerRow = children [0 ]
98+ const dataRows = children .slice (1 )
99+
100+ if (! headerRow || dataRows .length === 0 ) return ' '
101+
102+ const headerCells = Array .from (headerRow .children ).slice (1 )
103+ if (headerCells .length === 0 ) return ' '
104+
105+ const ths = headerCells .map (cell => {
106+ const link = cell .querySelector (' a' )
107+ if (link ) {
108+ const href = link .getAttribute (' href' ) || ' '
109+ const absoluteHref = / ^ https? :\/\/ | ^ \/\/ / .test (href ) ? href : ` ${NPMX_SITE }${href } `
110+ return ` <th><a href="${escapeHtml (absoluteHref )}">${escapeHtml (link .textContent ?.trim () || ' ' )}</a></th> `
111+ }
112+ return ` <th>${escapeHtml (cell .textContent ?.trim () || ' ' )}</th> `
113+ })
114+
115+ const trs = dataRows .map (row => {
116+ const rowChildren = Array .from (row .children )
117+ const label = rowChildren [0 ]?.textContent ?.trim () || ' '
118+ const valueCells = rowChildren .slice (1 )
119+ const tds = [label , ... valueCells .map (cell => cell .textContent ?.trim () || ' -' )]
120+ .map (v => ` <td>${escapeHtml (v )}</td> ` )
121+ .join (' ' )
122+ return ` <tr>${tds }</tr> `
123+ })
124+
125+ const tableHtml = [
126+ ' <table>' ,
127+ ` <thead><tr><th>Metric</th>${ths .join (' ' )}</tr></thead> ` ,
128+ ` <tbody>${trs .join (' ' )}</tbody> ` ,
129+ ' </table>' ,
130+ ].join (' ' )
131+
132+ return htmlToMarkdown (tableHtml , { tablePipeAlign: false })
133+ }
134+
82135useSeoMeta ({
83136 title : () =>
84137 packages .value .length > 0
@@ -193,9 +246,30 @@ useSeoMeta({
193246
194247 <!-- Comparison grid -->
195248 <section v-if =" canCompare" class =" mt-10" aria-labelledby =" comparison-heading" >
196- <h2 id =" comparison-heading" class =" text-xs text-fg-subtle uppercase tracking-wider mb-4" >
197- {{ $t('compare.packages.section_comparison') }}
198- </h2 >
249+ <div class =" relative group mb-4 inline-block" >
250+ <h2 id =" comparison-heading" class =" text-xs text-fg-subtle uppercase tracking-wider" >
251+ {{ $t('compare.packages.section_comparison') }}
252+ </h2 >
253+
254+ <button
255+ v-if =" packagesData && packagesData.some(p => p !== null)"
256+ type =" button"
257+ class =" absolute z-20 inset-is-0 top-full hidden md:inline-flex items-center gap-1 px-2 py-1 rounded border text-xs font-mono whitespace-nowrap transition-all duration-150 opacity-0 -translate-y-1 pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:translate-y-0 focus-visible:pointer-events-auto"
258+ :class =" [
259+ $style.copyButton,
260+ copied ? 'text-accent bg-accent/10' : 'text-fg-muted bg-bg border-border',
261+ ]"
262+ :aria-label =" copied ? $t('common.copied') : $t('compare.packages.copy_as_markdown')"
263+ @click =" copyComparisonGridAsMd"
264+ >
265+ <span
266+ :class =" copied ? 'i-lucide:check' : 'i-lucide:copy'"
267+ class =" w-3.5 h-3.5"
268+ aria-hidden =" true"
269+ />
270+ {{ copied ? $t('common.copied') : $t('compare.packages.copy_as_markdown') }}
271+ </button >
272+ </div >
199273
200274 <div
201275 v-if ="
@@ -209,7 +283,7 @@ useSeoMeta({
209283
210284 <div v-else-if =" packagesData && packagesData.some(p => p !== null)" >
211285 <!-- Desktop: Grid layout -->
212- <div class =" hidden md:block overflow-x-auto" >
286+ <div ref = " gridRef " class =" hidden md:block overflow-x-auto" >
213287 <CompareComparisonGrid :columns =" gridColumns" :show-no-dependency =" showNoDependency" >
214288 <CompareFacetRow
215289 v-for =" facet in selectedFacets"
@@ -241,7 +315,7 @@ useSeoMeta({
241315 </div >
242316
243317 <h2
244- id =" comparison-heading"
318+ id =" trends- comparison-heading"
245319 class =" text-xs text-fg-subtle uppercase tracking-wider mb-4 mt-10"
246320 >
247321 {{ $t('compare.facets.trends.title') }}
@@ -277,3 +351,38 @@ useSeoMeta({
277351 </div >
278352 </main >
279353</template >
354+
355+ <style module>
356+ .copyButton {
357+ clip : rect (0 0 0 0 );
358+ clip-path : inset (50% );
359+ height : 1px ;
360+ overflow : hidden ;
361+ width : 1px ;
362+ transition :
363+ opacity 0.25s 0.1s ,
364+ translate 0.15s 0.1s ,
365+ clip 0.01s 0.34s allow-discrete,
366+ clip-path 0.01s 0.34s allow-discrete,
367+ height 0.01s 0.34s allow-discrete,
368+ width 0.01s 0.34s allow-discrete;
369+ }
370+
371+ :global(.group ):hover .copyButton ,
372+ .copyButton :focus-visible {
373+ clip : auto ;
374+ clip-path : none ;
375+ height : auto ;
376+ overflow : visible ;
377+ width : auto ;
378+ transition :
379+ opacity 0.15s ,
380+ translate 0.15s ;
381+ }
382+
383+ @media (hover : none) {
384+ .copyButton {
385+ display : none ;
386+ }
387+ }
388+ </style >
0 commit comments