[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: load markdown source code on copy instead of on main request (#1386)

authored by

Alex Savelyev and committed by
GitHub
678f3066 ffd9c9cf

+461 -124
+39 -4
app/pages/package/[[org]]/[name].vue
··· 5 5 PackumentVersion, 6 6 ProvenanceDetails, 7 7 ReadmeResponse, 8 + ReadmeMarkdownResponse, 8 9 SkillsListResponse, 9 10 } from '#shared/types' 10 11 import type { JsrPackageInfo } from '#shared/types/jsr' ··· 106 107 const version = requestedVersion.value 107 108 return version ? `${base}/v/${version}` : base 108 109 }, 109 - { default: () => ({ html: '', md: '', playgroundLinks: [], toc: [] }) }, 110 + { default: () => ({ html: '', mdExists: false, playgroundLinks: [], toc: [] }) }, 111 + ) 112 + 113 + const { 114 + data: readmeMarkdownData, 115 + status: readmeMarkdownStatus, 116 + execute: fetchReadmeMarkdown, 117 + } = useLazyFetch<ReadmeMarkdownResponse>( 118 + () => { 119 + const base = `/api/registry/readme/markdown/${packageName.value}` 120 + const version = requestedVersion.value 121 + return version ? `${base}/v/${version}` : base 122 + }, 123 + { 124 + server: false, 125 + immediate: false, 126 + default: () => ({}), 127 + }, 110 128 ) 111 129 112 130 //copy README file as Markdown 113 131 const { copied: copiedReadme, copy: copyReadme } = useClipboard({ 114 - source: () => readmeData.value?.md ?? '', 132 + source: () => '', 115 133 copiedDuring: 2000, 116 134 }) 135 + 136 + function prefetchReadmeMarkdown() { 137 + if (readmeMarkdownStatus.value === 'idle') { 138 + fetchReadmeMarkdown() 139 + } 140 + } 141 + 142 + async function copyReadmeHandler() { 143 + await fetchReadmeMarkdown() 144 + 145 + const markdown = readmeMarkdownData.value?.markdown 146 + if (!markdown) return 147 + 148 + await copyReadme(markdown) 149 + } 117 150 118 151 // Track active TOC item based on scroll position 119 152 const tocItems = computed(() => readmeData.value?.toc ?? []) ··· 1238 1271 <div class="flex gap-2"> 1239 1272 <!-- Copy readme as Markdown button --> 1240 1273 <TooltipApp 1241 - v-if="readmeData?.md" 1274 + v-if="readmeData?.mdExists" 1242 1275 :text="$t('package.readme.copy_as_markdown')" 1243 1276 position="bottom" 1244 1277 > 1245 1278 <ButtonBase 1246 - @click="copyReadme()" 1279 + @mouseenter="prefetchReadmeMarkdown" 1280 + @focus="prefetchReadmeMarkdown" 1281 + @click="copyReadmeHandler()" 1247 1282 :aria-pressed="copiedReadme" 1248 1283 :aria-label=" 1249 1284 copiedReadme ? $t('common.copied') : $t('package.readme.copy_as_markdown')
+7 -104
server/api/registry/readme/[...pkg].get.ts
··· 1 - import * as v from 'valibot' 2 - import { PackageRouteParamsSchema } from '#shared/schemas/package' 3 - import { 4 - CACHE_MAX_AGE_ONE_HOUR, 5 - NPM_MISSING_README_SENTINEL, 6 - ERROR_NPM_FETCH_FAILED, 7 - } from '#shared/utils/constants' 8 - 9 - /** Standard README filenames to try when fetching from jsdelivr (case-sensitive CDN) */ 10 - const standardReadmeFilenames = [ 11 - 'README.md', 12 - 'readme.md', 13 - 'Readme.md', 14 - 'README', 15 - 'readme', 16 - 'README.markdown', 17 - 'readme.markdown', 18 - ] 19 - 20 - /** Matches standard README filenames (case-insensitive, for checking registry metadata) */ 21 - const standardReadmePattern = /^readme(?:\.md|\.markdown)?$/i 22 - 23 - /** 24 - * Fetch README from jsdelivr CDN for a specific package version. 25 - * Falls back through common README filenames. 26 - */ 27 - async function fetchReadmeFromJsdelivr( 28 - packageName: string, 29 - readmeFilenames: string[], 30 - version?: string, 31 - ): Promise<string | null> { 32 - const versionSuffix = version ? `@${version}` : '' 33 - 34 - for (const filename of readmeFilenames) { 35 - try { 36 - const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}` 37 - const response = await fetch(url) 38 - if (response.ok) { 39 - return await response.text() 40 - } 41 - } catch { 42 - // Try next filename 43 - } 44 - } 45 - 46 - return null 47 - } 1 + import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' 2 + import { resolvePackageReadmeSource } from '#server/utils/readme-loaders' 48 3 49 4 /** 50 5 * Returns rendered README HTML for a package. ··· 57 12 */ 58 13 export default defineCachedEventHandler( 59 14 async event => { 60 - // Parse package name and optional version from URL segments 61 - // Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version] 62 - const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 63 - 64 - const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 65 - 66 15 try { 67 - // 1. Validate 68 - const { packageName, version } = v.parse(PackageRouteParamsSchema, { 69 - packageName: rawPackageName, 70 - version: rawVersion, 71 - }) 72 - 73 - const packageData = await fetchNpmPackage(packageName) 74 - 75 - let readmeContent: string | undefined 76 - let readmeFilename: string | undefined 16 + const packagePath = getRouterParam(event, 'pkg') ?? '' 17 + const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath) 77 18 78 - // If a specific version is requested, get README from that version 79 - if (version) { 80 - const versionData = packageData.versions[version] 81 - if (versionData) { 82 - readmeContent = versionData.readme 83 - readmeFilename = versionData.readmeFilename 84 - } 85 - } else { 86 - // Use the packument-level readme (from latest version) 87 - readmeContent = packageData.readme 88 - readmeFilename = packageData.readmeFilename 19 + if (!markdown) { 20 + return { html: '', mdExists: false, playgroundLinks: [], toc: [] } 89 21 } 90 22 91 - const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL 92 - 93 - // If no README in packument, or if readmeFilename is non-standard (e.g., README.zh-TW.md), 94 - // try fetching a standard README from jsdelivr (package tarball). 95 - // Note: When readmeFilename is missing, we defensively fetch from jsdelivr to ensure 96 - // we get a standard English README if one exists. 97 - if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) { 98 - const jsdelivrReadme = await fetchReadmeFromJsdelivr( 99 - packageName, 100 - standardReadmeFilenames, 101 - version, 102 - ) 103 - // Only replace npm content if jsdelivr returned something 104 - if (jsdelivrReadme) { 105 - readmeContent = jsdelivrReadme 106 - } 107 - } 108 - 109 - if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) { 110 - return { html: '', playgroundLinks: [], toc: [] } 111 - } 112 - 113 - // Parse repository info for resolving relative URLs to GitHub 114 - const repoInfo = parseRepositoryInfo(packageData.repository) 115 - 116 - return await renderReadmeHtml(readmeContent, packageName, repoInfo) 23 + return await renderReadmeHtml(markdown, packageName, repoInfo) 117 24 } catch (error: unknown) { 118 25 handleApiError(error, { 119 26 statusCode: 502, ··· 130 37 }, 131 38 }, 132 39 ) 133 - 134 - function isStandardReadme(filename: string | undefined): boolean { 135 - return !!filename && standardReadmePattern.test(filename) 136 - }
+15
server/api/registry/readme/markdown/[...pkg].get.ts
··· 1 + import type { H3Event } from 'h3' 2 + import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants' 3 + import { resolvePackageReadmeSource } from '#server/utils/readme-loaders' 4 + 5 + export default async function getMarkdownReadme(event: H3Event) { 6 + try { 7 + const packagePath = getRouterParam(event, 'pkg') ?? '' 8 + return await resolvePackageReadmeSource(packagePath) 9 + } catch (error: unknown) { 10 + handleApiError(error, { 11 + statusCode: 502, 12 + message: ERROR_NPM_FETCH_FAILED, 13 + }) 14 + } 15 + }
+112
server/utils/readme-loaders.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 3 + import { CACHE_MAX_AGE_ONE_HOUR, NPM_MISSING_README_SENTINEL } from '#shared/utils/constants' 4 + 5 + /** Standard README filenames to try when fetching from jsdelivr (case-sensitive CDN) */ 6 + const standardReadmeFilenames = [ 7 + 'README.md', 8 + 'readme.md', 9 + 'Readme.md', 10 + 'README', 11 + 'readme', 12 + 'README.markdown', 13 + 'readme.markdown', 14 + ] 15 + 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 + 23 + /** 24 + * Fetch README from jsdelivr CDN for a specific package version. 25 + * Falls back through common README filenames. 26 + */ 27 + export async function fetchReadmeFromJsdelivr( 28 + packageName: string, 29 + readmeFilenames: string[], 30 + version?: string, 31 + ): Promise<string | null> { 32 + const versionSuffix = version ? `@${version}` : '' 33 + 34 + for (const filename of readmeFilenames) { 35 + try { 36 + const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}` 37 + const response = await fetch(url) 38 + if (response.ok) { 39 + return await response.text() 40 + } 41 + } catch { 42 + // Try next filename 43 + } 44 + } 45 + 46 + return null 47 + } 48 + 49 + export const resolvePackageReadmeSource = defineCachedFunction( 50 + async (packagePath: string) => { 51 + const pkgParamSegments = packagePath.split('/') 52 + 53 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 54 + 55 + const { packageName, version } = v.parse(PackageRouteParamsSchema, { 56 + packageName: rawPackageName, 57 + version: rawVersion, 58 + }) 59 + 60 + const packageData = await fetchNpmPackage(packageName) 61 + 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 86 + } 87 + } 88 + 89 + if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) { 90 + return { 91 + packageName, 92 + version, 93 + markdown: undefined, 94 + repoInfo: undefined, 95 + } 96 + } 97 + 98 + const repoInfo = parseRepositoryInfo(packageData.repository) 99 + 100 + return { 101 + packageName, 102 + version, 103 + markdown: readmeContent, 104 + repoInfo, 105 + } 106 + }, 107 + { 108 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 109 + swr: true, 110 + getKey: (packagePath: string) => packagePath, 111 + }, 112 + )
+2 -2
server/utils/readme.ts
··· 319 319 packageName: string, 320 320 repoInfo?: RepositoryInfo, 321 321 ): Promise<ReadmeResponse> { 322 - if (!content) return { html: '', md: '', playgroundLinks: [], toc: [] } 322 + if (!content) return { html: '', playgroundLinks: [], toc: [] } 323 323 324 324 const shiki = await getShikiHighlighter() 325 325 const renderer = new marked.Renderer() ··· 511 511 512 512 return { 513 513 html: convertToEmoji(sanitized), 514 - md: content, 514 + mdExists: Boolean(content), 515 515 playgroundLinks: collectedLinks, 516 516 toc, 517 517 }
+7 -2
shared/types/readme.ts
··· 28 28 * Response from README API endpoint 29 29 */ 30 30 export interface ReadmeResponse { 31 + /** Whether the README exists */ 32 + mdExists?: boolean 31 33 /** Rendered HTML content */ 32 34 html: string 33 - /** Original markdown content */ 34 - md: string 35 35 /** Extracted playground/demo links */ 36 36 playgroundLinks: PlaygroundLink[] 37 37 /** Table of contents extracted from headings */ 38 38 toc: TocItem[] 39 39 } 40 + 41 + export interface ReadmeMarkdownResponse { 42 + /** Original markdown content */ 43 + markdown?: string 44 + }
+238
test/unit/server/utils/readme-loaders.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest' 2 + import { parsePackageParams } from '../../../../server/utils/parse-package-params' 3 + import { NPM_MISSING_README_SENTINEL } from '#shared/utils/constants' 4 + 5 + // Mock Nitro globals before importing the module 6 + vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) 7 + const $fetchMock = vi.fn() 8 + vi.stubGlobal('$fetch', $fetchMock) 9 + vi.stubGlobal('parsePackageParams', parsePackageParams) 10 + 11 + const fetchNpmPackageMock = vi.fn() 12 + vi.stubGlobal('fetchNpmPackage', fetchNpmPackageMock) 13 + 14 + const parseRepositoryInfoMock = vi.fn() 15 + vi.stubGlobal('parseRepositoryInfo', parseRepositoryInfoMock) 16 + 17 + const { fetchReadmeFromJsdelivr, isStandardReadme, resolvePackageReadmeSource } = 18 + await import('../../../../server/utils/readme-loaders') 19 + 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 + 40 + describe('fetchReadmeFromJsdelivr', () => { 41 + it('returns content when first filename succeeds', async () => { 42 + const content = '# Package' 43 + const fetchMock = vi.fn().mockResolvedValue({ 44 + ok: true, 45 + text: async () => content, 46 + }) 47 + vi.stubGlobal('fetch', fetchMock) 48 + 49 + const result = await fetchReadmeFromJsdelivr('some-pkg', ['README.md']) 50 + 51 + expect(result).toBe(content) 52 + expect(fetchMock).toHaveBeenCalledWith('https://cdn.jsdelivr.net/npm/some-pkg/README.md') 53 + }) 54 + 55 + it('includes version in URL when version is passed', async () => { 56 + const fetchMock = vi.fn().mockResolvedValue({ 57 + ok: true, 58 + text: async () => '', 59 + }) 60 + vi.stubGlobal('fetch', fetchMock) 61 + 62 + await fetchReadmeFromJsdelivr('pkg', ['README.md'], '1.2.3') 63 + 64 + expect(fetchMock).toHaveBeenCalledWith('https://cdn.jsdelivr.net/npm/pkg@1.2.3/README.md') 65 + }) 66 + 67 + it('returns null when all fetches fail', async () => { 68 + const fetchMock = vi.fn().mockResolvedValue({ ok: false }) 69 + vi.stubGlobal('fetch', fetchMock) 70 + 71 + const result = await fetchReadmeFromJsdelivr('pkg', ['README.md', 'readme.md']) 72 + 73 + expect(result).toBeNull() 74 + expect(fetchMock).toHaveBeenCalledTimes(2) 75 + }) 76 + }) 77 + 78 + describe('resolvePackageReadmeSource', () => { 79 + beforeEach(() => { 80 + fetchNpmPackageMock.mockReset() 81 + parseRepositoryInfoMock.mockReset() 82 + }) 83 + 84 + it('returns markdown and repoInfo when package has valid npm readme (latest)', async () => { 85 + const markdown = '# Hello' 86 + fetchNpmPackageMock.mockResolvedValue({ 87 + readme: markdown, 88 + readmeFilename: 'README.md', 89 + repository: { url: 'https://github.com/u/r' }, 90 + versions: {}, 91 + }) 92 + parseRepositoryInfoMock.mockReturnValue({ 93 + provider: 'github', 94 + owner: 'u', 95 + repo: 'r', 96 + rawBaseUrl: 'https://raw.githubusercontent.com/u/r/HEAD', 97 + blobBaseUrl: 'https://github.com/u/r/blob/HEAD', 98 + }) 99 + 100 + const result = await resolvePackageReadmeSource('some-pkg') 101 + 102 + expect(result).toMatchObject({ 103 + packageName: 'some-pkg', 104 + version: undefined, 105 + markdown, 106 + repoInfo: { provider: 'github', owner: 'u', repo: 'r' }, 107 + }) 108 + expect(fetchNpmPackageMock).toHaveBeenCalledWith('some-pkg') 109 + }) 110 + 111 + it('returns markdown from version when packagePath includes version', async () => { 112 + const markdown = '# Version readme' 113 + fetchNpmPackageMock.mockResolvedValue({ 114 + readme: 'latest readme', 115 + readmeFilename: 'README.md', 116 + repository: undefined, 117 + versions: { 118 + '1.0.0': { readme: markdown, readmeFilename: 'README.md' }, 119 + }, 120 + }) 121 + parseRepositoryInfoMock.mockReturnValue(undefined) 122 + 123 + const result = await resolvePackageReadmeSource('some-pkg/v/1.0.0') 124 + 125 + expect(result).toMatchObject({ 126 + packageName: 'some-pkg', 127 + version: '1.0.0', 128 + markdown, 129 + }) 130 + }) 131 + 132 + it('falls back to jsdelivr when npm readme is missing sentinel', async () => { 133 + const jsdelivrContent = '# From CDN' 134 + fetchNpmPackageMock.mockResolvedValue({ 135 + readme: NPM_MISSING_README_SENTINEL, 136 + readmeFilename: 'README.md', 137 + repository: undefined, 138 + versions: {}, 139 + }) 140 + parseRepositoryInfoMock.mockReturnValue(undefined) 141 + const fetchMock = vi.fn().mockResolvedValue({ 142 + ok: true, 143 + text: async () => jsdelivrContent, 144 + }) 145 + vi.stubGlobal('fetch', fetchMock) 146 + 147 + const result = await resolvePackageReadmeSource('pkg') 148 + 149 + expect(result).toMatchObject({ 150 + packageName: 'pkg', 151 + markdown: jsdelivrContent, 152 + repoInfo: undefined, 153 + }) 154 + expect(fetchMock).toHaveBeenCalled() 155 + }) 156 + 157 + it('falls back to jsdelivr when readmeFilename is not standard', async () => { 158 + const jsdelivrContent = '# From CDN' 159 + fetchNpmPackageMock.mockResolvedValue({ 160 + readme: 'content', 161 + readmeFilename: 'DOCS.md', 162 + repository: undefined, 163 + versions: {}, 164 + }) 165 + parseRepositoryInfoMock.mockReturnValue(undefined) 166 + const fetchMock = vi.fn().mockResolvedValue({ 167 + ok: true, 168 + text: async () => jsdelivrContent, 169 + }) 170 + vi.stubGlobal('fetch', fetchMock) 171 + 172 + const result = await resolvePackageReadmeSource('pkg') 173 + 174 + expect(result).toMatchObject({ markdown: jsdelivrContent }) 175 + }) 176 + 177 + it('returns undefined markdown when no content and jsdelivr fails', async () => { 178 + fetchNpmPackageMock.mockResolvedValue({ 179 + readme: undefined, 180 + readmeFilename: undefined, 181 + repository: undefined, 182 + versions: {}, 183 + }) 184 + parseRepositoryInfoMock.mockReturnValue(undefined) 185 + const fetchMock = vi.fn().mockResolvedValue({ ok: false }) 186 + vi.stubGlobal('fetch', fetchMock) 187 + 188 + const result = await resolvePackageReadmeSource('pkg') 189 + 190 + expect(result).toMatchObject({ 191 + packageName: 'pkg', 192 + version: undefined, 193 + markdown: undefined, 194 + repoInfo: undefined, 195 + }) 196 + }) 197 + 198 + it('returns undefined markdown when content is NPM_MISSING_README_SENTINEL and jsdelivr fails', async () => { 199 + fetchNpmPackageMock.mockResolvedValue({ 200 + readme: NPM_MISSING_README_SENTINEL, 201 + readmeFilename: 'README.md', 202 + repository: undefined, 203 + versions: {}, 204 + }) 205 + const fetchMock = vi.fn().mockResolvedValue({ ok: false }) 206 + vi.stubGlobal('fetch', fetchMock) 207 + 208 + const result = await resolvePackageReadmeSource('pkg') 209 + 210 + expect(result).toMatchObject({ 211 + packageName: 'pkg', 212 + markdown: undefined, 213 + repoInfo: undefined, 214 + }) 215 + }) 216 + 217 + it('uses package repository for repoInfo when markdown is present', async () => { 218 + fetchNpmPackageMock.mockResolvedValue({ 219 + readme: '# Hi', 220 + readmeFilename: 'README.md', 221 + repository: { url: 'https://github.com/a/b' }, 222 + versions: {}, 223 + }) 224 + const repoInfo = { 225 + provider: 'github' as const, 226 + owner: 'a', 227 + repo: 'b', 228 + rawBaseUrl: 'https://raw.githubusercontent.com/a/b/HEAD', 229 + blobBaseUrl: 'https://github.com/a/b/blob/HEAD', 230 + } 231 + parseRepositoryInfoMock.mockReturnValue(repoInfo) 232 + 233 + const result = await resolvePackageReadmeSource('pkg') 234 + 235 + expect(result?.repoInfo).toEqual(repoInfo) 236 + expect(parseRepositoryInfoMock).toHaveBeenCalledWith({ url: 'https://github.com/a/b' }) 237 + }) 238 + })
+41 -12
test/unit/server/utils/readme.spec.ts
··· 331 331 }) 332 332 }) 333 333 334 - describe('Markdown Content Extraction', () => { 335 - describe('Markdown', () => { 336 - it('returns original markdown content unchanged', async () => { 337 - const markdown = `# Title\n\nSome **bold** text and a [link](https://example.com).` 338 - const result = await renderReadmeHtml(markdown, 'test-pkg') 334 + describe('ReadmeResponse shape (HTML route contract)', () => { 335 + it('returns ReadmeResponse with html, mdExists, playgroundLinks, toc', async () => { 336 + const markdown = `# Title\n\nSome **bold** text.` 337 + const result = await renderReadmeHtml(markdown, 'test-pkg') 339 338 340 - expect(result.md).toBe(markdown) 339 + expect(result).toMatchObject({ 340 + html: expect.any(String), 341 + mdExists: true, 342 + playgroundLinks: [], 343 + toc: expect.any(Array), 341 344 }) 345 + expect(result.html).toContain('Title') 346 + expect(result.html).toContain('bold') 342 347 }) 343 - describe('HTML', () => { 344 - it('returns sanitized html', async () => { 345 - const markdown = `# Title\n\nSome **bold** text and a [link](https://example.com).` 346 - const result = await renderReadmeHtml(markdown, 'test-pkg') 348 + 349 + it('returns empty-state shape when content is empty', async () => { 350 + const result = await renderReadmeHtml('', 'test-pkg') 351 + 352 + expect(result).toMatchObject({ 353 + html: '', 354 + playgroundLinks: [], 355 + toc: [], 356 + }) 357 + expect(result.playgroundLinks).toHaveLength(0) 358 + expect(result.toc).toHaveLength(0) 359 + }) 360 + 361 + it('extracts toc from headings', async () => { 362 + const markdown = `# Install\n\n## CLI\n\n## API` 363 + const result = await renderReadmeHtml(markdown, 'test-pkg') 347 364 348 - expect(result.html).toBe(`<h3 id="user-content-title" data-level="1">Title</h3> 365 + expect(result.toc).toHaveLength(3) 366 + expect(result.toc[0]).toMatchObject({ text: 'Install', depth: 1 }) 367 + expect(result.toc[1]).toMatchObject({ text: 'CLI', depth: 2 }) 368 + expect(result.toc[2]).toMatchObject({ text: 'API', depth: 2 }) 369 + expect(result.toc.every(t => t.id.startsWith('user-content-'))).toBe(true) 370 + }) 371 + }) 372 + 373 + describe('HTML output', () => { 374 + it('returns sanitized html', async () => { 375 + const markdown = `# Title\n\nSome **bold** text and a [link](https://example.com).` 376 + const result = await renderReadmeHtml(markdown, 'test-pkg') 377 + 378 + expect(result.html).toBe(`<h3 id="user-content-title" data-level="1">Title</h3> 349 379 <p>Some <strong>bold</strong> text and a <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">link</a>.</p> 350 380 `) 351 - }) 352 381 }) 353 382 })