Skip to content

Commit a618e47

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 3186091 commit a618e47

File tree

7 files changed

+950
-1
lines changed

7 files changed

+950
-1
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,10 @@ const numberFormatter = useNumberFormatter()
465465
const bytesFormatter = useBytesFormatter()
466466
467467
useHead({
468-
link: [{ rel: 'canonical', href: canonicalUrl }],
468+
link: [
469+
{ rel: 'canonical', href: canonicalUrl },
470+
{ rel: 'alternate', type: 'text/markdown', href: `/raw/${packageName.value}.md` },
471+
],
469472
})
470473
471474
useSeoMeta({

codecov.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ coverage:
1313
only_pulls: true
1414
informational: true
1515

16+
# Ignore files that are covered by browser tests (Playwright) rather than unit tests
17+
ignore:
18+
- 'app/pages/**/*'
19+
- 'app/layouts/**/*'
20+
1621
comment:
1722
layout: 'reach,diff,flags,tree,components'
1823
behavior: default

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

0 commit comments

Comments
 (0)