Skip to content
114 changes: 41 additions & 73 deletions app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,84 +74,52 @@ const numberFormatter = useNumberFormatter()
/>
</header>
Comment thread
ghostdevv marked this conversation as resolved.

<div class="flex flex-col sm:flex-row sm:justify-start sm:items-start gap-6 sm:gap-8">
<div class="min-w-0 w-full">
<p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
<span v-html="pkgDescription" />
</p>
<div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted">
<dl v-if="showPublisher || result.package.date" class="flex items-center gap-4 m-0">
<div
v-if="showPublisher && result.package.publisher?.username"
class="flex items-center gap-1.5"
>
<dt class="sr-only">{{ $t('package.card.publisher') }}</dt>
<dd class="font-mono">{{ result.package.publisher.username }}</dd>
</div>
<div v-if="result.package.date" class="flex items-center gap-1.5">
<dt class="sr-only">{{ $t('package.card.published') }}</dt>
<dd>
<DateTime
:datetime="result.package.date"
year="numeric"
month="short"
day="numeric"
/>
</dd>
</div>
<div v-if="result.package.license" class="flex items-center gap-1.5">
<dt class="sr-only">{{ $t('package.card.license') }}</dt>
<dd>{{ result.package.license }}</dd>
</div>
</dl>
</div>
<!-- Mobile: downloads on separate row -->
<dl
v-if="result.downloads?.weekly"
class="sm:hidden flex items-center gap-4 mt-2 text-xs text-fg-muted m-0"
>
<div class="flex items-center gap-1.5">
<dt class="sr-only">{{ $t('package.card.weekly_downloads') }}</dt>
<dd class="flex items-center gap-1.5">
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
<span class="font-mono">{{ $n(result.downloads.weekly) }}/w</span>
</dd>
</div>
</dl>
</div>

<div class="flex flex-col gap-2 shrink-0">
<div class="text-fg-subtle flex items-start gap-2 sm:justify-end">
<span
v-if="result.package.version"
class="font-mono text-xs truncate max-w-32"
:title="result.package.version"
>
<p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
<span v-html="pkgDescription" />
</p>
<div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-muted">
<ProvenanceBadge
v-if="result.package.publisher?.trustedPublisher"
:provider="result.package.publisher.trustedPublisher.id"
:package-name="result.package.name"
:version="result.package.version"
:linked="false"
compact
/>
<dl class="contents m-0">
<div v-if="result.package.version" class="flex items-center gap-1.5 min-w-0">
<dt class="sr-only">{{ $t('package.card.version') }}</dt>
<dd class="font-mono truncate max-w-32" :title="result.package.version">
v{{ result.package.version }}
</span>
<div
v-if="result.package.publisher?.trustedPublisher"
class="flex items-center gap-1.5 shrink-0 max-w-32"
>
<ProvenanceBadge
:provider="result.package.publisher.trustedPublisher.id"
:package-name="result.package.name"
:version="result.package.version"
:linked="false"
compact
/>
</div>
</dd>
</div>
<div v-if="result.package.date" class="flex items-center gap-1.5">
<dt class="sr-only">{{ $t('package.card.published') }}</dt>
<dd>
<DateTime :datetime="result.package.date" year="numeric" month="short" day="numeric" />
</dd>
</div>
<div
v-if="result.downloads?.weekly"
class="text-fg-subtle gap-2 flex items-center sm:justify-end"
v-if="showPublisher && result.package.publisher?.username"
class="flex items-center gap-1.5"
>
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
<span class="font-mono text-xs">
{{ $n(result.downloads.weekly) }} {{ $t('common.per_week') }}
</span>
<dt class="sr-only">{{ $t('package.card.publisher') }}</dt>
<dd class="font-mono">{{ result.package.publisher.username }}</dd>
</div>
<div v-if="result.package.license" class="flex items-center gap-1.5">
<dt class="sr-only">{{ $t('package.card.license') }}</dt>
<dd>{{ result.package.license }}</dd>
</div>
<div v-if="result.downloads?.weekly != null" class="flex items-center gap-1.5 sm:ms-auto">
<dt class="sr-only">{{ $t('package.card.weekly_downloads') }}</dt>
<dd class="flex items-center gap-1.5">
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
<span class="font-mono">
{{ $n(result.downloads.weekly) }} {{ $t('common.per_week') }}
</span>
</dd>
</div>
</div>
</dl>
</div>

<ul
Expand Down
35 changes: 27 additions & 8 deletions app/composables/useMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,34 @@ export function useMarkdown(options: MaybeRefOrGetter<UseMarkdownOptions>) {
return computed(() => parseMarkdown(toValue(options)))
}

// Strip markdown image badges from text
/**
* Each step strips one badge shape. Bounded quantifiers ({0,N}) guard against
* ReDoS, and patterns are compiled once at module scope so reactive callers
* don't pay re-instantiation cost on every render.
*/
const STRIPPABLE_MARKDOWN = Object.freeze([
// Image atom: ![alt](url) OR reference-style ![alt][ref]
/!\[[^\]]{0,500}\](?:\([^)]{0,2000}\)|\[[^\]]{0,500}\])/g,
// Empty link wrapper left behind after image removal: [](url) OR [][ref]
/\[\s*\](?:\([^)]{0,2000}\)?|\[[^\]]{0,500}\])/g,
// Reference link definition line: [ref]: url "optional title"
/^[ \t]*\[[^\]]{1,500}\]:[ \t]+\S{1,2000}(?:[ \t]+["'(].*?["')])?[ \t]*$/gm,
])

/**
* Strip markdown image badges from text.
* Each pass removes image atoms, empty link wrappers, and reference defs.
* Re-run to a fixed point so nested shapes like `[![…][ref]][ref]` collapse
* without per-shape rules.
*/
function stripMarkdownImages(text: string): string {
// Remove linked images: [![alt](image-url)](link-url) - handles incomplete URLs too
// Using {0,500} instead of * to prevent ReDoS on pathological inputs
text = text.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '')
// Remove standalone images: ![alt](url)
text = text.replace(/!\[[^\]]{0,500}\]\([^)]{0,2000}\)/g, '')
// Remove any leftover empty links or broken markdown link syntax
text = text.replace(/\[\]\([^)]{0,2000}\)?/g, '')
let previous: string
do {
previous = text
for (const pattern of STRIPPABLE_MARKDOWN) {
text = text.replace(pattern, '')
}
} while (text !== previous)
return text.trim()
}

Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@
"weekly_downloads": "Weekly downloads",
"keywords": "Keywords",
"license": "License",
"version": "Version",
"select": "Select package",
"select_maximum": "Maximum {count} packages can be selected"
},
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,9 @@
"license": {
"type": "string"
},
"version": {
"type": "string"
},
"select": {
"type": "string"
},
Expand Down
28 changes: 28 additions & 0 deletions test/nuxt/composables/use-markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,34 @@ describe('useMarkdown', () => {
expect(processed.value).toBe('A library')
})

it('strips reference-style linked image badges (regression #2767)', () => {
const processed = useMarkdown({
text: '[![npm version][npm-v-src]][npm-v-href] [![npm downloads][npm-d-src]][npm-d-href] A library',
})
expect(processed.value).toBe('A library')
})

it('returns empty when description is only reference-style badges (regression #2767)', () => {
const processed = useMarkdown({
text: '[![npm version][npm-v-src]][npm-v-href] [![npm downloads][npm-d-src]][npm-d-href]',
})
expect(processed.value).toBe('')
})

it('strips standalone reference-style images', () => {
const processed = useMarkdown({
text: '![badge][badge-ref] A library',
})
expect(processed.value).toBe('A library')
})

it('strips reference link definitions', () => {
const processed = useMarkdown({
text: 'A library\n\n[npm-v-src]: https://img.shields.io/npm/v/foo.svg\n[npm-v-href]: https://npm.im/foo',
})
expect(processed.value).toBe('A library')
})

it('preserves regular markdown links', () => {
const processed = useMarkdown({ text: '[documentation](https://docs.example.com) is here' })
expect(processed.value).toBe(
Expand Down
Loading