From 4f66bb15d50db904a66743790885581d218c14ef Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 27 Jan 2026 00:39:06 +0000 Subject: [PATCH 1/8] 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 0be158403..829ddcb90 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -316,8 +316,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 9e1bda7f1..d4baab23b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -77,6 +77,7 @@ export default defineNuxtConfig({ '/': { prerender: true }, '/opensearch.xml': { isr: true }, '/**': { isr: 60 }, + '/*.md': { isr: 60 }, '/package/**': { isr: 60 }, '/search': { isr: false, cache: false }, // infinite cache (versioned - doesn't change) diff --git a/server/middleware/markdown.ts b/server/middleware/markdown.ts new file mode 100644 index 000000000..4296f731c --- /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 000000000..76540e18e --- /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 ec6982143..c70989cc4 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 c9750201f46fc20d68c87f299383e1ddcfd69278 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 27 Jan 2026 00:57:08 +0000 Subject: [PATCH 2/8] 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 4296f731c..dcf1452d1 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 76540e18e..d9e285aa4 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 91a16eea25167ef29b0b2260ba647d9950c52a0a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 31 Jan 2026 02:19:26 +0000 Subject: [PATCH 3/8] refactor: use /raw/ server route instead of middleware for markdown Address review feedback from @atinux: - Replace middleware with server route at /raw/[...slug].md - Update vercel.json rewrites to point to /raw/:path.md - Add curl user-agent rewrite support for CLI tools - Enable CDN-level caching for markdown responses Also fix maintainer URLs to use npmx.dev instead of npmjs.com --- nuxt.config.ts | 2 +- .../raw/[...slug].md.get.ts} | 92 ++++++++----------- server/utils/markdown.ts | 2 +- vercel.json | 9 +- 4 files changed, 45 insertions(+), 60 deletions(-) rename server/{middleware/markdown.ts => routes/raw/[...slug].md.get.ts} (77%) diff --git a/nuxt.config.ts b/nuxt.config.ts index d4baab23b..8fb3af2c6 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -77,7 +77,7 @@ export default defineNuxtConfig({ '/': { prerender: true }, '/opensearch.xml': { isr: true }, '/**': { isr: 60 }, - '/*.md': { isr: 60 }, + '/raw/**': { isr: 60 }, '/package/**': { isr: 60 }, '/search': { isr: false, cache: false }, // infinite cache (versioned - doesn't change) diff --git a/server/middleware/markdown.ts b/server/routes/raw/[...slug].md.get.ts similarity index 77% rename from server/middleware/markdown.ts rename to server/routes/raw/[...slug].md.get.ts index dcf1452d1..b0b467652 100644 --- a/server/middleware/markdown.ts +++ b/server/routes/raw/[...slug].md.get.ts @@ -1,4 +1,4 @@ -import { generatePackageMarkdown } from '../utils/markdown' +import { generatePackageMarkdown } from '../../utils/markdown' import * as v from 'valibot' import { PackageRouteParamsSchema } from '#shared/schemas/package' import { @@ -92,11 +92,11 @@ function isStandardReadme(filename: string | undefined): boolean { return !!filename && standardReadmePattern.test(filename) } -function parsePackageParamsFromPath(path: string): { +function parsePackageParamsFromSlug(slug: string): { rawPackageName: string rawVersion: string | undefined } { - const segments = path.slice(1).split('/').filter(Boolean) + const segments = slug.split('/').filter(Boolean) if (segments.length === 0) { return { rawPackageName: '', rawVersion: undefined } @@ -127,8 +127,22 @@ function parsePackageParamsFromPath(path: string): { } } -async function handleMarkdownRequest(packagePath: string): Promise { - const { rawPackageName, rawVersion } = parsePackageParamsFromPath(packagePath) +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({ @@ -142,7 +156,15 @@ async function handleMarkdownRequest(packagePath: string): Promise { version: rawVersion, }) - const packageData = await fetchNpmPackage(packageName) + let packageData + try { + packageData = await fetchNpmPackage(packageName) + } catch { + throw createError({ + statusCode: 502, + statusMessage: ERROR_NPM_FETCH_FAILED, + }) + } let targetVersion = version if (!targetVersion) { @@ -193,7 +215,7 @@ async function handleMarkdownRequest(packagePath: string): Promise { const repoInfo = parseRepositoryInfo(packageData.repository) - return generatePackageMarkdown({ + const markdown = generatePackageMarkdown({ pkg: packageData, version: versionData, readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null, @@ -201,55 +223,13 @@ async function handleMarkdownRequest(packagePath: string): Promise { 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 + setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') + setHeader( + event, + 'Cache-Control', + `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, stale-while-revalidate`, + ) - 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, - }) - } + return markdown }) diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts index d9e285aa4..0746152ae 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.dev/~${username})`) } else { lines.push(`- ${name}`) } diff --git a/vercel.json b/vercel.json index c70989cc4..98871a335 100644 --- a/vercel.json +++ b/vercel.json @@ -2,9 +2,14 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "rewrites": [ { - "source": "/:path((?!api|_nuxt|_v|__nuxt|search|code|\\.md$).*)", + "source": "/:path((?!api|_nuxt|_v|__nuxt|search|code|raw/).*)", "has": [{ "type": "header", "key": "accept", "value": "(.*?)text/markdown(.*)" }], - "destination": "/:path.md" + "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": [ From 7e2890e7bf6ddd71f657be3dcfb889cb4ef7aa23 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Feb 2026 17:03:50 +0000 Subject: [PATCH 4/8] fix: address PR review comments for markdown output - Fix Cache-Control mismatch: change from 3600s to 60s to match ISR TTL - Add URL validation after normalizeGitUrl() to skip non-HTTP URLs - Fix installSize check: use !== undefined instead of truthiness - Enhance sparkline with numeric trend values for LLM readability - Add comprehensive unit tests for markdown utils (97.97% coverage) --- server/routes/raw/[...slug].md.get.ts | 15 +- server/utils/markdown.ts | 16 +- test/unit/server/utils/markdown.spec.ts | 482 ++++++++++++++++++++++++ 3 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 test/unit/server/utils/markdown.spec.ts diff --git a/server/routes/raw/[...slug].md.get.ts b/server/routes/raw/[...slug].md.get.ts index b0b467652..5da18ff1a 100644 --- a/server/routes/raw/[...slug].md.get.ts +++ b/server/routes/raw/[...slug].md.get.ts @@ -1,11 +1,10 @@ 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 { 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 import { parseRepositoryInfo } from '#shared/utils/git-providers' const NPM_API = 'https://api.npmjs.org' @@ -225,11 +224,7 @@ export default defineEventHandler(async event => { }) setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') - setHeader( - event, - 'Cache-Control', - `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, stale-while-revalidate`, - ) + 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 index 0746152ae..6001e27fb 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -30,6 +30,12 @@ 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` @@ -65,6 +71,8 @@ function getRepositoryUrl(repository?: { }): string | null { if (!repository?.url) return null let url = normalizeGitUrl(repository.url) + // Skip non-HTTP URLs after normalization + if (!isHttpUrl(url)) return null // Append directory for monorepo packages if (repository.directory) { url = joinURL(`${url}/tree/HEAD`, repository.directory) @@ -160,7 +168,7 @@ export function generatePackageMarkdown(options: PackageMarkdownOptions): string const statsSeparators: string[] = [] const statsValues: string[] = [] - // Weekly downloads with sparkline + // Weekly downloads with sparkline and trend numbers if (weeklyDownloads !== undefined) { statsHeaders.push('Downloads (weekly)') statsSeparators.push('---') @@ -169,7 +177,9 @@ export function generatePackageMarkdown(options: PackageMarkdownOptions): string if (dailyDownloads && dailyDownloads.length > 0) { const weeklyTotals = buildWeeklyTotals(dailyDownloads) if (weeklyTotals.length > 1) { - downloadCell += ` ${generateSparkline(weeklyTotals)}` + // Add both numeric trend and visual sparkline for LLM + human readability + const compactTotals = weeklyTotals.map(formatCompactNumber).join(' ') + downloadCell += ` [${compactTotals}] ${generateSparkline(weeklyTotals)}` } } statsValues.push(downloadCell) @@ -182,7 +192,7 @@ export function generatePackageMarkdown(options: PackageMarkdownOptions): string statsValues.push(String(depCount)) // Install size - if (installSize) { + if (installSize !== undefined) { statsHeaders.push('Install Size') statsSeparators.push('---') statsValues.push(formatBytes(installSize)) diff --git a/test/unit/server/utils/markdown.spec.ts b/test/unit/server/utils/markdown.spec.ts new file mode 100644 index 000000000..9d808839d --- /dev/null +++ b/test/unit/server/utils/markdown.spec.ts @@ -0,0 +1,482 @@ +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('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://') + }) + }) +}) From fc6d7ea932a128039118ce3e22471785be463b94 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Feb 2026 18:54:33 +0000 Subject: [PATCH 5/8] fix: address CodeRabbit review comments - Add index clamping in generateSparkline() to prevent floating-point edge cases - Update getRepositoryUrl() to handle string repository values (common in npm metadata) - Add unit tests for string repository handling --- server/utils/markdown.ts | 29 +++++++++++++-------- test/unit/server/utils/markdown.spec.ts | 34 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts index 6001e27fb..7ea9360ad 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -20,7 +20,9 @@ export function generateSparkline(data: number[]): string { return data .map(val => { const normalized = (val - min) / range - const index = Math.round(normalized * (SPARKLINE_CHARS.length - 1)) + // 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('') @@ -64,17 +66,24 @@ function isHttpUrl(url: string): boolean { } } -function getRepositoryUrl(repository?: { - type?: string - url?: string - directory?: string -}): string | null { - if (!repository?.url) return null - let url = normalizeGitUrl(repository.url) +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 - if (repository.directory) { + // 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 diff --git a/test/unit/server/utils/markdown.spec.ts b/test/unit/server/utils/markdown.spec.ts index 9d808839d..3b6a22d94 100644 --- a/test/unit/server/utils/markdown.spec.ts +++ b/test/unit/server/utils/markdown.spec.ts @@ -179,6 +179,40 @@ describe('markdown utils', () => { ) }) + 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' }, From 6dc769b98dc0405b929192bc96f1b79067029607 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Feb 2026 19:51:32 +0000 Subject: [PATCH 6/8] fix: remove unused repoInfo param and exclude pages from codecov - Remove unused repoInfo parameter from PackageMarkdownOptions interface - Exclude app/pages and app/layouts from codecov patch coverage (these are tested by Playwright browser tests, not unit tests) --- codecov.yml | 5 +++++ server/utils/markdown.ts | 12 +----------- 2 files changed, 6 insertions(+), 11 deletions(-) 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/server/utils/markdown.ts b/server/utils/markdown.ts index 7ea9360ad..8c9e0a421 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -1,5 +1,4 @@ import type { Packument, PackumentVersion } from '#shared/types' -import type { RepositoryInfo } from '#shared/utils/git-providers' import { joinURL } from 'ufo' const SPARKLINE_CHARS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const @@ -126,19 +125,10 @@ export interface PackageMarkdownOptions { 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 { pkg, version, readme, weeklyDownloads, dailyDownloads, installSize } = options const lines: string[] = [] From e1f001cc9481156645ca636a949b56be70368b6a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 4 Feb 2026 21:00:47 +0000 Subject: [PATCH 7/8] fix: remove repoInfo from route handler after removing from interface --- server/routes/raw/[...slug].md.get.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/routes/raw/[...slug].md.get.ts b/server/routes/raw/[...slug].md.get.ts index 5da18ff1a..85161bf77 100644 --- a/server/routes/raw/[...slug].md.get.ts +++ b/server/routes/raw/[...slug].md.get.ts @@ -5,7 +5,6 @@ import { NPM_MISSING_README_SENTINEL, ERROR_NPM_FETCH_FAILED } from '#shared/uti // Cache TTL matches the ISR config for /raw/** routes (60 seconds) const CACHE_MAX_AGE = 60 -import { parseRepositoryInfo } from '#shared/utils/git-providers' const NPM_API = 'https://api.npmjs.org' @@ -212,15 +211,12 @@ export default defineEventHandler(async event => { fetchDownloadRange(packageName, 12), ]) - const repoInfo = parseRepositoryInfo(packageData.repository) - const markdown = generatePackageMarkdown({ pkg: packageData, version: versionData, readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null, weeklyDownloads: weeklyDownloadsData?.downloads, dailyDownloads: dailyDownloads ?? undefined, - repoInfo, }) setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') From c5cf3669f18e2f8fd2e468d2f9623d16b8a93dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 9 Feb 2026 18:07:55 +0100 Subject: [PATCH 8/8] Update server/utils/markdown.ts 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 8c9e0a421..dd20750d3 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -256,7 +256,7 @@ export function generatePackageMarkdown(options: PackageMarkdownOptions): string 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 + // 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) {