From 68b77995b46e5b09b27c296f203bce313079d88c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 27 Jan 2026 00:39:06 +0000 Subject: [PATCH 1/3] feat: add markdown output support for package pages 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 --- app/pages/[...package].vue | 8 +- nuxt.config.ts | 1 + server/middleware/markdown.ts | 249 ++++++++++++++++++++++++++++++ server/utils/markdown.ts | 278 ++++++++++++++++++++++++++++++++++ vercel.json | 7 + 5 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 server/middleware/markdown.ts create mode 100644 server/utils/markdown.ts diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 62d3de03..3bb44b71 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -314,8 +314,14 @@ const canonicalUrl = computed(() => { return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base }) +// Markdown alternate URL for AI/LLM consumption +const markdownUrl = computed(() => `${canonicalUrl.value}.md`) + useHead({ - link: [{ rel: 'canonical', href: canonicalUrl }], + link: [ + { rel: 'canonical', href: canonicalUrl }, + { rel: 'alternate', type: 'text/markdown', href: markdownUrl }, + ], }) useSeoMeta({ diff --git a/nuxt.config.ts b/nuxt.config.ts index 1a3b48b3..c9e4b2fa 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -50,6 +50,7 @@ export default defineNuxtConfig({ '/': { prerender: true }, '/opensearch.xml': { isr: true }, '/**': { isr: 60 }, + '/*.md': { isr: 60 }, '/package/**': { isr: 60 }, '/search': { isr: false, cache: false }, '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' }, diff --git a/server/middleware/markdown.ts b/server/middleware/markdown.ts new file mode 100644 index 00000000..4296f731 --- /dev/null +++ b/server/middleware/markdown.ts @@ -0,0 +1,249 @@ +import { generatePackageMarkdown } from '../utils/markdown' +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { + CACHE_MAX_AGE_ONE_HOUR, + NPM_MISSING_README_SENTINEL, + ERROR_NPM_FETCH_FAILED, +} from '#shared/utils/constants' +import { parseRepositoryInfo } from '#shared/utils/git-providers' + +const NPM_API = 'https://api.npmjs.org' + +const standardReadmeFilenames = [ + 'README.md', + 'readme.md', + 'Readme.md', + 'README', + 'readme', + 'README.markdown', + 'readme.markdown', +] + +const standardReadmePattern = /^readme(\.md|\.markdown)?$/i + +function encodePackageName(name: string): string { + if (name.startsWith('@')) { + return `@${encodeURIComponent(name.slice(1))}` + } + return encodeURIComponent(name) +} + +async function fetchReadmeFromJsdelivr( + packageName: string, + readmeFilenames: string[], + version?: string, +): Promise { + const versionSuffix = version ? `@${version}` : '' + + for (const filename of readmeFilenames) { + try { + const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}` + const response = await fetch(url) + if (response.ok) { + return await response.text() + } + } catch { + // Try next filename + } + } + + return null +} + +async function fetchWeeklyDownloads(packageName: string): Promise<{ downloads: number } | null> { + try { + const encodedName = encodePackageName(packageName) + return await $fetch<{ downloads: number }>( + `${NPM_API}/downloads/point/last-week/${encodedName}`, + ) + } catch { + return null + } +} + +async function fetchDownloadRange( + packageName: string, + weeks: number = 12, +): Promise | null> { + try { + const encodedName = encodePackageName(packageName) + const today = new Date() + const end = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1), + ) + const start = new Date(end) + start.setUTCDate(start.getUTCDate() - weeks * 7 + 1) + + const startStr = start.toISOString().split('T')[0] + const endStr = end.toISOString().split('T')[0] + + const response = await $fetch<{ + downloads: Array<{ day: string; downloads: number }> + }>(`${NPM_API}/downloads/range/${startStr}:${endStr}/${encodedName}`) + + return response.downloads + } catch { + return null + } +} + +function isStandardReadme(filename: string | undefined): boolean { + return !!filename && standardReadmePattern.test(filename) +} + +function parsePackageParamsFromPath(path: string): { + rawPackageName: string + rawVersion: string | undefined +} { + const segments = path.slice(1).split('/').filter(Boolean) + + if (segments.length === 0) { + return { rawPackageName: '', rawVersion: undefined } + } + + const vIndex = segments.indexOf('v') + + if (vIndex !== -1 && vIndex < segments.length - 1) { + return { + rawPackageName: segments.slice(0, vIndex).join('/'), + rawVersion: segments.slice(vIndex + 1).join('/'), + } + } + + const fullPath = segments.join('/') + const versionMatch = fullPath.match(/^(@[^/]+\/[^@]+|[^@]+)@(.+)$/) + if (versionMatch) { + const [, packageName, version] = versionMatch as [string, string, string] + return { + rawPackageName: packageName, + rawVersion: version, + } + } + + return { + rawPackageName: fullPath, + rawVersion: undefined, + } +} + +async function handleMarkdownRequest(packagePath: string): Promise { + const { rawPackageName, rawVersion } = parsePackageParamsFromPath(packagePath) + + if (!rawPackageName) { + throw createError({ + statusCode: 404, + statusMessage: 'Package not found', + }) + } + + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + + const packageData = await fetchNpmPackage(packageName) + + let targetVersion = version + if (!targetVersion) { + targetVersion = packageData['dist-tags']?.latest + } + + if (!targetVersion || !packageData.versions[targetVersion]) { + throw createError({ + statusCode: 404, + statusMessage: 'Package version not found', + }) + } + + const versionData = packageData.versions[targetVersion] + + let readmeContent: string | undefined + + if (version) { + readmeContent = versionData.readme + } else { + readmeContent = packageData.readme + } + + const readmeFilename = version ? versionData.readmeFilename : packageData.readmeFilename + const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL + + if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) { + const jsdelivrReadme = await fetchReadmeFromJsdelivr( + packageName, + standardReadmeFilenames, + targetVersion, + ) + if (jsdelivrReadme) { + readmeContent = jsdelivrReadme + } + } + + const [weeklyDownloadsData, dailyDownloads] = await Promise.all([ + fetchWeeklyDownloads(packageName), + fetchDownloadRange(packageName, 12), + ]) + + const repoInfo = parseRepositoryInfo(packageData.repository) + + return generatePackageMarkdown({ + pkg: packageData, + version: versionData, + readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null, + weeklyDownloads: weeklyDownloadsData?.downloads, + dailyDownloads: dailyDownloads ?? undefined, + repoInfo, + }) +} + +/** Handle .md suffix and Accept: text/markdown header requests */ +export default defineEventHandler(async event => { + const url = getRequestURL(event) + const path = url.pathname + + if ( + path.startsWith('/api/') || + path.startsWith('/_') || + path.startsWith('/__') || + path === '/search' || + path.startsWith('/search') || + path.startsWith('/code/') || + path === '/' || + path === '/.md' + ) { + return + } + + const isMarkdownPath = path.endsWith('.md') && path.length > 3 + const acceptHeader = getHeader(event, 'accept') ?? '' + const wantsMarkdown = acceptHeader.includes('text/markdown') + + if (!isMarkdownPath && !wantsMarkdown) { + return + } + + const packagePath = isMarkdownPath ? path.slice(0, -3) : path + + try { + const markdown = await handleMarkdownRequest(packagePath) + + setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') + setHeader( + event, + 'Cache-Control', + `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, stale-while-revalidate`, + ) + + return markdown + } catch (error: unknown) { + if (error && typeof error === 'object' && 'statusCode' in error) { + throw error + } + + throw createError({ + statusCode: 502, + statusMessage: ERROR_NPM_FETCH_FAILED, + }) + } +}) diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts new file mode 100644 index 00000000..76540e18 --- /dev/null +++ b/server/utils/markdown.ts @@ -0,0 +1,278 @@ +import type { Packument, PackumentVersion } from '#shared/types' +import type { RepositoryInfo } from '#shared/utils/git-providers' +import { joinURL } from 'ufo' + +const SPARKLINE_CHARS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] +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('') +} + +function formatNumber(num: number): string { + return new Intl.NumberFormat('en-US').format(num) +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function escapeMarkdown(text: string): string { + return text.replace(/([*_`[\]\\])/g, '\\$1') +} + +function normalizeGitUrl(url: string): string { + return url + .replace(/^git\+/, '') + .replace(/^git:\/\//, 'https://') + .replace(/\.git$/, '') + .replace(/^ssh:\/\/git@github\.com/, 'https://github.com') + .replace(/^git@github\.com:/, 'https://github.com/') +} + +function isHttpUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +function getRepositoryUrl(repository?: { + type?: string + url?: string + directory?: string +}): string | null { + if (!repository?.url) return null + let url = normalizeGitUrl(repository.url) + // Append directory for monorepo packages + if (repository.directory) { + url = joinURL(`${url}/tree/HEAD`, repository.directory) + } + return url +} + +function buildWeeklyTotals(dailyDownloads: Array<{ day: string; downloads: number }>): number[] { + if (!dailyDownloads.length) return [] + + // Sort by date + const sorted = [...dailyDownloads].sort((a, b) => a.day.localeCompare(b.day)) + + // Group into weeks (7 days each) + const weeks: number[] = [] + let weekTotal = 0 + let dayCount = 0 + + for (const entry of sorted) { + weekTotal += entry.downloads + dayCount++ + + if (dayCount === 7) { + weeks.push(weekTotal) + weekTotal = 0 + dayCount = 0 + } + } + + // Include partial last week if any days remain + if (dayCount > 0) { + weeks.push(weekTotal) + } + + return weeks +} + +export interface PackageMarkdownOptions { + pkg: Packument + version: PackumentVersion + readme?: string | null + weeklyDownloads?: number + dailyDownloads?: Array<{ day: string; downloads: number }> + installSize?: number + repoInfo?: RepositoryInfo +} + +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)) { + const name = maintainer.name || maintainer.username || 'Unknown' + if (maintainer.username) { + lines.push(`- [${name}](https://www.npmjs.com/~${maintainer.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') +} diff --git a/vercel.json b/vercel.json index ec698214..c70989cc 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,12 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { + "source": "/:path((?!api|_nuxt|_v|__nuxt|search|code|\\.md$).*)", + "has": [{ "type": "header", "key": "accept", "value": "(.*?)text/markdown(.*)" }], + "destination": "/:path.md" + } + ], "redirects": [ { "source": "/", From edf5815344486c75a1a22d0899b4fa3128e6f683 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 27 Jan 2026 00:57:08 +0000 Subject: [PATCH 2/3] fix: resolve TypeScript errors in markdown utilities --- server/middleware/markdown.ts | 8 +++++++- server/utils/markdown.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/server/middleware/markdown.ts b/server/middleware/markdown.ts index 4296f731..dcf1452d 100644 --- a/server/middleware/markdown.ts +++ b/server/middleware/markdown.ts @@ -149,7 +149,7 @@ async function handleMarkdownRequest(packagePath: string): Promise { targetVersion = packageData['dist-tags']?.latest } - if (!targetVersion || !packageData.versions[targetVersion]) { + if (!targetVersion) { throw createError({ statusCode: 404, statusMessage: 'Package version not found', @@ -157,6 +157,12 @@ async function handleMarkdownRequest(packagePath: string): Promise { } const versionData = packageData.versions[targetVersion] + if (!versionData) { + throw createError({ + statusCode: 404, + statusMessage: 'Package version not found', + }) + } let readmeContent: string | undefined diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts index 76540e18..d9e285aa 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -2,7 +2,7 @@ import type { Packument, PackumentVersion } from '#shared/types' import type { RepositoryInfo } from '#shared/utils/git-providers' import { joinURL } from 'ufo' -const SPARKLINE_CHARS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] +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 { @@ -247,9 +247,11 @@ export function generatePackageMarkdown(options: PackageMarkdownOptions): string lines.push('## Maintainers') lines.push('') for (const maintainer of pkg.maintainers.slice(0, 10)) { - const name = maintainer.name || maintainer.username || 'Unknown' - if (maintainer.username) { - lines.push(`- [${name}](https://www.npmjs.com/~${maintainer.username})`) + // 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}`) } From 2053f3d5952acba49660602ab8bf735d4f4e1cd9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 28 Jan 2026 09:16:39 +0000 Subject: [PATCH 3/3] Use _the_ website Co-authored-by: Okinea Dev --- server/utils/markdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts index d9e285aa..c5c0a569 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -251,7 +251,7 @@ export function generatePackageMarkdown(options: PackageMarkdownOptions): string const username = (maintainer as { username?: string }).username const name = maintainer.name || username || 'Unknown' if (username) { - lines.push(`- [${name}](https://www.npmjs.com/~${username})`) + lines.push(`- [${name}](https://npmx.com/~${username})`) } else { lines.push(`- ${name}`) }