Skip to content

feat: add share card#2164

Open
ShroXd wants to merge 39 commits intonpmx-dev:mainfrom
ShroXd:feat/share-card
Open

feat: add share card#2164
ShroXd wants to merge 39 commits intonpmx-dev:mainfrom
ShroXd:feat/share-card

Conversation

@ShroXd
Copy link
Copy Markdown
Contributor

@ShroXd ShroXd commented Mar 20, 2026

🔗 Linked issue

Resolves #2146

🧭 Context

Add a share button to help user generate a well-designed, shareable card and post it on social media or send it to friends.

📚 Description

Some implementation details need to be mentioned here:

  1. Share card is generated via nuxt-og-image, inline styles and hard code icon are used to ensure rendering compatibility
  2. Colors are pre-calculated to avoid on-the-fly computation. Since ACCENT_COLORS is widely used, a temporary ACCENT_COLOR_TOKENS is introduced in this PR — refactoring will be completed once merged
  3. Weekly download data on the share card is fetched directly from npm, so it may not strictly match the data shown on the package page

Demo

PixPin_2026-03-29_23-08-00.mp4

ALT of nuxt

nuxt 4.4.2 (latest) — 1.4M weekly downloads — MIT license — via npmx.dev

Screenshot

Scenario Screenshot
Light mode image
Dark mode image

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 29, 2026 2:31am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 29, 2026 2:31am
npmx-lunaria Ignored Ignored Mar 29, 2026 2:31am

Request Review

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 20, 2026

Codecov Report

❌ Patch coverage is 56.45161% with 27 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Package/ShareModal.vue 59.25% 19 Missing and 3 partials ⚠️
app/utils/colors.ts 0.00% 3 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@ShroXd ShroXd changed the title (WIP) feat: add share card feat: add share card Mar 29, 2026
@ShroXd ShroXd marked this pull request as ready for review March 30, 2026 01:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 30, 2026

📝 Walkthrough

Walkthrough

This PR implements a shareable package card feature for social media sharing. It introduces new OG image components and pages for generating share cards, adds a share modal to the package page, creates server API routes for card serving with ISR caching, and adds supporting utilities for date formatting, colour manipulation, and string truncation. Configuration updates include ISR settings for card routes and OG image paths. Tests validate card rendering and component coverage is updated accordingly.

Possibly related PRs

Suggested labels

front, ux, needs review, a11y

Suggested reviewers

  • danielroe
  • graphieros
  • ghostdevv
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset by explaining the feature addition (share button and card generation) and implementation details.
Linked Issues check ✅ Passed The PR successfully implements the primary objectives from issue #2146: adds a share button to the package page, generates shareable cards, and supports configurable themes and colours.
Out of Scope Changes check ✅ Passed All changes are scoped to the share card feature. Utility functions (formatters, colours, string) and configuration updates support the core functionality without introducing unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
test/unit/a11y-component-coverage.spec.ts (1)

31-34: Avoid long-term a11y skip for Package/ShareModal.vue.

Line 32 skips an interactive modal in a core user flow; that can mask regressions. Consider keeping this as a short-lived skip with a tracked follow-up to add at least one focused modal a11y smoke test.

app/components/Package/ShareModal.vue (2)

68-79: The async keyword is unnecessary here.

downloadCard is declared async but contains no await expressions. The try/finally block handles synchronous operations only.

♻️ Proposed simplification
-async function downloadCard() {
+function downloadCard() {
   const a = document.createElement('a')
   a.href = cardUrl.value
   a.download = `${props.packageName.replace('/', '-')}-card.png`
   document.body.appendChild(a)
   try {
     a.click()
   } finally {
     document.body.removeChild(a)
   }
   showAlt.value = true
 }

31-33: Alt text may be redundant for non-latest versions.

When isLatest is false, the tag falls back to resolvedVersion, producing alt text like "nuxt 4.4.2 (4.4.2)" which duplicates the version. Consider omitting the parenthetical for non-latest versions, or showing the actual dist-tag if available.

♻️ Proposed improvement
 const altText = computed(() => {
-  const tag = props.isLatest ? 'latest' : props.resolvedVersion
-  const parts: string[] = [`${props.packageName} ${props.resolvedVersion} (${tag})`]
+  const versionPart = props.isLatest
+    ? `${props.resolvedVersion} (latest)`
+    : props.resolvedVersion
+  const parts: string[] = [`${props.packageName} ${versionPart}`]

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 56dab79f-1b89-43a9-acfd-ce896de2de51

📥 Commits

Reviewing files that changed from the base of the PR and between 7688cd7 and 6040dfb.

⛔ Files ignored due to path filters (3)
  • test/e2e/og-image.spec.ts-snapshots/og-image-for--package-nuxt-v-3-20-2.png is excluded by !**/*.png
  • test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-dark.png is excluded by !**/*.png
  • test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-light.png is excluded by !**/*.png
📒 Files selected for processing (16)
  • app/components/OgImage/BlogPost.vue
  • app/components/OgImage/ShareCard.d.vue.ts
  • app/components/OgImage/ShareCard.vue
  • app/components/Package/Header.vue
  • app/components/Package/ShareModal.vue
  • app/components/Package/Skeleton.vue
  • app/pages/package/[[org]]/[name].vue
  • app/pages/share-card/[[org]]/[name].vue
  • app/utils/colors.ts
  • app/utils/formatters.ts
  • app/utils/string.ts
  • nuxt.config.ts
  • server/api/card/[...pkg].get.ts
  • shared/utils/constants.ts
  • test/e2e/og-image.spec.ts
  • test/unit/a11y-component-coverage.spec.ts

Comment on lines +237 to +241
aria-label="Share package card"
@click="shareModal.open()"
>
<span class="max-sm:sr-only">share</span>
</ButtonBase>
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

Localise the new share button label text.

Line 237 and Line 240 use hardcoded English strings; this should use $t(...) so the header stays fully localised.

🌐 Proposed fix
         <ButtonBase
           classicon="i-lucide:share-2"
-          aria-label="Share package card"
+          :aria-label="$t('package.share_card')"
           `@click`="shareModal.open()"
         >
-          <span class="max-sm:sr-only">share</span>
+          <span class="max-sm:sr-only">{{ $t('common.share') }}</span>
         </ButtonBase>
📝 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
aria-label="Share package card"
@click="shareModal.open()"
>
<span class="max-sm:sr-only">share</span>
</ButtonBase>
aria-label="Share package card"
`@click`="shareModal.open()"
>
<span class="max-sm:sr-only">share</span>
Suggested change
aria-label="Share package card"
@click="shareModal.open()"
>
<span class="max-sm:sr-only">share</span>
</ButtonBase>
:aria-label="$t('package.share_card')"
`@click`="shareModal.open()"
>
<span class="max-sm:sr-only">{{ $t('common.share') }}</span>

Comment on lines +8 to +17
export function withAlpha(color: string, alpha: number): string {
if (color.startsWith('oklch(')) return color.replace(')', ` / ${alpha})`)
if (color.startsWith('#'))
return (
color +
Math.round(alpha * 255)
.toString(16)
.padStart(2, '0')
)
return color
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 | 🟠 Major

withAlpha can emit invalid colour formats for valid inputs.

Lines 9-16 append alpha blindly, which breaks inputs that already include alpha (oklch(... / a) or #RRGGBBAA) and allows out-of-range alpha values.

🎨 Proposed fix
 export function withAlpha(color: string, alpha: number): string {
-  if (color.startsWith('oklch(')) return color.replace(')', ` / ${alpha})`)
-  if (color.startsWith('#'))
-    return (
-      color +
-      Math.round(alpha * 255)
-        .toString(16)
-        .padStart(2, '0')
-    )
+  const clamped = Math.min(Math.max(alpha, 0), 1)
+  if (color.startsWith('oklch(')) {
+    const withoutAlpha = color.replace(/\s*\/\s*[^)]+(?=\))/i, '')
+    return withoutAlpha.replace(')', ` / ${clamped})`)
+  }
+  if (/^#([a-f\d]{6}|[a-f\d]{8})$/i.test(color)) {
+    const base = color.slice(0, 7)
+    const a = Math.round(clamped * 255).toString(16).padStart(2, '0')
+    return `${base}${a}`
+  }
   return color
 }

Comment on lines +12 to +23
export function formatDate(date: string | undefined): string {
if (!date) return ''
try {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return date
}
}
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

Invalid Date is not caught by try/catch — consider explicit validation.

new Date(invalidString) doesn't throw; it returns an Invalid Date object. Calling toLocaleDateString() on it returns the string "Invalid Date" rather than throwing. The fallback to date won't trigger for malformed inputs.

🛡️ Proposed fix to handle Invalid Date
 export function formatDate(date: string | undefined): string {
   if (!date) return ''
-  try {
-    return new Date(date).toLocaleDateString('en-US', {
-      year: 'numeric',
-      month: 'short',
-      day: 'numeric',
-    })
-  } catch {
-    return date
-  }
+  const parsed = new Date(date)
+  if (Number.isNaN(parsed.getTime())) return date
+  return parsed.toLocaleDateString('en-US', {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+  })
 }
📝 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
export function formatDate(date: string | undefined): string {
if (!date) return ''
try {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return date
}
}
export function formatDate(date: string | undefined): string {
if (!date) return ''
const parsed = new Date(date)
if (Number.isNaN(parsed.getTime())) return date
return parsed.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}

Comment on lines +1 to +2
export function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s
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

Handle non-positive n explicitly in truncate.

Line 2 misbehaves for n <= 0 (e.g. negative/zero limits can still return content). Please guard this boundary.

✂️ Proposed fix
 export function truncate(s: string, n: number): string {
-  return s.length > n ? s.slice(0, n - 1) + '…' : s
+  if (n <= 0) return ''
+  if (s.length <= n) return s
+  return n === 1 ? '…' : s.slice(0, n - 1) + '…'
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sharing card for package page

1 participant