Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>

<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" class="flex items-center gap-1.5 sm:ms-auto">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Render weekly downloads when the value is 0

v-if="result.downloads?.weekly" hides a valid 0 value. Use a nullish check so zero still renders.

Suggested patch
-        <div v-if="result.downloads?.weekly" class="flex items-center gap-1.5 sm:ms-auto">
+        <div v-if="result.downloads?.weekly != null" class="flex items-center gap-1.5 sm:ms-auto">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div v-if="result.downloads?.weekly" class="flex items-center gap-1.5 sm:ms-auto">
<div v-if="result.downloads?.weekly != null" class="flex items-center gap-1.5 sm:ms-auto">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/Package/Card.vue` at line 113, The template currently uses a
truthy check on result.downloads?.weekly in the div's v-if which hides a valid 0
value; update the v-if on that div (the element referencing
result.downloads?.weekly) to use a null/undefined check (i.e., render when
weekly is not null/undefined) so that 0 will still display.

<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
26 changes: 18 additions & 8 deletions app/composables/useMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@ export function useMarkdown(options: MaybeRefOrGetter<UseMarkdownOptions>) {
return computed(() => parseMarkdown(toValue(options)))
}

// Strip markdown image badges from text
// Single alternation matches any of:
// - image atom: ![alt](url) OR ![alt][ref]
// - empty link wrapper left behind after image removal: [](url) / [][ref]
// - reference link definition line: [ref]: url "optional title"
// Bounded quantifiers ({0,N}) guard against ReDoS. Compiled once at module
// scope so reactive callers don't pay re-instantiation cost on every render.
const STRIPPABLE_MARKDOWN =
/!\[[^\]]{0,500}\](?:\([^)]{0,2000}\)|\[[^\]]{0,500}\])|\[\s*\](?:\([^)]{0,2000}\)?|\[[^\]]{0,500}\])|^[ \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 in a
// single scan. 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
text = text.replace(STRIPPABLE_MARKDOWN, '')
} 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