Skip to content

Commit 54f1889

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 54f1889

File tree

7 files changed

+923
-1
lines changed

7 files changed

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

0 commit comments

Comments
 (0)