Skip to content

Commit db974ce

Browse files
committed
fix(ui): skip README badge markdown in package card summaries
Closes #2767. npm's search API sometimes returns the README's leading badge markdown as the package description (e.g. @nuxtjs/opencollective). Extends the markdown stripper to handle reference-style image badges and collapses the side-column metadata into a single flex-wrap row so the card layout stays consistent when the description ends up empty. Also drops the duplicated mobile downloads row.
1 parent f2be533 commit db974ce

4 files changed

Lines changed: 91 additions & 82 deletions

File tree

app/components/Package/Card.vue

Lines changed: 44 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -74,85 +74,55 @@ const numberFormatter = useNumberFormatter()
7474
/>
7575
</header>
7676

77-
<div class="flex flex-col sm:flex-row sm:justify-start sm:items-start gap-6 sm:gap-8">
78-
<div class="min-w-0 w-full">
79-
<p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
80-
<span v-html="pkgDescription" />
81-
</p>
82-
<div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted">
83-
<dl v-if="showPublisher || result.package.date" class="flex items-center gap-4 m-0">
84-
<div
85-
v-if="showPublisher && result.package.publisher?.username"
86-
class="flex items-center gap-1.5"
87-
>
88-
<dt class="sr-only">{{ $t('package.card.publisher') }}</dt>
89-
<dd class="font-mono">{{ result.package.publisher.username }}</dd>
90-
</div>
91-
<div v-if="result.package.date" class="flex items-center gap-1.5">
92-
<dt class="sr-only">{{ $t('package.card.published') }}</dt>
93-
<dd>
94-
<DateTime
95-
:datetime="result.package.date"
96-
year="numeric"
97-
month="short"
98-
day="numeric"
99-
/>
100-
</dd>
101-
</div>
102-
<div v-if="result.package.license" class="flex items-center gap-1.5">
103-
<dt class="sr-only">{{ $t('package.card.license') }}</dt>
104-
<dd>{{ result.package.license }}</dd>
105-
</div>
106-
</dl>
107-
</div>
108-
<!-- Mobile: downloads on separate row -->
109-
<dl
110-
v-if="result.downloads?.weekly"
111-
class="sm:hidden flex items-center gap-4 mt-2 text-xs text-fg-muted m-0"
112-
>
113-
<div class="flex items-center gap-1.5">
114-
<dt class="sr-only">{{ $t('package.card.weekly_downloads') }}</dt>
115-
<dd class="flex items-center gap-1.5">
116-
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
117-
<span class="font-mono">{{ $n(result.downloads.weekly) }}/w</span>
118-
</dd>
119-
</div>
120-
</dl>
77+
<p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
78+
<span v-html="pkgDescription" />
79+
</p>
80+
<dl class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted m-0">
81+
<div v-if="result.package.version" class="flex items-center gap-1.5 min-w-0">
82+
<dt class="sr-only">{{ $t('package.card.version') }}</dt>
83+
<dd class="font-mono truncate max-w-32" :title="result.package.version">
84+
v{{ result.package.version }}
85+
</dd>
12186
</div>
122-
123-
<div class="flex flex-col gap-2 shrink-0">
124-
<div class="text-fg-subtle flex items-start gap-2 sm:justify-end">
125-
<span
126-
v-if="result.package.version"
127-
class="font-mono text-xs truncate max-w-32"
128-
:title="result.package.version"
129-
>
130-
v{{ result.package.version }}
131-
</span>
132-
<div
133-
v-if="result.package.publisher?.trustedPublisher"
134-
class="flex items-center gap-1.5 shrink-0 max-w-32"
135-
>
136-
<ProvenanceBadge
137-
:provider="result.package.publisher.trustedPublisher.id"
138-
:package-name="result.package.name"
139-
:version="result.package.version"
140-
:linked="false"
141-
compact
142-
/>
143-
</div>
144-
</div>
145-
<div
146-
v-if="result.downloads?.weekly"
147-
class="text-fg-subtle gap-2 flex items-center sm:justify-end"
148-
>
87+
<div
88+
v-if="result.package.publisher?.trustedPublisher"
89+
class="flex items-center gap-1.5 shrink-0"
90+
>
91+
<ProvenanceBadge
92+
:provider="result.package.publisher.trustedPublisher.id"
93+
:package-name="result.package.name"
94+
:version="result.package.version"
95+
:linked="false"
96+
compact
97+
/>
98+
</div>
99+
<div
100+
v-if="showPublisher && result.package.publisher?.username"
101+
class="flex items-center gap-1.5"
102+
>
103+
<dt class="sr-only">{{ $t('package.card.publisher') }}</dt>
104+
<dd class="font-mono">{{ result.package.publisher.username }}</dd>
105+
</div>
106+
<div v-if="result.package.date" class="flex items-center gap-1.5">
107+
<dt class="sr-only">{{ $t('package.card.published') }}</dt>
108+
<dd>
109+
<DateTime :datetime="result.package.date" year="numeric" month="short" day="numeric" />
110+
</dd>
111+
</div>
112+
<div v-if="result.package.license" class="flex items-center gap-1.5">
113+
<dt class="sr-only">{{ $t('package.card.license') }}</dt>
114+
<dd>{{ result.package.license }}</dd>
115+
</div>
116+
<div v-if="result.downloads?.weekly" class="flex items-center gap-1.5 sm:ms-auto">
117+
<dt class="sr-only">{{ $t('package.card.weekly_downloads') }}</dt>
118+
<dd class="flex items-center gap-1.5">
149119
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
150-
<span class="font-mono text-xs">
120+
<span class="font-mono">
151121
{{ $n(result.downloads.weekly) }} {{ $t('common.per_week') }}
152122
</span>
153-
</div>
123+
</dd>
154124
</div>
155-
</div>
125+
</dl>
156126

157127
<ul
158128
role="list"

app/composables/useMarkdown.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,25 @@ export function useMarkdown(options: MaybeRefOrGetter<UseMarkdownOptions>) {
1010
return computed(() => parseMarkdown(toValue(options)))
1111
}
1212

13-
// Strip markdown image badges from text
13+
// Single alternation matches any of:
14+
// - image atom: ![alt](url) OR ![alt][ref]
15+
// - empty link wrapper left behind after image removal: [](url) / [][ref]
16+
// - reference link definition line: [ref]: url "optional title"
17+
// Bounded quantifiers ({0,N}) guard against ReDoS. Compiled once at module
18+
// scope so reactive callers don't pay re-instantiation cost on every render.
19+
const STRIPPABLE_MARKDOWN =
20+
/!\[[^\]]{0,500}\](?:\([^)]{0,2000}\)|\[[^\]]{0,500}\])|\[\s*\](?:\([^)]{0,2000}\)?|\[[^\]]{0,500}\])|^[ \t]*\[[^\]]{1,500}\]:[ \t]+\S{1,2000}(?:[ \t]+["'(].*?["')])?[ \t]*$/gm
21+
22+
// Strip markdown image badges from text.
23+
// Each pass removes image atoms, empty link wrappers, and reference defs in a
24+
// single scan. Re-run to a fixed point so nested shapes like
25+
// `[![…][ref]][ref]` collapse without per-shape rules.
1426
function stripMarkdownImages(text: string): string {
15-
// Remove linked images: [![alt](image-url)](link-url) - handles incomplete URLs too
16-
// Using {0,500} instead of * to prevent ReDoS on pathological inputs
17-
text = text.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '')
18-
// Remove standalone images: ![alt](url)
19-
text = text.replace(/!\[[^\]]{0,500}\]\([^)]{0,2000}\)/g, '')
20-
// Remove any leftover empty links or broken markdown link syntax
21-
text = text.replace(/\[\]\([^)]{0,2000}\)?/g, '')
27+
let previous: string
28+
do {
29+
previous = text
30+
text = text.replace(STRIPPABLE_MARKDOWN, '')
31+
} while (text !== previous)
2232
return text.trim()
2333
}
2434

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@
532532
"weekly_downloads": "Weekly downloads",
533533
"keywords": "Keywords",
534534
"license": "License",
535+
"version": "Version",
535536
"select": "Select package",
536537
"select_maximum": "Maximum {count} packages can be selected"
537538
},

test/nuxt/composables/use-markdown.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,34 @@ describe('useMarkdown', () => {
188188
expect(processed.value).toBe('A library')
189189
})
190190

191+
it('strips reference-style linked image badges (regression #2767)', () => {
192+
const processed = useMarkdown({
193+
text: '[![npm version][npm-v-src]][npm-v-href] [![npm downloads][npm-d-src]][npm-d-href] A library',
194+
})
195+
expect(processed.value).toBe('A library')
196+
})
197+
198+
it('returns empty when description is only reference-style badges (regression #2767)', () => {
199+
const processed = useMarkdown({
200+
text: '[![npm version][npm-v-src]][npm-v-href] [![npm downloads][npm-d-src]][npm-d-href]',
201+
})
202+
expect(processed.value).toBe('')
203+
})
204+
205+
it('strips standalone reference-style images', () => {
206+
const processed = useMarkdown({
207+
text: '![badge][badge-ref] A library',
208+
})
209+
expect(processed.value).toBe('A library')
210+
})
211+
212+
it('strips reference link definitions', () => {
213+
const processed = useMarkdown({
214+
text: 'A library\n\n[npm-v-src]: https://img.shields.io/npm/v/foo.svg\n[npm-v-href]: https://npm.im/foo',
215+
})
216+
expect(processed.value).toBe('A library')
217+
})
218+
191219
it('preserves regular markdown links', () => {
192220
const processed = useMarkdown({ text: '[documentation](https://docs.example.com) is here' })
193221
expect(processed.value).toBe(

0 commit comments

Comments
 (0)