-
Notifications
You must be signed in to change notification settings - Fork 95
feat: add markdown output support for package pages #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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
|
@BYK is attempting to deploy a commit to the danielroe Team on Vercel. A member of the Team first needs to authorize it. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| 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('') | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
| 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') | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>
|
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 |
Summary
Add support for getting package information in Markdown format, useful for AI/LLM consumption and command-line tools.
Access Methods
/<package>.md(e.g.,/vue.md,/@nuxt/kit.md)Accept: text/markdownOutput Includes
Security Hardening
javascript:protocol injection)