Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
PackumentVersion,
ProvenanceDetails,
ReadmeResponse,
ReadmeMarkdownResponse,
SkillsListResponse,
} from '#shared/types'
import type { JsrPackageInfo } from '#shared/types/jsr'
Expand Down Expand Up @@ -106,15 +107,47 @@ const { data: readmeData } = useLazyFetch<ReadmeResponse>(
const version = requestedVersion.value
return version ? `${base}/v/${version}` : base
},
{ default: () => ({ html: '', md: '', playgroundLinks: [], toc: [] }) },
{ default: () => ({ html: '', mdExists: false, playgroundLinks: [], toc: [] }) },
)

const {
data: readmeMarkdownData,
status: readmeMarkdownStatus,
execute: fetchReadmeMarkdown,
} = useLazyFetch<ReadmeMarkdownResponse>(
() => {
const base = `/api/registry/readme/markdown/${packageName.value}`
const version = requestedVersion.value
return version ? `${base}/v/${version}` : base
},
{
server: false,
immediate: false,
default: () => ({}),
},
)

//copy README file as Markdown
const { copied: copiedReadme, copy: copyReadme } = useClipboard({
source: () => readmeData.value?.md ?? '',
source: () => '',
copiedDuring: 2000,
})

function prefetchReadmeMarkdown() {
if (readmeMarkdownStatus.value === 'idle') {
fetchReadmeMarkdown()
}
}

async function copyReadmeHandler() {
await fetchReadmeMarkdown()

const markdown = readmeMarkdownData.value?.markdown
if (!markdown) return

await copyReadme(markdown)
}

// Track active TOC item based on scroll position
const tocItems = computed(() => readmeData.value?.toc ?? [])
const { activeId: activeTocId } = useActiveTocItem(tocItems)
Expand Down Expand Up @@ -1238,12 +1271,14 @@ const showSkeleton = shallowRef(false)
<div class="flex gap-2">
<!-- Copy readme as Markdown button -->
<TooltipApp
v-if="readmeData?.md"
v-if="readmeData?.mdExists"
:text="$t('package.readme.copy_as_markdown')"
position="bottom"
>
<ButtonBase
@click="copyReadme()"
@mouseenter="prefetchReadmeMarkdown"
@focus="prefetchReadmeMarkdown"
@click="copyReadmeHandler()"
:aria-pressed="copiedReadme"
:aria-label="
copiedReadme ? $t('common.copied') : $t('package.readme.copy_as_markdown')
Expand Down
111 changes: 7 additions & 104 deletions server/api/registry/readme/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,5 @@
import * as v from 'valibot'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
import {
CACHE_MAX_AGE_ONE_HOUR,
NPM_MISSING_README_SENTINEL,
ERROR_NPM_FETCH_FAILED,
} from '#shared/utils/constants'

/** Standard README filenames to try when fetching from jsdelivr (case-sensitive CDN) */
const standardReadmeFilenames = [
'README.md',
'readme.md',
'Readme.md',
'README',
'readme',
'README.markdown',
'readme.markdown',
]

/** Matches standard README filenames (case-insensitive, for checking registry metadata) */
const standardReadmePattern = /^readme(?:\.md|\.markdown)?$/i

/**
* Fetch README from jsdelivr CDN for a specific package version.
* Falls back through common README filenames.
*/
async function fetchReadmeFromJsdelivr(
packageName: string,
readmeFilenames: string[],
version?: string,
): Promise<string | null> {
const versionSuffix = version ? `@${version}` : ''

for (const filename of readmeFilenames) {
try {
const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}`
const response = await fetch(url)
if (response.ok) {
return await response.text()
}
} catch {
// Try next filename
}
}

return null
}
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
import { resolvePackageReadmeSource } from '#server/utils/readme-loaders'

/**
* Returns rendered README HTML for a package.
Expand All @@ -57,63 +12,15 @@ async function fetchReadmeFromJsdelivr(
*/
export default defineCachedEventHandler(
async event => {
// Parse package name and optional version from URL segments
// Patterns: [pkg] or [pkg, 'v', version] or [@scope, pkg] or [@scope, pkg, 'v', version]
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []

const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)

try {
// 1. Validate
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
})

const packageData = await fetchNpmPackage(packageName)
const packagePath = getRouterParam(event, 'pkg') ?? ''
const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath)

Comment on lines +16 to 18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalise packagePath before resolving.

Keep resolver input consistent with cache key normalisation to avoid duplicate cache entries and edge-case validation errors.

Proposed fix
-      const packagePath = getRouterParam(event, 'pkg') ?? ''
-      const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath)
+      const packagePath = (getRouterParam(event, 'pkg') ?? '').replace(/\/+$/, '').trim()
+      const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const packagePath = getRouterParam(event, 'pkg') ?? ''
const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath)
const packagePath = (getRouterParam(event, 'pkg') ?? '').replace(/\/+$/, '').trim()
const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath)

let readmeContent: string | undefined
let readmeFilename: string | undefined

// If a specific version is requested, get README from that version
if (version) {
const versionData = packageData.versions[version]
if (versionData) {
readmeContent = versionData.readme
readmeFilename = versionData.readmeFilename
}
} else {
// Use the packument-level readme (from latest version)
readmeContent = packageData.readme
readmeFilename = packageData.readmeFilename
if (!markdown) {
return { html: '', mdExists: false, playgroundLinks: [], toc: [] }
}

const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL

// If no README in packument, or if readmeFilename is non-standard (e.g., README.zh-TW.md),
// try fetching a standard README from jsdelivr (package tarball).
// Note: When readmeFilename is missing, we defensively fetch from jsdelivr to ensure
// we get a standard English README if one exists.
if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) {
const jsdelivrReadme = await fetchReadmeFromJsdelivr(
packageName,
standardReadmeFilenames,
version,
)
// Only replace npm content if jsdelivr returned something
if (jsdelivrReadme) {
readmeContent = jsdelivrReadme
}
}

if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) {
return { html: '', playgroundLinks: [], toc: [] }
}

// Parse repository info for resolving relative URLs to GitHub
const repoInfo = parseRepositoryInfo(packageData.repository)

return await renderReadmeHtml(readmeContent, packageName, repoInfo)
return await renderReadmeHtml(markdown, packageName, repoInfo)
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
Expand All @@ -130,7 +37,3 @@ export default defineCachedEventHandler(
},
},
)

function isStandardReadme(filename: string | undefined): boolean {
return !!filename && standardReadmePattern.test(filename)
}
15 changes: 15 additions & 0 deletions server/api/registry/readme/markdown/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { H3Event } from 'h3'
import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
import { resolvePackageReadmeSource } from '#server/utils/readme-loaders'

export default async function getMarkdownReadme(event: H3Event) {
try {
const packagePath = getRouterParam(event, 'pkg') ?? ''
return await resolvePackageReadmeSource(packagePath)
Comment on lines +7 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalise packagePath before resolving.

Trailing slashes or whitespace can create duplicate cache entries and may lead to validation failures. Normalise once before calling the resolver.

Proposed fix
-    const packagePath = getRouterParam(event, 'pkg') ?? ''
-    return await resolvePackageReadmeSource(packagePath)
+    const packagePath = (getRouterParam(event, 'pkg') ?? '').replace(/\/+$/, '').trim()
+    return await resolvePackageReadmeSource(packagePath)

} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: ERROR_NPM_FETCH_FAILED,
})
}
}
112 changes: 112 additions & 0 deletions server/utils/readme-loaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as v from 'valibot'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
import { CACHE_MAX_AGE_ONE_HOUR, NPM_MISSING_README_SENTINEL } from '#shared/utils/constants'

/** Standard README filenames to try when fetching from jsdelivr (case-sensitive CDN) */
const standardReadmeFilenames = [
'README.md',
'readme.md',
'Readme.md',
'README',
'readme',
'README.markdown',
'readme.markdown',
]

/** Matches standard README filenames (case-insensitive, for checking registry metadata) */
const standardReadmePattern = /^readme(?:\.md|\.markdown)?$/i

export function isStandardReadme(filename: string | undefined): boolean {
return !!filename && standardReadmePattern.test(filename)
}

/**
* Fetch README from jsdelivr CDN for a specific package version.
* Falls back through common README filenames.
*/
export async function fetchReadmeFromJsdelivr(
packageName: string,
readmeFilenames: string[],
version?: string,
): Promise<string | null> {
const versionSuffix = version ? `@${version}` : ''

for (const filename of readmeFilenames) {
try {
const url = `https://cdn.jsdelivr.net/npm/${packageName}${versionSuffix}/${filename}`
const response = await fetch(url)
if (response.ok) {
return await response.text()
}
} catch {
// Try next filename
}
}

return null
}

export const resolvePackageReadmeSource = defineCachedFunction(
async (packagePath: string) => {
const pkgParamSegments = packagePath.split('/')

const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)

const { packageName, version } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
})

const packageData = await fetchNpmPackage(packageName)

let readmeContent: string | undefined
let readmeFilename: string | undefined

if (version) {
const versionData = packageData.versions[version]
if (versionData) {
readmeContent = versionData.readme
readmeFilename = versionData.readmeFilename
}
} else {
readmeContent = packageData.readme
readmeFilename = packageData.readmeFilename
}

const hasValidNpmReadme = readmeContent && readmeContent !== NPM_MISSING_README_SENTINEL

if (!hasValidNpmReadme || !isStandardReadme(readmeFilename)) {
const jsdelivrReadme = await fetchReadmeFromJsdelivr(
packageName,
standardReadmeFilenames,
version,
)
if (jsdelivrReadme) {
readmeContent = jsdelivrReadme
}
}

if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) {
return {
packageName,
version,
markdown: undefined,
repoInfo: undefined,
}
}

const repoInfo = parseRepositoryInfo(packageData.repository)

return {
packageName,
version,
markdown: readmeContent,
repoInfo,
}
},
{
maxAge: CACHE_MAX_AGE_ONE_HOUR,
swr: true,
getKey: (packagePath: string) => packagePath,
},
)
4 changes: 2 additions & 2 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export async function renderReadmeHtml(
packageName: string,
repoInfo?: RepositoryInfo,
): Promise<ReadmeResponse> {
if (!content) return { html: '', md: '', playgroundLinks: [], toc: [] }
if (!content) return { html: '', playgroundLinks: [], toc: [] }

const shiki = await getShikiHighlighter()
const renderer = new marked.Renderer()
Expand Down Expand Up @@ -511,7 +511,7 @@ ${html}

return {
html: convertToEmoji(sanitized),
md: content,
mdExists: Boolean(content),
playgroundLinks: collectedLinks,
toc,
}
Expand Down
9 changes: 7 additions & 2 deletions shared/types/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@ export interface TocItem {
* Response from README API endpoint
*/
export interface ReadmeResponse {
/** Whether the README exists */
mdExists?: boolean
/** Rendered HTML content */
html: string
/** Original markdown content */
md: string
/** Extracted playground/demo links */
playgroundLinks: PlaygroundLink[]
/** Table of contents extracted from headings */
toc: TocItem[]
}

export interface ReadmeMarkdownResponse {
/** Original markdown content */
markdown?: string
}
Loading
Loading