Skip to content

Commit 2b2e5c4

Browse files
committed
feat: add markdown output support for package pages
Serve package info as Markdown via /raw/<package>.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 <link rel=alternate type=text/markdown> to package page - Add comprehensive unit tests (35 tests, ~98% coverage)
1 parent 54711c9 commit 2b2e5c4

6 files changed

Lines changed: 923 additions & 1 deletion

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,10 @@ const numberFormatter = useNumberFormatter()
495495
const bytesFormatter = useBytesFormatter()
496496
497497
useHead({
498-
link: [{ rel: 'canonical', href: canonicalUrl }],
498+
link: [
499+
{ rel: 'canonical', href: canonicalUrl },
500+
{ rel: 'alternate', type: 'text/markdown', href: `/raw/${packageName.value}.md` },
501+
],
499502
})
500503
501504
useSeoMeta({

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export default defineNuxtConfig({
100100
routeRules: {
101101
// API routes
102102
'/api/**': { isr: 300 },
103+
'/raw/**': { isr: 60 },
103104
'/api/registry/badge/**': {
104105
isr: {
105106
expiration: 60 * 60 /* one hour */,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { generatePackageMarkdown } from '../../utils/markdown'
2+
import { isStandardReadme, fetchReadmeFromJsdelivr } from '../../utils/readme-loaders'
3+
import * as v from 'valibot'
4+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
5+
import { NPM_MISSING_README_SENTINEL, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
6+
7+
// Cache TTL matches the ISR config for /raw/** routes (60 seconds)
8+
const CACHE_MAX_AGE = 60
9+
10+
const NPM_API = 'https://api.npmjs.org'
11+
12+
const standardReadmeFilenames = [
13+
'README.md',
14+
'readme.md',
15+
'Readme.md',
16+
'README',
17+
'readme',
18+
'README.markdown',
19+
'readme.markdown',
20+
]
21+
22+
function encodePackageName(name: string): string {
23+
if (name.startsWith('@')) {
24+
return `@${encodeURIComponent(name.slice(1))}`
25+
}
26+
return encodeURIComponent(name)
27+
}
28+
29+
async function fetchWeeklyDownloads(packageName: string): Promise<{ downloads: number } | null> {
30+
try {
31+
const encodedName = encodePackageName(packageName)
32+
return await $fetch<{ downloads: number }>(
33+
`${NPM_API}/downloads/point/last-week/${encodedName}`,
34+
)
35+
} catch {
36+
return null
37+
}
38+
}
39+
40+
function parsePackageParamsFromSlug(slug: string): {
41+
rawPackageName: string
42+
rawVersion: string | undefined
43+
} {
44+
const segments = slug.split('/').filter(Boolean)
45+
46+
if (segments.length === 0) {
47+
return { rawPackageName: '', rawVersion: undefined }
48+
}
49+
50+
const vIndex = segments.indexOf('v')
51+
52+
if (vIndex !== -1 && vIndex < segments.length - 1) {
53+
return {
54+
rawPackageName: segments.slice(0, vIndex).join('/'),
55+
rawVersion: segments.slice(vIndex + 1).join('/'),
56+
}
57+
}
58+
59+
const fullPath = segments.join('/')
60+
const versionMatch = fullPath.match(/^(@[^/]+\/[^@]+|[^@]+)@(.+)$/)
61+
if (versionMatch) {
62+
const [, packageName, version] = versionMatch as [string, string, string]
63+
return {
64+
rawPackageName: packageName,
65+
rawVersion: version,
66+
}
67+
}
68+
69+
return {
70+
rawPackageName: fullPath,
71+
rawVersion: undefined,
72+
}
73+
}
74+
75+
export default defineEventHandler(async event => {
76+
// Get the slug parameter - Nitro captures it as "slug.md" due to the route pattern
77+
const params = getRouterParams(event)
78+
const slugParam = params['slug.md'] || params.slug
79+
80+
if (!slugParam) {
81+
throw createError({
82+
statusCode: 404,
83+
statusMessage: 'Package not found',
84+
})
85+
}
86+
87+
// Remove .md suffix if present (it will be there from the route)
88+
const slug = slugParam.endsWith('.md') ? slugParam.slice(0, -3) : slugParam
89+
90+
const { rawPackageName, rawVersion } = parsePackageParamsFromSlug(slug)
91+
92+
if (!rawPackageName) {
93+
throw createError({
94+
statusCode: 404,
95+
statusMessage: 'Package not found',
96+
})
97+
}
98+
99+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
100+
packageName: rawPackageName,
101+
version: rawVersion,
102+
})
103+
104+
let packageData
105+
try {
106+
packageData = await fetchNpmPackage(packageName)
107+
} catch {
108+
throw createError({
109+
statusCode: 502,
110+
statusMessage: ERROR_NPM_FETCH_FAILED,
111+
})
112+
}
113+
114+
let targetVersion = version
115+
if (!targetVersion) {
116+
targetVersion = packageData['dist-tags']?.latest
117+
}
118+
119+
if (!targetVersion) {
120+
throw createError({
121+
statusCode: 404,
122+
statusMessage: 'Package version not found',
123+
})
124+
}
125+
126+
const versionData = packageData.versions[targetVersion]
127+
if (!versionData) {
128+
throw createError({
129+
statusCode: 404,
130+
statusMessage: 'Package version not found',
131+
})
132+
}
133+
134+
let readmeContent: string | undefined
135+
136+
if (version) {
137+
readmeContent = versionData.readme
138+
} else {
139+
readmeContent = packageData.readme
140+
}
141+
142+
const readmeFilename = version ? versionData.readmeFilename : packageData.readmeFilename
143+
const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL
144+
145+
if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) {
146+
const jsdelivrReadme = await fetchReadmeFromJsdelivr(
147+
packageName,
148+
standardReadmeFilenames,
149+
targetVersion,
150+
)
151+
if (jsdelivrReadme) {
152+
readmeContent = jsdelivrReadme
153+
}
154+
}
155+
156+
const weeklyDownloadsData = await fetchWeeklyDownloads(packageName)
157+
158+
const markdown = generatePackageMarkdown({
159+
pkg: packageData,
160+
version: versionData,
161+
readme: readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL ? readmeContent : null,
162+
weeklyDownloads: weeklyDownloadsData?.downloads,
163+
})
164+
165+
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
166+
setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE}, stale-while-revalidate`)
167+
168+
return markdown
169+
})

0 commit comments

Comments
 (0)