Skip to content

Conversation

@BYK
Copy link
Contributor

@BYK BYK commented Jan 27, 2026

Summary

Add support for getting package information in Markdown format, useful for AI/LLM consumption and command-line tools.

Access Methods

  • URL suffix: /<package>.md (e.g., /vue.md, /@nuxt/kit.md)
  • Accept header: Accept: text/markdown

Output Includes

  • Package metadata (name, version, license, last updated)
  • Stats table with weekly downloads (with sparkline trend) and dependency count
  • Install command
  • Links (npm, repository, homepage, issues)
  • Keywords and maintainers
  • Full README content

Security Hardening

  • URL validation for homepage/bugs links (prevents javascript: protocol injection)
  • README size limit (500KB) to prevent resource exhaustion
  • Path exclusion alignment between Vercel rewrites and middleware

Add support for getting package information in Markdown format via:
- URL suffix: /package-name.md
- Accept header: text/markdown

Includes security hardening:
- URL validation for homepage/bugs links (prevents javascript: injection)
- README size limit (500KB) to prevent DoS
- Path exclusion alignment between Vercel rewrites and middleware
@vercel
Copy link

vercel bot commented Jan 27, 2026

@BYK is attempting to deploy a commit to the danielroe Team on Vercel.

A member of the Team first needs to authorize it.

@BYK BYK marked this pull request as ready for review January 27, 2026 00:41
@danielroe danielroe requested a review from atinux January 27, 2026 00:51
@vercel
Copy link

vercel bot commented Jan 27, 2026

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

Project Deployment Actions Updated (UTC)
docs.npmx.dev Ready Ready Preview, Comment Jan 30, 2026 10:15am
npmx.dev Ready Ready Preview, Comment Jan 30, 2026 10:15am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
npmx-lunaria Ignored Ignored Jan 30, 2026 10:15am

Request Review

Comment on lines +5 to +27
const SPARKLINE_CHARS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
const MAX_README_SIZE = 500 * 1024 // 500KB, matching MAX_FILE_SIZE in file API

export function generateSparkline(data: number[]): string {
if (!data.length) return ''

const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min

// If all values are the same, use middle bar
if (range === 0) {
return SPARKLINE_CHARS[4].repeat(data.length)
}

return data
.map(val => {
const normalized = (val - min) / range
const index = Math.round(normalized * (SPARKLINE_CHARS.length - 1))
return SPARKLINE_CHARS[index]
})
.join('')
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know if this is true, but I think that LLM will have better understanding with a graph in the form of numbers instead of symbols, it's easier for us humans to perceive something visually

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How about both? This entire patch is LLM generated so I have a feeling that they are pretty competent at this point :D

Comment on lines 115 to 280
export function generatePackageMarkdown(options: PackageMarkdownOptions): string {
const {
pkg,
version,
readme,
weeklyDownloads,
dailyDownloads,
installSize,
repoInfo: _repoInfo,
} = options

const lines: string[] = []

// Title
lines.push(`# ${pkg.name}`)
lines.push('')

// Description
if (pkg.description) {
lines.push(`> ${escapeMarkdown(pkg.description)}`)
lines.push('')
}

// Version and metadata line
const metaParts: string[] = []
metaParts.push(`**Version:** ${version.version}`)

if (pkg.license) {
metaParts.push(`**License:** ${pkg.license}`)
}

if (pkg.time?.modified) {
const date = new Date(pkg.time.modified)
metaParts.push(`**Updated:** ${date.toLocaleDateString('en-US', { dateStyle: 'medium' })}`)
}

lines.push(metaParts.join(' | '))
lines.push('')

// Stats section
lines.push('## Stats')
lines.push('')

// Build stats table
const statsHeaders: string[] = []
const statsSeparators: string[] = []
const statsValues: string[] = []

// Weekly downloads with sparkline
if (weeklyDownloads !== undefined) {
statsHeaders.push('Downloads (weekly)')
statsSeparators.push('---')

let downloadCell = formatNumber(weeklyDownloads)
if (dailyDownloads && dailyDownloads.length > 0) {
const weeklyTotals = buildWeeklyTotals(dailyDownloads)
if (weeklyTotals.length > 1) {
downloadCell += ` ${generateSparkline(weeklyTotals)}`
}
}
statsValues.push(downloadCell)
}

// Dependencies count
const depCount = version.dependencies ? Object.keys(version.dependencies).length : 0
statsHeaders.push('Dependencies')
statsSeparators.push('---')
statsValues.push(String(depCount))

// Install size
if (installSize) {
statsHeaders.push('Install Size')
statsSeparators.push('---')
statsValues.push(formatBytes(installSize))
} else if (version.dist?.unpackedSize) {
statsHeaders.push('Package Size')
statsSeparators.push('---')
statsValues.push(formatBytes(version.dist.unpackedSize))
}

if (statsHeaders.length > 0) {
lines.push(`| ${statsHeaders.join(' | ')} |`)
lines.push(`| ${statsSeparators.join(' | ')} |`)
lines.push(`| ${statsValues.join(' | ')} |`)
lines.push('')
}

// Install section
lines.push('## Install')
lines.push('')
lines.push('```bash')
lines.push(`npm install ${pkg.name}`)
lines.push('```')
lines.push('')

// Links section
const links: Array<{ label: string; url: string }> = []

links.push({ label: 'npm', url: `https://www.npmjs.com/package/${pkg.name}` })

const repoUrl = getRepositoryUrl(pkg.repository)
if (repoUrl) {
links.push({ label: 'Repository', url: repoUrl })
}

if (version.homepage && version.homepage !== repoUrl && isHttpUrl(version.homepage)) {
links.push({ label: 'Homepage', url: version.homepage })
}

if (version.bugs?.url && isHttpUrl(version.bugs.url)) {
links.push({ label: 'Issues', url: version.bugs.url })
}

if (links.length > 0) {
lines.push('## Links')
lines.push('')
for (const link of links) {
lines.push(`- [${link.label}](${link.url})`)
}
lines.push('')
}

// Keywords
if (version.keywords && version.keywords.length > 0) {
lines.push('## Keywords')
lines.push('')
lines.push(version.keywords.slice(0, 20).join(', '))
lines.push('')
}

// Maintainers
if (pkg.maintainers && pkg.maintainers.length > 0) {
lines.push('## Maintainers')
lines.push('')
for (const maintainer of pkg.maintainers.slice(0, 10)) {
// npm API returns username but @npm/types Contact doesn't include it
const username = (maintainer as { username?: string }).username
const name = maintainer.name || username || 'Unknown'
if (username) {
lines.push(`- [${name}](https://www.npmjs.com/~${username})`)
} else {
lines.push(`- ${name}`)
}
}
lines.push('')
}

// README section
if (readme && readme.trim()) {
lines.push('---')
lines.push('')
lines.push('## README')
lines.push('')
const trimmedReadme = readme.trim()
if (trimmedReadme.length > MAX_README_SIZE) {
lines.push(trimmedReadme.slice(0, MAX_README_SIZE))
lines.push('')
lines.push('*[README truncated due to size]*')
} else {
lines.push(trimmedReadme)
}
lines.push('')
}

return lines.join('\n')
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it would be better to use Handlebars template for Markdown generation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd hesitate adding a template engine for this but I can look into switching to template strings. I like the array-based construction here as much as it looks old school. Simple, clear, effective. What's the problem you want to solve with handlebars? To have a better understanding of the final output?

Co-authored-by: Okinea Dev <hi@okinea.dev>
@danielroe
Copy link
Collaborator

danielroe commented Jan 28, 2026

ultimately it might make sense to share some logic with the client (see #169) so we can click 'copy as markdown'

...although that button could also fetch the markdown from the server endpoint, which might be better from a JS bundle size

so... just linking so you're aware

@BYK
Copy link
Contributor Author

BYK commented Jan 30, 2026

@atinux @okineadev are you waiting on me for something? (just making sure I'm not the blocker here)

@atinux
Copy link
Collaborator

atinux commented Jan 30, 2026

Sorry I was quite busy.

I suggest to not use a middleware here but instead having a server route with /raw prefix similar to how we have here: https://github.com/unjs/undocs/blob/main/app/server/routes/raw/%5B...slug%5D.md.get.ts

Then like on https://github.com/unjs/undocs/blob/main/app/modules/md-rewrite.ts, we can add the rewrite in the generated vercel.json to it happens at the CDN level

Would you be up to update your PR with this approach @BYK ?

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.

4 participants