Skip to content

Commit 6ed4a8a

Browse files
committed
fix(readme): fetch README from jsDelivr to avoid npm registry truncation
The npm registry truncates the packument readme field at exactly 65,536 characters, causing large READMEs to render incomplete. Fetch the actual README file from jsDelivr CDN (npm tarball) as the primary source, falling back to the packument field when jsDelivr doesn't have it. Closes #1458
1 parent a5b2e9c commit 6ed4a8a

2 files changed

Lines changed: 112 additions & 108 deletions

File tree

server/utils/readme-loaders.ts

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,6 @@ const standardReadmeFilenames = [
1313
'readme.markdown',
1414
]
1515

16-
/** Matches standard README filenames (case-insensitive, for checking registry metadata) */
17-
const standardReadmePattern = /^readme(?:\.md|\.markdown)?$/i
18-
19-
export function isStandardReadme(filename: string | undefined): boolean {
20-
return !!filename && standardReadmePattern.test(filename)
21-
}
22-
2316
/**
2417
* Fetch README from jsdelivr CDN for a specific package version.
2518
* Falls back through common README filenames.
@@ -58,35 +51,25 @@ export const resolvePackageReadmeSource = defineCachedFunction(
5851
})
5952

6053
const packageData = await fetchNpmPackage(packageName)
54+
const resolvedVersion = version ?? packageData['dist-tags']?.latest
6155

62-
let readmeContent: string | undefined
63-
let readmeFilename: string | undefined
64-
65-
if (version) {
66-
const versionData = packageData.versions[version]
67-
if (versionData) {
68-
readmeContent = versionData.readme
69-
readmeFilename = versionData.readmeFilename
70-
}
71-
} else {
72-
readmeContent = packageData.readme
73-
readmeFilename = packageData.readmeFilename
74-
}
75-
76-
const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL
77-
78-
if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) {
79-
const jsdelivrReadme = await fetchReadmeFromJsdelivr(
80-
packageName,
81-
standardReadmeFilenames,
82-
version,
83-
)
84-
if (jsdelivrReadme) {
85-
readmeContent = jsdelivrReadme
56+
// Prefer jsDelivr (actual file from npm tarball) over the packument readme field,
57+
// because the npm registry truncates readme content at 65,536 characters.
58+
let readmeContent = await fetchReadmeFromJsdelivr(
59+
packageName,
60+
standardReadmeFilenames,
61+
resolvedVersion,
62+
)
63+
64+
// Fall back to packument readme if jsDelivr didn't have it
65+
if (!readmeContent) {
66+
const packumentReadme = version ? packageData.versions[version]?.readme : packageData.readme
67+
if (packumentReadme && packumentReadme !== NPM_MISSING_README_SENTINEL) {
68+
readmeContent = packumentReadme
8669
}
8770
}
8871

89-
if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) {
72+
if (!readmeContent) {
9073
return {
9174
packageName,
9275
version,

test/unit/server/utils/readme-loaders.spec.ts

Lines changed: 97 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,9 @@ vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock)
1414
const parseRepositoryInfoMock = vi.fn()
1515
vi.stubGlobal('parseRepositoryInfo', parseRepositoryInfoMock)
1616

17-
const { fetchReadmeFromJsdelivr, isStandardReadme, resolvePackageReadmeSource } =
17+
const { fetchReadmeFromJsdelivr, resolvePackageReadmeSource } =
1818
await import('../../../../server/utils/readme-loaders')
1919

20-
describe('isStandardReadme', () => {
21-
it('returns true for standard README filenames', () => {
22-
expect(isStandardReadme('README.md')).toBe(true)
23-
expect(isStandardReadme('readme.md')).toBe(true)
24-
expect(isStandardReadme('Readme.md')).toBe(true)
25-
expect(isStandardReadme('README')).toBe(true)
26-
expect(isStandardReadme('readme')).toBe(true)
27-
expect(isStandardReadme('README.markdown')).toBe(true)
28-
expect(isStandardReadme('readme.markdown')).toBe(true)
29-
})
30-
31-
it('returns false for non-standard filenames', () => {
32-
expect(isStandardReadme('CONTRIBUTING.md')).toBe(false)
33-
expect(isStandardReadme('README.txt')).toBe(false)
34-
expect(isStandardReadme('readme.rst')).toBe(false)
35-
expect(isStandardReadme(undefined)).toBe(false)
36-
expect(isStandardReadme('')).toBe(false)
37-
})
38-
})
39-
4020
describe('fetchReadmeFromJsdelivr', () => {
4121
it('returns content when first filename succeeds', async () => {
4222
const content = '# Package'
@@ -81,13 +61,14 @@ describe('resolvePackageReadmeSource', () => {
8161
parseRepositoryInfoMock.mockReset()
8262
})
8363

84-
it('returns markdown and repoInfo when package has valid npm readme (latest)', async () => {
85-
const markdown = '# Hello'
64+
it('prefers jsDelivr readme over packument readme (latest)', async () => {
65+
const jsdelivrContent = '# Full README from CDN'
8666
fetchNpmPackageMock.mockResolvedValue({
87-
readme: markdown,
88-
readmeFilename: 'README.md',
89-
repository: { url: 'https://github.com/u/r' },
90-
versions: {},
67+
'readme': '# Truncated',
68+
'readmeFilename': 'README.md',
69+
'repository': { url: 'https://github.com/u/r' },
70+
'versions': {},
71+
'dist-tags': { latest: '2.0.0' },
9172
})
9273
parseRepositoryInfoMock.mockReturnValue({
9374
provider: 'github',
@@ -96,90 +77,122 @@ describe('resolvePackageReadmeSource', () => {
9677
rawBaseUrl: 'https://raw.githubusercontent.com/u/r/HEAD',
9778
blobBaseUrl: 'https://github.com/u/r/blob/HEAD',
9879
})
80+
const fetchMock = vi.fn().mockResolvedValue({
81+
ok: true,
82+
text: async () => jsdelivrContent,
83+
})
84+
vi.stubGlobal('fetch', fetchMock)
9985

10086
const result = await resolvePackageReadmeSource('some-pkg')
10187

10288
expect(result).toMatchObject({
10389
packageName: 'some-pkg',
10490
version: undefined,
105-
markdown,
91+
markdown: jsdelivrContent,
10692
repoInfo: { provider: 'github', owner: 'u', repo: 'r' },
10793
})
10894
expect(fetchNpmPackageMock).toHaveBeenCalledWith('some-pkg')
10995
})
11096

111-
it('returns markdown from version when packagePath includes version', async () => {
112-
const markdown = '# Version readme'
97+
it('uses resolved latest version for jsDelivr when no version specified', async () => {
11398
fetchNpmPackageMock.mockResolvedValue({
114-
readme: 'latest readme',
115-
readmeFilename: 'README.md',
116-
repository: undefined,
117-
versions: {
118-
'1.0.0': { readme: markdown, readmeFilename: 'README.md' },
99+
'readme': '# Packument',
100+
'readmeFilename': 'README.md',
101+
'repository': undefined,
102+
'versions': {},
103+
'dist-tags': { latest: '3.1.0' },
104+
})
105+
parseRepositoryInfoMock.mockReturnValue(undefined)
106+
const fetchMock = vi.fn().mockResolvedValue({
107+
ok: true,
108+
text: async () => '# CDN',
109+
})
110+
vi.stubGlobal('fetch', fetchMock)
111+
112+
await resolvePackageReadmeSource('pkg')
113+
114+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('pkg@3.1.0'))
115+
})
116+
117+
it('returns markdown from specific version jsDelivr when packagePath includes version', async () => {
118+
const jsdelivrContent = '# Version readme from CDN'
119+
fetchNpmPackageMock.mockResolvedValue({
120+
'readme': 'latest readme',
121+
'readmeFilename': 'README.md',
122+
'repository': undefined,
123+
'versions': {
124+
'1.0.0': { readme: 'version readme from packument', readmeFilename: 'README.md' },
119125
},
126+
'dist-tags': { latest: '2.0.0' },
120127
})
121128
parseRepositoryInfoMock.mockReturnValue(undefined)
129+
const fetchMock = vi.fn().mockResolvedValue({
130+
ok: true,
131+
text: async () => jsdelivrContent,
132+
})
133+
vi.stubGlobal('fetch', fetchMock)
122134

123135
const result = await resolvePackageReadmeSource('some-pkg/v/1.0.0')
124136

125137
expect(result).toMatchObject({
126138
packageName: 'some-pkg',
127139
version: '1.0.0',
128-
markdown,
140+
markdown: jsdelivrContent,
129141
})
142+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('some-pkg@1.0.0'))
130143
})
131144

132-
it('falls back to jsdelivr when npm readme is missing sentinel', async () => {
133-
const jsdelivrContent = '# From CDN'
145+
it('falls back to packument readme when jsDelivr fails', async () => {
146+
const packumentReadme = '# From packument'
134147
fetchNpmPackageMock.mockResolvedValue({
135-
readme: NPM_MISSING_README_SENTINEL,
136-
readmeFilename: 'README.md',
137-
repository: undefined,
138-
versions: {},
148+
'readme': packumentReadme,
149+
'readmeFilename': 'README.md',
150+
'repository': undefined,
151+
'versions': {},
152+
'dist-tags': { latest: '1.0.0' },
139153
})
140154
parseRepositoryInfoMock.mockReturnValue(undefined)
141-
const fetchMock = vi.fn().mockResolvedValue({
142-
ok: true,
143-
text: async () => jsdelivrContent,
144-
})
155+
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
145156
vi.stubGlobal('fetch', fetchMock)
146157

147158
const result = await resolvePackageReadmeSource('pkg')
148159

149160
expect(result).toMatchObject({
150161
packageName: 'pkg',
151-
markdown: jsdelivrContent,
152-
repoInfo: undefined,
162+
markdown: packumentReadme,
153163
})
154-
expect(fetchMock).toHaveBeenCalled()
155164
})
156165

157-
it('falls back to jsdelivr when readmeFilename is not standard', async () => {
158-
const jsdelivrContent = '# From CDN'
166+
it('falls back to version packument readme when jsDelivr fails', async () => {
167+
const versionReadme = '# Version readme'
159168
fetchNpmPackageMock.mockResolvedValue({
160-
readme: 'content',
161-
readmeFilename: 'DOCS.md',
162-
repository: undefined,
163-
versions: {},
169+
'readme': 'latest readme',
170+
'repository': undefined,
171+
'versions': {
172+
'1.0.0': { readme: versionReadme },
173+
},
174+
'dist-tags': { latest: '1.0.0' },
164175
})
165176
parseRepositoryInfoMock.mockReturnValue(undefined)
166-
const fetchMock = vi.fn().mockResolvedValue({
167-
ok: true,
168-
text: async () => jsdelivrContent,
169-
})
177+
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
170178
vi.stubGlobal('fetch', fetchMock)
171179

172-
const result = await resolvePackageReadmeSource('pkg')
180+
const result = await resolvePackageReadmeSource('pkg/v/1.0.0')
173181

174-
expect(result).toMatchObject({ markdown: jsdelivrContent })
182+
expect(result).toMatchObject({
183+
packageName: 'pkg',
184+
version: '1.0.0',
185+
markdown: versionReadme,
186+
})
175187
})
176188

177-
it('returns undefined markdown when no content and jsdelivr fails', async () => {
189+
it('skips packument readme with missing sentinel in fallback', async () => {
178190
fetchNpmPackageMock.mockResolvedValue({
179-
readme: undefined,
180-
readmeFilename: undefined,
181-
repository: undefined,
182-
versions: {},
191+
'readme': NPM_MISSING_README_SENTINEL,
192+
'readmeFilename': 'README.md',
193+
'repository': undefined,
194+
'versions': {},
195+
'dist-tags': { latest: '1.0.0' },
183196
})
184197
parseRepositoryInfoMock.mockReturnValue(undefined)
185198
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
@@ -189,37 +202,40 @@ describe('resolvePackageReadmeSource', () => {
189202

190203
expect(result).toMatchObject({
191204
packageName: 'pkg',
192-
version: undefined,
193205
markdown: undefined,
194206
repoInfo: undefined,
195207
})
196208
})
197209

198-
it('returns undefined markdown when content is NPM_MISSING_README_SENTINEL and jsdelivr fails', async () => {
210+
it('returns undefined markdown when no content anywhere', async () => {
199211
fetchNpmPackageMock.mockResolvedValue({
200-
readme: NPM_MISSING_README_SENTINEL,
201-
readmeFilename: 'README.md',
202-
repository: undefined,
203-
versions: {},
212+
'readme': undefined,
213+
'readmeFilename': undefined,
214+
'repository': undefined,
215+
'versions': {},
216+
'dist-tags': { latest: '1.0.0' },
204217
})
218+
parseRepositoryInfoMock.mockReturnValue(undefined)
205219
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
206220
vi.stubGlobal('fetch', fetchMock)
207221

208222
const result = await resolvePackageReadmeSource('pkg')
209223

210224
expect(result).toMatchObject({
211225
packageName: 'pkg',
226+
version: undefined,
212227
markdown: undefined,
213228
repoInfo: undefined,
214229
})
215230
})
216231

217232
it('uses package repository for repoInfo when markdown is present', async () => {
218233
fetchNpmPackageMock.mockResolvedValue({
219-
readme: '# Hi',
220-
readmeFilename: 'README.md',
221-
repository: { url: 'https://github.com/a/b' },
222-
versions: {},
234+
'readme': '# Hi',
235+
'readmeFilename': 'README.md',
236+
'repository': { url: 'https://github.com/a/b' },
237+
'versions': {},
238+
'dist-tags': { latest: '1.0.0' },
223239
})
224240
const repoInfo = {
225241
provider: 'github' as const,
@@ -229,6 +245,11 @@ describe('resolvePackageReadmeSource', () => {
229245
blobBaseUrl: 'https://github.com/a/b/blob/HEAD',
230246
}
231247
parseRepositoryInfoMock.mockReturnValue(repoInfo)
248+
const fetchMock = vi.fn().mockResolvedValue({
249+
ok: true,
250+
text: async () => '# CDN',
251+
})
252+
vi.stubGlobal('fetch', fetchMock)
232253

233254
const result = await resolvePackageReadmeSource('pkg')
234255

0 commit comments

Comments
 (0)