From 3056a7501d4f693dc260aaa22a17c0bfa23ced16 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 26 Mar 2026 11:33:33 +0000 Subject: [PATCH 1/2] feat: add markdown output support for package pages Serve package info as Markdown via /raw/.md routes. Clients can request Markdown by adding Accept: text/markdown header to any package URL (/package/vue, /vue, etc.). Content includes: metadata, stats, links (npmx + npm + repo + homepage), compatibility (engines), dist-tags, keywords, maintainers, and README. - Add server route handler at /raw/[...slug].md.get.ts - Add markdown generation utility at server/utils/markdown.ts - Add Vercel rewrite rules for content negotiation - Add ISR caching (60s) for /raw/** routes - Add to package page - Add comprehensive unit tests (35 tests, ~98% coverage) --- app/pages/package/[[org]]/[name].vue | 5 +- nuxt.config.ts | 1 + server/routes/raw/[...slug].md.get.ts | 172 +++++++++ server/utils/markdown.ts | 225 +++++++++++ test/unit/server/utils/markdown.spec.ts | 492 ++++++++++++++++++++++++ vercel.json | 32 ++ 6 files changed, 926 insertions(+), 1 deletion(-) create mode 100644 server/routes/raw/[...slug].md.get.ts create mode 100644 server/utils/markdown.ts create mode 100644 test/unit/server/utils/markdown.spec.ts diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index bc10db6210..9983fbdd32 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -487,7 +487,10 @@ const numberFormatter = useNumberFormatter() const bytesFormatter = useBytesFormatter() useHead({ - link: [{ rel: 'canonical', href: canonicalUrl }], + link: [ + { rel: 'canonical', href: canonicalUrl }, + { rel: 'alternate', type: 'text/markdown', href: `/raw/${packageName.value}.md` }, + ], }) useSeoMeta({ diff --git a/nuxt.config.ts b/nuxt.config.ts index d206d0824f..b9306a0e2d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -100,6 +100,7 @@ export default defineNuxtConfig({ routeRules: { // API routes '/api/**': { isr: 300 }, + '/raw/**': { isr: 60 }, '/api/registry/badge/**': { isr: { expiration: 60 * 60 /* one hour */, diff --git a/server/routes/raw/[...slug].md.get.ts b/server/routes/raw/[...slug].md.get.ts new file mode 100644 index 0000000000..0ede4e75a4 --- /dev/null +++ b/server/routes/raw/[...slug].md.get.ts @@ -0,0 +1,172 @@ +import { generatePackageMarkdown } from '../../utils/markdown' +import { + isStandardReadme, + fetchReadmeFromJsdelivr, +} from '../../utils/readme-loaders' +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', +] + +function encodePackageName(name: string): string { + if (name.startsWith('@')) { + return `@${encodeURIComponent(name.slice(1))}` + } + return encodeURIComponent(name) +} + +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 + } +} + +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 = await fetchWeeklyDownloads(packageName) + + const markdown = generatePackageMarkdown({ + pkg: packageData, + version: versionData, + readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null, + weeklyDownloads: weeklyDownloadsData?.downloads, + }) + + 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 0000000000..db201a3ef4 --- /dev/null +++ b/server/utils/markdown.ts @@ -0,0 +1,225 @@ +import type { Packument, PackumentVersion } from '#shared/types' +import { normalizeGitUrl } from '#shared/utils/git-providers' +import { joinURL } from 'ufo' + +const MAX_README_SIZE = 500 * 1024 // 500KB, matching MAX_FILE_SIZE in file API + +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 isHttpUrl(url: string): boolean { + const parsed = URL.parse(url) + if (!parsed) return false + return parsed.protocol === 'http:' || parsed.protocol === 'https:' +} + +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 + const normalized = normalizeGitUrl(repoUrl) + // normalizeGitUrl returns null for empty/invalid URLs, and may return non-HTTP URLs + if (!normalized || !isHttpUrl(normalized)) return null + // Strip .git suffix for cleaner display URLs + const cleanUrl = normalized.replace(/\.git$/, '') + // Append directory for monorepo packages (only available in object form) + if (typeof repository !== 'string' && repository.directory) { + return joinURL(`${cleanUrl}/tree/HEAD`, repository.directory) + } + return cleanUrl +} + +export interface PackageMarkdownOptions { + pkg: Packument + version: PackumentVersion + readme?: string | null + weeklyDownloads?: number + installSize?: number +} + +export function generatePackageMarkdown(options: PackageMarkdownOptions): string { + const { pkg, version, readme, weeklyDownloads, 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) { + const licenseText = typeof pkg.license === 'string' ? pkg.license : pkg.license.type + metaParts.push(`**License:** ${escapeMarkdown(licenseText)}`) + } + + 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 + if (weeklyDownloads !== undefined) { + statsHeaders.push('Downloads (weekly)') + statsSeparators.push('---') + statsValues.push(formatNumber(weeklyDownloads)) + } + + // 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('') + } + + // Links section + const links: Array<{ label: string; url: string }> = [] + + links.push({ label: 'npmx', url: `https://npmx.dev/package/${pkg.name}` }) + 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('') + } + + // Compatibility (engines) + if (version.engines && Object.keys(version.engines).length > 0) { + lines.push('## Compatibility') + lines.push('') + for (const [engine, range] of Object.entries(version.engines)) { + lines.push(`- **${escapeMarkdown(engine)}:** ${escapeMarkdown(range)}`) + } + lines.push('') + } + + // Dist-tags + const distTags = pkg['dist-tags'] + if (distTags && Object.keys(distTags).length > 0) { + lines.push('## Dist-tags') + lines.push('') + for (const [tag, tagVersion] of Object.entries(distTags)) { + lines.push(`- **${escapeMarkdown(tag)}:** ${tagVersion}`) + } + 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 + // maintainers is user-supplied so we escape both name and username + const username = (maintainer as { username?: string }).username + const safeName = escapeMarkdown(maintainer.name || username || 'Unknown') + if (username) { + lines.push(`- [${safeName}](https://npmx.dev/~${encodeURIComponent(username)})`) + } else { + lines.push(`- ${safeName}`) + } + } + 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 0000000000..89551673c8 --- /dev/null +++ b/test/unit/server/utils/markdown.spec.ts @@ -0,0 +1,492 @@ +import { describe, expect, it } from 'vitest' +import { generatePackageMarkdown } from '../../../../server/utils/markdown' +import type { Packument, PackumentVersion } from '#shared/types' + +describe('markdown utils', () => { + 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 npmx and npm links', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('- [npmx](https://npmx.dev/package/test-package)') + expect(result).toContain('- [npm](https://www.npmjs.com/package/test-package)') + }) + + it('does not include install section', () => { + const pkg = createMockPkg() + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).not.toContain('## Install') + }) + + 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: '', + }, + }) + 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 empty string repository', () => { + const pkg = createMockPkg({ + repository: '' as any, + }) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + 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 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('includes engines section when available', () => { + const pkg = createMockPkg() + const version = createMockVersion({ + engines: { node: '>=18.0.0', npm: '>=9.0.0' }, + }) + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('## Compatibility') + expect(result).toContain('**node:** >=18.0.0') + expect(result).toContain('**npm:** >=9.0.0') + }) + + it('includes dist-tags section', () => { + const pkg = createMockPkg({ + 'dist-tags': { latest: '1.0.0', next: '2.0.0-beta.1' }, + } as any) + const version = createMockVersion() + + const result = generatePackageMarkdown({ pkg, version }) + + expect(result).toContain('## Dist-tags') + expect(result).toContain('**latest:** 1.0.0') + expect(result).toContain('**next:** 2.0.0-beta.1') + }) + + 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 351f0d4bf9..068db2873a 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,38 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "trailingSlash": false, + "rewrites": [ + { + "source": "/package/:path(.*)\\.md", + "destination": "/raw/:path.md" + }, + { + "source": "/:path((?!api|_nuxt|_v|__nuxt|search|code|raw/).*)\\.md", + "destination": "/raw/:path.md" + }, + { + "source": "/package/:path(.*)", + "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": "accept", + "value": "(.*?)text/markdown(.*)" + } + ], + "destination": "/raw/:path.md" + } + ], "redirects": [ { "source": "/(.*)", From 741d4aa0575c1fd6c0dde6bd4c7183984dc4ede6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:07:10 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- server/routes/raw/[...slug].md.get.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/routes/raw/[...slug].md.get.ts b/server/routes/raw/[...slug].md.get.ts index 0ede4e75a4..4a7f8d1b70 100644 --- a/server/routes/raw/[...slug].md.get.ts +++ b/server/routes/raw/[...slug].md.get.ts @@ -1,8 +1,5 @@ import { generatePackageMarkdown } from '../../utils/markdown' -import { - isStandardReadme, - fetchReadmeFromJsdelivr, -} from '../../utils/readme-loaders' +import { isStandardReadme, fetchReadmeFromJsdelivr } from '../../utils/readme-loaders' import * as v from 'valibot' import { PackageRouteParamsSchema } from '#shared/schemas/package' import { NPM_MISSING_README_SENTINEL, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'