diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index d48313a32..065939e96 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -393,6 +393,9 @@ const canonicalUrl = computed(() => { return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base }) +// Markdown alternate URL for AI/LLM consumption +const markdownUrl = computed(() => `${canonicalUrl.value}.md`) + //atproto // TODO: Maybe set this where it's not loaded here every load? const { user } = useAtproto() @@ -451,7 +454,10 @@ const likeAction = async () => { } useHead({ - link: [{ rel: 'canonical', href: canonicalUrl }], + link: [ + { rel: 'canonical', href: canonicalUrl }, + { rel: 'alternate', type: 'text/markdown', href: markdownUrl }, + ], }) useSeoMeta({ diff --git a/codecov.yml b/codecov.yml index 75f04d965..986f4395b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,6 +7,11 @@ coverage: default: informational: true +# Ignore files that are covered by browser tests (Playwright) rather than unit tests +ignore: + - 'app/pages/**/*' + - 'app/layouts/**/*' + comment: layout: 'reach,diff,flags,tree,components,tests,build' behavior: default diff --git a/nuxt.config.ts b/nuxt.config.ts index 68bf20c6b..64530e5d0 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -89,6 +89,7 @@ export default defineNuxtConfig({ '/**': { isr: getISRConfig(60, true) }, '/api/**': { isr: 60 }, '/200.html': { prerender: true }, + '/raw/**': { isr: 60 }, '/package/**': { isr: getISRConfig(60, true) }, '/:pkg/.well-known/skills/**': { isr: 3600 }, '/:scope/:pkg/.well-known/skills/**': { isr: 3600 }, diff --git a/server/routes/raw/[...slug].md.get.ts b/server/routes/raw/[...slug].md.get.ts new file mode 100644 index 000000000..85161bf77 --- /dev/null +++ b/server/routes/raw/[...slug].md.get.ts @@ -0,0 +1,226 @@ +import { generatePackageMarkdown } from '../../utils/markdown' +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { NPM_MISSING_README_SENTINEL, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' + +// Cache TTL matches the ISR config for /raw/** routes (60 seconds) +const CACHE_MAX_AGE = 60 + +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 parsePackageParamsFromSlug(slug: string): { + rawPackageName: string + rawVersion: string | undefined +} { + const segments = slug.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, + } +} + +export default defineEventHandler(async event => { + // Get the slug parameter - Nitro captures it as "slug.md" due to the route pattern + const params = getRouterParams(event) + const slugParam = params['slug.md'] || params.slug + + if (!slugParam) { + throw createError({ + statusCode: 404, + statusMessage: 'Package not found', + }) + } + + // Remove .md suffix if present (it will be there from the route) + const slug = slugParam.endsWith('.md') ? slugParam.slice(0, -3) : slugParam + + const { rawPackageName, rawVersion } = parsePackageParamsFromSlug(slug) + + if (!rawPackageName) { + throw createError({ + statusCode: 404, + statusMessage: 'Package not found', + }) + } + + const { packageName, version } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + + let packageData + try { + packageData = await fetchNpmPackage(packageName) + } catch { + throw createError({ + statusCode: 502, + statusMessage: ERROR_NPM_FETCH_FAILED, + }) + } + + let targetVersion = version + if (!targetVersion) { + targetVersion = packageData['dist-tags']?.latest + } + + if (!targetVersion) { + throw createError({ + statusCode: 404, + statusMessage: 'Package version not found', + }) + } + + const versionData = packageData.versions[targetVersion] + if (!versionData) { + throw createError({ + statusCode: 404, + statusMessage: 'Package version not found', + }) + } + + 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 markdown = generatePackageMarkdown({ + pkg: packageData, + version: versionData, + readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null, + weeklyDownloads: weeklyDownloadsData?.downloads, + dailyDownloads: dailyDownloads ?? undefined, + }) + + setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') + setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE}, stale-while-revalidate`) + + return markdown +}) diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts new file mode 100644 index 000000000..dd20750d3 --- /dev/null +++ b/server/utils/markdown.ts @@ -0,0 +1,289 @@ +import type { Packument, PackumentVersion } from '#shared/types' +import { joinURL } from 'ufo' + +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 + // Clamp index to valid bounds to prevent floating-point edge cases + const rawIndex = Math.round(normalized * (SPARKLINE_CHARS.length - 1)) + const index = Math.min(SPARKLINE_CHARS.length - 1, Math.max(0, rawIndex)) + return SPARKLINE_CHARS[index] + }) + .join('') +} + +function formatNumber(num: number): string { + return new Intl.NumberFormat('en-US').format(num) +} + +function formatCompactNumber(num: number): string { + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).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, +): string | null { + if (!repository) return null + // Handle both string and object forms of repository field + const repoUrl = typeof repository === 'string' ? repository : repository.url + if (!repoUrl) return null + let url = normalizeGitUrl(repoUrl) + // Skip non-HTTP URLs after normalization + if (!isHttpUrl(url)) return null + // Append directory for monorepo packages (only available in object form) + if (typeof repository !== 'string' && 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 +} + +export function generatePackageMarkdown(options: PackageMarkdownOptions): string { + const { pkg, version, readme, weeklyDownloads, dailyDownloads, installSize } = 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 and trend numbers + 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) { + // Add both numeric trend and visual sparkline for LLM + human readability + const compactTotals = weeklyTotals.map(formatCompactNumber).join(' ') + downloadCell += ` [${compactTotals}] ${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 !== undefined) { + 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://npmx.dev/~${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/test/unit/server/utils/markdown.spec.ts b/test/unit/server/utils/markdown.spec.ts new file mode 100644 index 000000000..3b6a22d94 --- /dev/null +++ b/test/unit/server/utils/markdown.spec.ts @@ -0,0 +1,516 @@ +import { describe, expect, it } from 'vitest' +import { generatePackageMarkdown, generateSparkline } from '../../../../server/utils/markdown' +import type { Packument, PackumentVersion } from '#shared/types' + +describe('markdown utils', () => { + describe('generateSparkline', () => { + it('returns empty string for empty array', () => { + expect(generateSparkline([])).toBe('') + }) + + it('returns middle bars for constant values', () => { + expect(generateSparkline([5, 5, 5, 5])).toBe('▄▄▄▄') + }) + + it('generates sparkline for ascending values', () => { + const result = generateSparkline([1, 2, 3, 4, 5]) + expect(result).toBe(' ▂▄▆█') + }) + + it('generates sparkline for descending values', () => { + const result = generateSparkline([5, 4, 3, 2, 1]) + expect(result).toBe('█▆▄▂ ') + }) + + it('handles single value', () => { + expect(generateSparkline([100])).toBe('▄') + }) + + it('handles values with large range', () => { + const result = generateSparkline([0, 1000000]) + expect(result).toBe(' █') + }) + }) + + describe('generatePackageMarkdown', () => { + const createMockPkg = (overrides?: Partial): Packument => + ({ + _id: 'test-package', + _rev: '1-abc', + name: 'test-package', + description: 'A test package', + license: 'MIT', + maintainers: [{ name: 'Test User', email: 'test@example.com' }], + time: { + created: '2024-01-01T00:00:00.000Z', + modified: '2024-06-15T00:00:00.000Z', + }, + ...overrides, + }) as Packument + + const createMockVersion = (overrides?: Partial): PackumentVersion => + ({ + name: 'test-package', + version: '1.0.0', + dependencies: {}, + keywords: ['test', 'package'], + dist: { + tarball: 'https://registry.npmjs.org/test-package/-/test-package-1.0.0.tgz', + shasum: 'abc123', + }, + ...overrides, + }) as PackumentVersion + + it('generates markdown with basic package info', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('# test-package') + expect(result).toContain('> A test package') + expect(result).toContain('**Version:** 1.0.0') + expect(result).toContain('**License:** MIT') + }) + + it('includes install command', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('## Install') + expect(result).toContain('npm install test-package') + }) + + it('includes npm link', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [npm](https://www.npmjs.com/package/test-package)') + }) + + it('includes repository link when available', () => { + const pkg = createMockPkg({ + repository: { + type: 'git', + url: 'https://github.com/user/repo', + }, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Repository](https://github.com/user/repo)') + }) + + it('normalizes git+ URLs', () => { + const pkg = createMockPkg({ + repository: { + type: 'git', + url: 'git+https://github.com/user/repo.git', + }, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Repository](https://github.com/user/repo)') + }) + + it('normalizes git:// URLs to https://', () => { + const pkg = createMockPkg({ + repository: { + type: 'git', + url: 'git://github.com/user/repo.git', + }, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Repository](https://github.com/user/repo)') + }) + + it('normalizes SSH URLs to HTTPS', () => { + const pkg = createMockPkg({ + repository: { + type: 'git', + url: 'git@github.com:user/repo.git', + }, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Repository](https://github.com/user/repo)') + }) + + it('skips non-HTTP URLs after normalization', () => { + const pkg = createMockPkg({ + repository: { + type: 'git', + url: 'file:///path/to/repo', + }, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).not.toContain('- [Repository]') + }) + + it('handles monorepo packages with directory', () => { + const pkg = createMockPkg({ + repository: { + type: 'git', + url: 'https://github.com/user/monorepo', + directory: 'packages/sub-package', + }, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain( + '- [Repository](https://github.com/user/monorepo/tree/HEAD/packages/sub-package)', + ) + }) + + it('handles string repository URL', () => { + const pkg = createMockPkg({ + repository: 'https://github.com/user/repo' as any, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Repository](https://github.com/user/repo)') + }) + + it('normalizes string repository with git+ prefix', () => { + const pkg = createMockPkg({ + repository: 'git+https://github.com/user/repo.git' as any, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Repository](https://github.com/user/repo)') + }) + + it('handles GitHub shorthand string repository', () => { + const pkg = createMockPkg({ + repository: 'github:user/repo' as any, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + // github: shorthand is not a valid HTTP URL, so should be skipped + expect(result).not.toContain('- [Repository]') + }) + + it('includes homepage link when different from repo', () => { + const pkg = createMockPkg({ + repository: { url: 'https://github.com/user/repo' }, + }) + const version = createMockVersion({ + homepage: 'https://docs.example.com', + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Homepage](https://docs.example.com)') + }) + + it('excludes homepage when same as repo URL', () => { + const pkg = createMockPkg({ + repository: { url: 'https://github.com/user/repo' }, + }) + const version = createMockVersion({ + homepage: 'https://github.com/user/repo', + }) + + const result = generatePackageMarkdown({ pkg, version }) + + // Should only have one occurrence in Links section + const matches = result.match(/https:\/\/github\.com\/user\/repo/g) + expect(matches?.length).toBe(1) + }) + + it('includes bugs link when available', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + bugs: { url: 'https://github.com/user/repo/issues' }, + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Issues](https://github.com/user/repo/issues)') + }) + + it('includes weekly downloads in stats', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ + pkg, + version, + weeklyDownloads: 1234567, + }) + + expect(result).toContain('Downloads (weekly)') + expect(result).toContain('1,234,567') + }) + + it('includes sparkline with download trend', () => { + const pkg = createMockPkg() + const version = createMockVersion() + const dailyDownloads = Array.from({ length: 14 }, (_, i) => ({ + day: `2024-01-${String(i + 1).padStart(2, '0')}`, + downloads: (i + 1) * 1000, + })) + + const result = generatePackageMarkdown({ + pkg, + version, + weeklyDownloads: 100000, + dailyDownloads, + }) + + // Should contain compact numbers and sparkline + expect(result).toMatch(/\[[\d.]+[KMB]?\s+[\d.]+[KMB]?\]/) + expect(result).toMatch(/[▁▂▃▄▅▆▇█]/) + }) + + it('includes dependencies count', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + dependencies: { + lodash: '^4.0.0', + express: '^5.0.0', + }, + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('Dependencies') + expect(result).toContain('| 2 |') + }) + + it('shows zero dependencies when none exist', () => { + const pkg = createMockPkg() + const version = createMockVersion({ dependencies: undefined }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('| 0 |') + }) + + it('includes install size when provided', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ + pkg, + version, + installSize: 1024 * 1024 * 2.5, // 2.5 MB + }) + + expect(result).toContain('Install Size') + expect(result).toContain('2.5 MB') + }) + + it('includes install size of 0 when explicitly provided', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ + pkg, + version, + installSize: 0, + }) + + expect(result).toContain('Install Size') + expect(result).toContain('0 B') + }) + + it('falls back to unpacked size when no install size', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + dist: { + tarball: 'https://example.com/tarball.tgz', + shasum: 'abc123', + unpackedSize: 50000, + signatures: [], + }, + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('Package Size') + expect(result).toContain('48.8 kB') + }) + + it('includes keywords section', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + keywords: ['test', 'package', 'npm'], + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('## Keywords') + expect(result).toContain('test, package, npm') + }) + + it('limits keywords to 20', () => { + const pkg = createMockPkg() + const keywords = Array.from({ length: 30 }, (_, i) => `keyword${i}`) + const version = createMockVersion({ keywords }) + + const result = generatePackageMarkdown({ pkg, version }) + + const keywordLine = result.split('\n').find(line => line.includes('keyword0')) + expect(keywordLine?.split(',').length).toBe(20) + }) + + it('includes maintainers section', () => { + const pkg = createMockPkg({ + maintainers: [ + { name: 'Alice', email: 'alice@example.com' }, + { name: 'Bob', email: 'bob@example.com' }, + ], + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('## Maintainers') + expect(result).toContain('- Alice') + expect(result).toContain('- Bob') + }) + + it('links maintainers with username', () => { + const pkg = createMockPkg({ + maintainers: [{ name: 'Alice', email: 'alice@example.com', username: 'alice123' } as any], + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [Alice](https://npmx.dev/~alice123)') + }) + + it('limits maintainers to 10', () => { + const maintainers = Array.from({ length: 15 }, (_, i) => ({ + name: `User${i}`, + email: `user${i}@example.com`, + })) + const pkg = createMockPkg({ maintainers }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- User0') + expect(result).toContain('- User9') + expect(result).not.toContain('- User10') + }) + + it('includes README section when provided', () => { + const pkg = createMockPkg() + const version = createMockVersion() + const readme = '# My Package\n\nThis is the readme content.' + + const result = generatePackageMarkdown({ pkg, version, readme }) + + expect(result).toContain('## README') + expect(result).toContain('# My Package') + expect(result).toContain('This is the readme content.') + }) + + it('truncates very long READMEs', () => { + const pkg = createMockPkg() + const version = createMockVersion() + const readme = 'x'.repeat(600 * 1024) // 600KB, over the 500KB limit + + const result = generatePackageMarkdown({ pkg, version, readme }) + + expect(result).toContain('*[README truncated due to size]*') + expect(result.length).toBeLessThan(600 * 1024) + }) + + it('omits README section when readme is empty', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version, readme: ' ' }) + + expect(result).not.toContain('## README') + }) + + it('omits README section when readme is null', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version, readme: null }) + + expect(result).not.toContain('## README') + }) + + it('escapes markdown special characters in description', () => { + const pkg = createMockPkg({ + description: 'A *test* package with [special] _chars_', + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('> A \\*test\\* package with \\[special\\] \\_chars\\_') + }) + + it('handles package without description', () => { + const pkg = createMockPkg({ description: undefined }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('# test-package') + expect(result).not.toContain('>') + }) + + it('handles package without license', () => { + const pkg = createMockPkg({ license: undefined }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).not.toContain('**License:**') + }) + + it('validates homepage URLs', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + homepage: 'javascript:alert("xss")', + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).not.toContain('javascript:') + }) + + it('validates bugs URLs', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + bugs: { url: 'file:///etc/passwd' }, + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).not.toContain('file://') + }) + }) +}) diff --git a/vercel.json b/vercel.json index 602fb126a..ec92ae5fb 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,18 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "trailingSlash": false, + "rewrites": [ + { + "source": "/:path((?!api|_nuxt|_v|__nuxt|search|code|raw/).*)", + "has": [{ "type": "header", "key": "accept", "value": "(.*?)text/markdown(.*)" }], + "destination": "/raw/:path.md" + }, + { + "source": "/:path((?!api|_nuxt|_v|__nuxt|search|code|raw/).*)", + "has": [{ "type": "header", "key": "user-agent", "value": "curl/.*" }], + "destination": "/raw/:path.md" + } + ], "redirects": [ { "source": "/",