From db974ceac7d9c92141e6a47e8902d8a0adbcf384 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Wed, 20 May 2026 13:40:49 +0100 Subject: [PATCH 1/3] 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. --- app/components/Package/Card.vue | 118 ++++++++------------- app/composables/useMarkdown.ts | 26 +++-- i18n/locales/en.json | 1 + test/nuxt/composables/use-markdown.spec.ts | 28 +++++ 4 files changed, 91 insertions(+), 82 deletions(-) diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue index f7b06fb26a..a795632a9b 100644 --- a/app/components/Package/Card.vue +++ b/app/components/Package/Card.vue @@ -74,85 +74,55 @@ const numberFormatter = useNumberFormatter() /> -
-
-

- -

-
-
-
-
{{ $t('package.card.publisher') }}
-
{{ result.package.publisher.username }}
-
-
-
{{ $t('package.card.published') }}
-
- -
-
-
-
{{ $t('package.card.license') }}
-
{{ result.package.license }}
-
-
-
- -
-
-
{{ $t('package.card.weekly_downloads') }}
-
-
-
-
+

+ +

+
+
+
{{ $t('package.card.version') }}
+
+ v{{ result.package.version }} +
- -
-
- - v{{ result.package.version }} - -
- -
-
-
+
+ +
+
+
{{ $t('package.card.publisher') }}
+
{{ result.package.publisher.username }}
+
+
+
{{ $t('package.card.published') }}
+
+ +
+
+
+
{{ $t('package.card.license') }}
+
{{ result.package.license }}
+
+
+
{{ $t('package.card.weekly_downloads') }}
+
+
-
+
    ) { 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() } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 7e119affa7..2805bc7109 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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" }, diff --git a/test/nuxt/composables/use-markdown.spec.ts b/test/nuxt/composables/use-markdown.spec.ts index 7aa258e0df..2002a5ad30 100644 --- a/test/nuxt/composables/use-markdown.spec.ts +++ b/test/nuxt/composables/use-markdown.spec.ts @@ -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( From 070d5ad79f7ee9607a96e0891c225fbeca0fa827 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Thu, 21 May 2026 22:06:00 +0100 Subject: [PATCH 2/3] fix(ui): move ProvenanceBadge out of dl to satisfy a11y, regenerate i18n schema --- app/components/Package/Card.vue | 88 ++++++++++++++++----------------- i18n/schema.json | 3 ++ 2 files changed, 46 insertions(+), 45 deletions(-) diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue index a795632a9b..b2c9b059da 100644 --- a/app/components/Package/Card.vue +++ b/app/components/Package/Card.vue @@ -77,52 +77,50 @@ const numberFormatter = useNumberFormatter()

    -
    -
    -
    {{ $t('package.card.version') }}
    -
    - v{{ result.package.version }} -
    -
    -
    + - -
    -
    -
    {{ $t('package.card.publisher') }}
    -
    {{ result.package.publisher.username }}
    -
    -
    -
    {{ $t('package.card.published') }}
    -
    - -
    -
    -
    -
    {{ $t('package.card.license') }}
    -
    {{ result.package.license }}
    -
    -
    -
    {{ $t('package.card.weekly_downloads') }}
    -
    -
    -
    -
    + :provider="result.package.publisher.trustedPublisher.id" + :package-name="result.package.name" + :version="result.package.version" + :linked="false" + compact + /> +
    +
    +
    {{ $t('package.card.version') }}
    +
    + v{{ result.package.version }} +
    +
    +
    +
    {{ $t('package.card.publisher') }}
    +
    {{ result.package.publisher.username }}
    +
    +
    +
    {{ $t('package.card.published') }}
    +
    + +
    +
    +
    +
    {{ $t('package.card.license') }}
    +
    {{ result.package.license }}
    +
    +
    +
    {{ $t('package.card.weekly_downloads') }}
    +
    +
    +
    +
    +
+
+
{{ $t('package.card.published') }}
+
+ +
+
{{ $t('package.card.publisher') }}
{{ result.package.publisher.username }}
-
-
{{ $t('package.card.published') }}
-
- -
-
{{ $t('package.card.license') }}
{{ result.package.license }}