Skip to content

Commit 4aab312

Browse files
authored
feat: copy compare table as markdown (#1533)
1 parent d4e1ae9 commit 4aab312

File tree

11 files changed

+203
-79
lines changed

11 files changed

+203
-79
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
4+
defineOptions({
5+
inheritAttrs: false,
6+
})
7+
8+
const props = defineProps<{
9+
copied: boolean
10+
copyText?: string
11+
copiedText?: string
12+
ariaLabelCopy?: string
13+
ariaLabelCopied?: string
14+
buttonAttrs?: HTMLAttributes
15+
}>()
16+
17+
const buttonCopyText = computed(() => props.copyText || $t('common.copy'))
18+
const buttonCopiedText = computed(() => props.copiedText || $t('common.copied'))
19+
const buttonAriaLabelCopy = computed(() => props.ariaLabelCopy || $t('common.copy'))
20+
const buttonAriaLabelCopied = computed(() => props.ariaLabelCopied || $t('common.copied'))
21+
22+
const emit = defineEmits<{
23+
click: []
24+
}>()
25+
26+
function handleClick() {
27+
emit('click')
28+
}
29+
</script>
30+
31+
<template>
32+
<div class="group relative" v-bind="$attrs">
33+
<slot />
34+
<button
35+
type="button"
36+
@click="handleClick"
37+
class="absolute z-20 inset-is-0 top-full 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"
38+
:class="[
39+
$style.copyButton,
40+
copied ? 'text-accent bg-accent/10' : 'text-fg-muted bg-bg border-border',
41+
]"
42+
:aria-label="copied ? buttonAriaLabelCopied : buttonAriaLabelCopy"
43+
v-bind="buttonAttrs"
44+
>
45+
<span
46+
:class="copied ? 'i-lucide:check' : 'i-lucide:copy'"
47+
class="w-3.5 h-3.5"
48+
aria-hidden="true"
49+
/>
50+
{{ copied ? buttonCopiedText : buttonCopyText }}
51+
</button>
52+
</div>
53+
</template>
54+
55+
<style module>
56+
.copyButton {
57+
clip: rect(0 0 0 0);
58+
clip-path: inset(50%);
59+
height: 1px;
60+
overflow: hidden;
61+
width: 1px;
62+
transition:
63+
opacity 0.25s 0.1s,
64+
translate 0.15s 0.1s,
65+
clip 0.01s 0.34s allow-discrete,
66+
clip-path 0.01s 0.34s allow-discrete,
67+
height 0.01s 0.34s allow-discrete,
68+
width 0.01s 0.34s allow-discrete;
69+
}
70+
71+
:global(.group):hover .copyButton,
72+
.copyButton:focus-visible {
73+
clip: auto;
74+
clip-path: none;
75+
height: auto;
76+
overflow: visible;
77+
width: auto;
78+
transition:
79+
opacity 0.15s,
80+
translate 0.15s;
81+
}
82+
83+
@media (hover: none) {
84+
.copyButton {
85+
display: none;
86+
}
87+
}
88+
</style>

app/pages/compare.vue

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ definePageMeta({
66
name: 'compare',
77
})
88
9+
const { locale } = useI18n()
910
const router = useRouter()
1011
const canGoBack = useCanGoBack()
12+
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
1113
1214
// Sync packages with URL query param (stable ref - doesn't change on other query changes)
1315
const 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+
/*
85+
* Convert the comparison grid data to a Markdown table.
86+
*/
87+
function exportComparisonDataAsMarkdown() {
88+
const mdData: Array<Array<string>> = []
89+
const headers = [
90+
'',
91+
...gridHeaders.value,
92+
...(showNoDependency.value ? [$t('compare.no_dependency.label')] : []),
93+
]
94+
mdData.push(headers)
95+
const maxLengths = headers.map(item => item.length)
96+
97+
selectedFacets.value.forEach((facet, index) => {
98+
const label = facet.label
99+
const data = getFacetValues(facet.id)
100+
mdData.push([
101+
label,
102+
...data.map(item =>
103+
item?.type === 'date'
104+
? new Date(item.display).toLocaleDateString(locale.value, {
105+
year: 'numeric',
106+
month: 'short',
107+
day: 'numeric',
108+
})
109+
: item?.display || '',
110+
),
111+
])
112+
mdData?.[index + 1]?.forEach((item, itemIndex) => {
113+
if (item.length > (maxLengths?.[itemIndex] || 0)) {
114+
maxLengths[itemIndex] = item.length
115+
}
116+
})
117+
})
118+
119+
const markdown = mdData.reduce((result, row, index) => {
120+
// replacing pipe `|` with `ǀ` (U+01C0 Latin Letter Dental Click) to avoid breaking tables
121+
result += `| ${row
122+
.map((el, ind) => el.padEnd(maxLengths[ind] || 0, ' ').replace(/\|/g, 'ǀ'))
123+
.join(' | ')} |`
124+
if (index === 0) {
125+
result += `\n|`
126+
maxLengths.forEach(len => (result += ` ${'-'.padEnd(len, '-')} |`))
127+
}
128+
result += `\n`
129+
return result
130+
}, '')
131+
132+
copy(markdown)
133+
}
134+
82135
useSeoMeta({
83136
title: () =>
84137
packages.value.length > 0
@@ -193,7 +246,24 @@ 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">
249+
<CopyToClipboardButton
250+
v-if="packagesData && packagesData.some(p => p !== null)"
251+
:copied="copied"
252+
:copy-text="$t('compare.packages.copy_as_markdown')"
253+
class="mb-4"
254+
:button-attrs="{ class: 'hidden md:inline-flex' }"
255+
@click="exportComparisonDataAsMarkdown"
256+
>
257+
<h2 id="comparison-heading" class="text-xs text-fg-subtle uppercase tracking-wider">
258+
{{ $t('compare.packages.section_comparison') }}
259+
</h2>
260+
</CopyToClipboardButton>
261+
262+
<h2
263+
v-else
264+
id="comparison-heading"
265+
class="text-xs text-fg-subtle uppercase tracking-wider mb-4"
266+
>
197267
{{ $t('compare.packages.section_comparison') }}
198268
</h2>
199269

@@ -241,7 +311,7 @@ useSeoMeta({
241311
</div>
242312

243313
<h2
244-
id="comparison-heading"
314+
id="trends-comparison-heading"
245315
class="text-xs text-fg-subtle uppercase tracking-wider mb-4 mt-10"
246316
>
247317
{{ $t('compare.facets.trends.title') }}

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

Lines changed: 13 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,12 @@ const showSkeleton = shallowRef(false)
668668
>
669669
<!-- Package name and version -->
670670
<div class="flex items-baseline gap-x-2 gap-y-1 sm:gap-x-3 flex-wrap min-w-0">
671-
<div class="group relative flex flex-col items-start min-w-0">
671+
<CopyToClipboardButton
672+
:copied="copiedPkgName"
673+
:copy-text="$t('package.copy_name')"
674+
class="flex flex-col items-start min-w-0"
675+
@click="copyPkgName()"
676+
>
672677
<h1
673678
class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words"
674679
:title="pkg.name"
@@ -682,30 +687,14 @@ const showSkeleton = shallowRef(false)
682687
{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
683688
</span>
684689
</h1>
690+
</CopyToClipboardButton>
685691

686-
<!-- Floating copy name button -->
687-
<button
688-
type="button"
689-
@click="copyPkgName()"
690-
class="absolute z-20 inset-is-0 top-full 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"
691-
:class="[
692-
$style.copyButton,
693-
copiedPkgName ? 'text-accent bg-accent/10' : 'text-fg-muted bg-bg border-border',
694-
]"
695-
:aria-label="copiedPkgName ? $t('common.copied') : $t('package.copy_name')"
696-
>
697-
<span
698-
:class="copiedPkgName ? 'i-lucide:check' : 'i-lucide:copy'"
699-
class="w-3.5 h-3.5"
700-
aria-hidden="true"
701-
/>
702-
{{ copiedPkgName ? $t('common.copied') : $t('package.copy_name') }}
703-
</button>
704-
</div>
705-
706-
<span
692+
<CopyToClipboardButton
707693
v-if="resolvedVersion"
708-
class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0 relative group"
694+
:copied="copiedVersion"
695+
:copy-text="$t('package.copy_version')"
696+
class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0"
697+
@click="copyVersion()"
709698
>
710699
<!-- Version resolution indicator (e.g., "latest → 4.2.0") -->
711700
<template v-if="requestedVersion && resolvedVersion !== requestedVersion">
@@ -748,26 +737,7 @@ const showSkeleton = shallowRef(false)
748737
class="text-fg-subtle text-sm shrink-0"
749738
>{{ $t('package.not_latest') }}</span
750739
>
751-
752-
<!-- Floating copy version button -->
753-
<button
754-
type="button"
755-
@click="copyVersion()"
756-
class="absolute z-20 inset-is-0 top-full 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"
757-
:class="[
758-
$style.copyButton,
759-
copiedVersion ? 'text-accent bg-accent/10' : 'text-fg-muted bg-bg border-border',
760-
]"
761-
:aria-label="copiedVersion ? $t('common.copied') : $t('package.copy_version')"
762-
>
763-
<span
764-
:class="copiedVersion ? 'i-lucide:check' : 'i-lucide:copy'"
765-
class="w-3.5 h-3.5"
766-
aria-hidden="true"
767-
/>
768-
{{ copiedVersion ? $t('common.copied') : $t('package.copy_version') }}
769-
</button>
770-
</span>
740+
</CopyToClipboardButton>
771741

772742
<!-- Docs + Code + Compare — inline on desktop, floating bottom bar on mobile -->
773743
<ButtonGroup
@@ -1551,39 +1521,6 @@ const showSkeleton = shallowRef(false)
15511521
grid-area: sidebar;
15521522
}
15531523
1554-
.copyButton {
1555-
clip: rect(0 0 0 0);
1556-
clip-path: inset(50%);
1557-
height: 1px;
1558-
overflow: hidden;
1559-
width: 1px;
1560-
transition:
1561-
opacity 0.25s 0.1s,
1562-
translate 0.15s 0.1s,
1563-
clip 0.01s 0.34s allow-discrete,
1564-
clip-path 0.01s 0.34s allow-discrete,
1565-
height 0.01s 0.34s allow-discrete,
1566-
width 0.01s 0.34s allow-discrete;
1567-
}
1568-
1569-
:global(.group):hover .copyButton,
1570-
.copyButton:focus-visible {
1571-
clip: auto;
1572-
clip-path: none;
1573-
height: auto;
1574-
overflow: visible;
1575-
width: auto;
1576-
transition:
1577-
opacity 0.15s,
1578-
translate 0.15s;
1579-
}
1580-
1581-
@media (hover: none) {
1582-
.copyButton {
1583-
display: none;
1584-
}
1585-
}
1586-
15871524
/* Mobile floating nav: safe-area positioning + kbd hiding */
15881525
@media (max-width: 639.9px) {
15891526
.packageNav {

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,7 @@
918918
"section_packages": "Packages",
919919
"section_facets": "Facets",
920920
"section_comparison": "Comparison",
921+
"copy_as_markdown": "Copy table",
921922
"loading": "Loading package data...",
922923
"error": "Failed to load package data. Please try again.",
923924
"empty_title": "Select packages to compare",

i18n/locales/pl-PL.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,7 @@
908908
"section_packages": "Pakiety",
909909
"section_facets": "Aspekty",
910910
"section_comparison": "Porównanie",
911+
"copy_as_markdown": "Kopiuj tabelę",
911912
"loading": "Ładowanie danych pakietów...",
912913
"error": "Nie udało się wczytać danych pakietów. Spróbuj ponownie.",
913914
"empty_title": "Wybierz pakiety do porównania",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2758,6 +2758,9 @@
27582758
"section_comparison": {
27592759
"type": "string"
27602760
},
2761+
"copy_as_markdown": {
2762+
"type": "string"
2763+
},
27612764
"loading": {
27622765
"type": "string"
27632766
},

lunaria/files/en-GB.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,7 @@
917917
"section_packages": "Packages",
918918
"section_facets": "Facets",
919919
"section_comparison": "Comparison",
920+
"copy_as_markdown": "Copy table",
920921
"loading": "Loading package data...",
921922
"error": "Failed to load package data. Please try again.",
922923
"empty_title": "Select packages to compare",

lunaria/files/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,7 @@
917917
"section_packages": "Packages",
918918
"section_facets": "Facets",
919919
"section_comparison": "Comparison",
920+
"copy_as_markdown": "Copy table",
920921
"loading": "Loading package data...",
921922
"error": "Failed to load package data. Please try again.",
922923
"empty_title": "Select packages to compare",

lunaria/files/pl-PL.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,7 @@
907907
"section_packages": "Pakiety",
908908
"section_facets": "Aspekty",
909909
"section_comparison": "Porównanie",
910+
"copy_as_markdown": "Kopiuj tabelę",
910911
"loading": "Ładowanie danych pakietów...",
911912
"error": "Nie udało się wczytać danych pakietów. Spróbuj ponownie.",
912913
"empty_title": "Wybierz pakiety do porównania",

server/utils/docs/render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc'
1010
import { highlightCodeBlock } from '../shiki'
1111
import { formatParam, formatType, getNodeSignature } from './format'
1212
import { groupMergedByKind } from './processing'
13-
import { createSymbolId, escapeHtml, parseJsDocLinks, renderMarkdown } from './text'
13+
import { escapeHtml, createSymbolId, parseJsDocLinks, renderMarkdown } from './text'
1414
import type { MergedSymbol, SymbolLookup } from './types'
1515

1616
// =============================================================================

0 commit comments

Comments
 (0)