Skip to content

Commit 4f66bb1

Browse files
committed
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
1 parent 7cf66b2 commit 4f66bb1

File tree

5 files changed

+542
-1
lines changed

5 files changed

+542
-1
lines changed

app/pages/[...package].vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,14 @@ const canonicalUrl = computed(() => {
316316
return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base
317317
})
318318
319+
// Markdown alternate URL for AI/LLM consumption
320+
const markdownUrl = computed(() => `${canonicalUrl.value}.md`)
321+
319322
useHead({
320-
link: [{ rel: 'canonical', href: canonicalUrl }],
323+
link: [
324+
{ rel: 'canonical', href: canonicalUrl },
325+
{ rel: 'alternate', type: 'text/markdown', href: markdownUrl },
326+
],
321327
})
322328
323329
useSeoMeta({

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export default defineNuxtConfig({
7777
'/': { prerender: true },
7878
'/opensearch.xml': { isr: true },
7979
'/**': { isr: 60 },
80+
'/*.md': { isr: 60 },
8081
'/package/**': { isr: 60 },
8182
'/search': { isr: false, cache: false },
8283
// infinite cache (versioned - doesn't change)

server/middleware/markdown.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { generatePackageMarkdown } from '../utils/markdown'
2+
import * as v from 'valibot'
3+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
4+
import {
5+
CACHE_MAX_AGE_ONE_HOUR,
6+
NPM_MISSING_README_SENTINEL,
7+
ERROR_NPM_FETCH_FAILED,
8+
} from '#shared/utils/constants'
9+
import { parseRepositoryInfo } from '#shared/utils/git-providers'
10+
11+
const NPM_API = 'https://api.npmjs.org'
12+
13+
const standardReadmeFilenames = [
14+
'README.md',
15+
'readme.md',
16+
'Readme.md',
17+
'README',
18+
'readme',
19+
'README.markdown',
20+
'readme.markdown',
21+
]
22+
23+
const standardReadmePattern = /^readme(\.md|\.markdown)?$/i
24+
25+
function encodePackageName(name: string): string {
26+
if (name.startsWith('@')) {
27+
return `@${encodeURIComponent(name.slice(1))}`
28+
}
29+
return encodeURIComponent(name)
30+
}
31+
32+
async function fetchReadmeFromJsdelivr(
33+
packageName: string,
34+
readmeFilenames: string[],
35+
version?: string,
36+
): Promise<string | null> {
37+
const versionSuffix = version ? `@${version}` : ''
38+
39+
for (const filename of readmeFilenames) {
40+
try {
41+
const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}`
42+
const response = await fetch(url)
43+
if (response.ok) {
44+
return await response.text()
45+
}
46+
} catch {
47+
// Try next filename
48+
}
49+
}
50+
51+
return null
52+
}
53+
54+
async function fetchWeeklyDownloads(packageName: string): Promise<{ downloads: number } | null> {
55+
try {
56+
const encodedName = encodePackageName(packageName)
57+
return await $fetch<{ downloads: number }>(
58+
`${NPM_API}/downloads/point/last-week/${encodedName}`,
59+
)
60+
} catch {
61+
return null
62+
}
63+
}
64+
65+
async function fetchDownloadRange(
66+
packageName: string,
67+
weeks: number = 12,
68+
): Promise<Array<{ day: string; downloads: number }> | null> {
69+
try {
70+
const encodedName = encodePackageName(packageName)
71+
const today = new Date()
72+
const end = new Date(
73+
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1),
74+
)
75+
const start = new Date(end)
76+
start.setUTCDate(start.getUTCDate() - weeks * 7 + 1)
77+
78+
const startStr = start.toISOString().split('T')[0]
79+
const endStr = end.toISOString().split('T')[0]
80+
81+
const response = await $fetch<{
82+
downloads: Array<{ day: string; downloads: number }>
83+
}>(`${NPM_API}/downloads/range/${startStr}:${endStr}/${encodedName}`)
84+
85+
return response.downloads
86+
} catch {
87+
return null
88+
}
89+
}
90+
91+
function isStandardReadme(filename: string | undefined): boolean {
92+
return !!filename && standardReadmePattern.test(filename)
93+
}
94+
95+
function parsePackageParamsFromPath(path: string): {
96+
rawPackageName: string
97+
rawVersion: string | undefined
98+
} {
99+
const segments = path.slice(1).split('/').filter(Boolean)
100+
101+
if (segments.length === 0) {
102+
return { rawPackageName: '', rawVersion: undefined }
103+
}
104+
105+
const vIndex = segments.indexOf('v')
106+
107+
if (vIndex !== -1 && vIndex < segments.length - 1) {
108+
return {
109+
rawPackageName: segments.slice(0, vIndex).join('/'),
110+
rawVersion: segments.slice(vIndex + 1).join('/'),
111+
}
112+
}
113+
114+
const fullPath = segments.join('/')
115+
const versionMatch = fullPath.match(/^(@[^/]+\/[^@]+|[^@]+)@(.+)$/)
116+
if (versionMatch) {
117+
const [, packageName, version] = versionMatch as [string, string, string]
118+
return {
119+
rawPackageName: packageName,
120+
rawVersion: version,
121+
}
122+
}
123+
124+
return {
125+
rawPackageName: fullPath,
126+
rawVersion: undefined,
127+
}
128+
}
129+
130+
async function handleMarkdownRequest(packagePath: string): Promise<string> {
131+
const { rawPackageName, rawVersion } = parsePackageParamsFromPath(packagePath)
132+
133+
if (!rawPackageName) {
134+
throw createError({
135+
statusCode: 404,
136+
statusMessage: 'Package not found',
137+
})
138+
}
139+
140+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
141+
packageName: rawPackageName,
142+
version: rawVersion,
143+
})
144+
145+
const packageData = await fetchNpmPackage(packageName)
146+
147+
let targetVersion = version
148+
if (!targetVersion) {
149+
targetVersion = packageData['dist-tags']?.latest
150+
}
151+
152+
if (!targetVersion || !packageData.versions[targetVersion]) {
153+
throw createError({
154+
statusCode: 404,
155+
statusMessage: 'Package version not found',
156+
})
157+
}
158+
159+
const versionData = packageData.versions[targetVersion]
160+
161+
let readmeContent: string | undefined
162+
163+
if (version) {
164+
readmeContent = versionData.readme
165+
} else {
166+
readmeContent = packageData.readme
167+
}
168+
169+
const readmeFilename = version ? versionData.readmeFilename : packageData.readmeFilename
170+
const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL
171+
172+
if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) {
173+
const jsdelivrReadme = await fetchReadmeFromJsdelivr(
174+
packageName,
175+
standardReadmeFilenames,
176+
targetVersion,
177+
)
178+
if (jsdelivrReadme) {
179+
readmeContent = jsdelivrReadme
180+
}
181+
}
182+
183+
const [weeklyDownloadsData, dailyDownloads] = await Promise.all([
184+
fetchWeeklyDownloads(packageName),
185+
fetchDownloadRange(packageName, 12),
186+
])
187+
188+
const repoInfo = parseRepositoryInfo(packageData.repository)
189+
190+
return generatePackageMarkdown({
191+
pkg: packageData,
192+
version: versionData,
193+
readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null,
194+
weeklyDownloads: weeklyDownloadsData?.downloads,
195+
dailyDownloads: dailyDownloads ?? undefined,
196+
repoInfo,
197+
})
198+
}
199+
200+
/** Handle .md suffix and Accept: text/markdown header requests */
201+
export default defineEventHandler(async event => {
202+
const url = getRequestURL(event)
203+
const path = url.pathname
204+
205+
if (
206+
path.startsWith('/api/') ||
207+
path.startsWith('/_') ||
208+
path.startsWith('/__') ||
209+
path === '/search' ||
210+
path.startsWith('/search') ||
211+
path.startsWith('/code/') ||
212+
path === '/' ||
213+
path === '/.md'
214+
) {
215+
return
216+
}
217+
218+
const isMarkdownPath = path.endsWith('.md') && path.length > 3
219+
const acceptHeader = getHeader(event, 'accept') ?? ''
220+
const wantsMarkdown = acceptHeader.includes('text/markdown')
221+
222+
if (!isMarkdownPath && !wantsMarkdown) {
223+
return
224+
}
225+
226+
const packagePath = isMarkdownPath ? path.slice(0, -3) : path
227+
228+
try {
229+
const markdown = await handleMarkdownRequest(packagePath)
230+
231+
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
232+
setHeader(
233+
event,
234+
'Cache-Control',
235+
`public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, stale-while-revalidate`,
236+
)
237+
238+
return markdown
239+
} catch (error: unknown) {
240+
if (error && typeof error === 'object' && 'statusCode' in error) {
241+
throw error
242+
}
243+
244+
throw createError({
245+
statusCode: 502,
246+
statusMessage: ERROR_NPM_FETCH_FAILED,
247+
})
248+
}
249+
})

0 commit comments

Comments
 (0)