|
1 | 1 | import { marked, type Tokens } from 'marked' |
2 | 2 | import sanitizeHtml from 'sanitize-html' |
3 | | -import { hasProtocol } from 'ufo' |
| 3 | +import { hasProtocol, withoutTrailingSlash } from 'ufo' |
| 4 | + |
| 5 | +export interface RepositoryInfo { |
| 6 | + /** GitHub raw base URL (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ |
| 7 | + rawBaseUrl?: string |
| 8 | + /** Subdirectory within repo where package lives (e.g., packages/ai) */ |
| 9 | + directory?: string |
| 10 | +} |
4 | 11 |
|
5 | 12 | // only allow h3-h6 since we shift README headings down by 2 levels |
6 | 13 | // (page h1 = package name, h2 = "Readme" section, so README h1 → h3) |
@@ -63,31 +70,103 @@ const ALLOWED_ATTR: Record<string, string[]> = { |
63 | 70 | // GitHub-style callout types |
64 | 71 | // Format: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION] |
65 | 72 |
|
66 | | -function resolveUrl(url: string, packageName: string): string { |
| 73 | +/** |
| 74 | + * Parse repository field from package.json into GitHub raw URL base. |
| 75 | + * Supports both full objects and shorthand strings. |
| 76 | + */ |
| 77 | +export function parseRepositoryInfo( |
| 78 | + repository?: { type?: string; url?: string; directory?: string } | string, |
| 79 | +): RepositoryInfo | undefined { |
| 80 | + if (!repository) return undefined |
| 81 | + |
| 82 | + let url: string | undefined |
| 83 | + let directory: string | undefined |
| 84 | + |
| 85 | + if (typeof repository === 'string') { |
| 86 | + url = repository |
| 87 | + } else { |
| 88 | + url = repository.url |
| 89 | + directory = repository.directory |
| 90 | + } |
| 91 | + |
| 92 | + if (!url) return undefined |
| 93 | + |
| 94 | + // Parse GitHub URL: git+https://github.com/owner/repo.git or https://github.com/owner/repo |
| 95 | + const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?/) |
| 96 | + if (!githubMatch?.[1] || !githubMatch[2]) return undefined |
| 97 | + |
| 98 | + const owner = githubMatch[1] |
| 99 | + const repo = githubMatch[2] |
| 100 | + |
| 101 | + return { |
| 102 | + rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`, |
| 103 | + directory: directory ? withoutTrailingSlash(directory) : undefined, |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +/** |
| 108 | + * Resolve a relative URL to an absolute URL. |
| 109 | + * If repository info is available, resolve to GitHub raw URLs. |
| 110 | + * Otherwise, fall back to jsdelivr CDN. |
| 111 | + */ |
| 112 | +function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { |
67 | 113 | if (!url) return url |
68 | 114 | if (url.startsWith('#')) { |
69 | 115 | return url |
70 | 116 | } |
71 | 117 | if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) { |
72 | 118 | return url |
73 | 119 | } |
74 | | - // Relative URLs → jsdelivr CDN |
| 120 | + |
| 121 | + // Prefer GitHub raw URLs when repository info is available |
| 122 | + // This handles assets that exist in the repo but not in the npm tarball |
| 123 | + if (repoInfo?.rawBaseUrl) { |
| 124 | + // Normalize the relative path (remove leading ./) |
| 125 | + let relativePath = url.replace(/^\.\//, '') |
| 126 | + |
| 127 | + // If package is in a subdirectory, resolve relative paths from there |
| 128 | + // e.g., for packages/ai with ./assets/hero.gif → packages/ai/assets/hero.gif |
| 129 | + // but for ../../.github/assets/banner.jpg → resolve relative to subdirectory |
| 130 | + if (repoInfo.directory) { |
| 131 | + // Split directory into parts for relative path resolution |
| 132 | + const dirParts = repoInfo.directory.split('/').filter(Boolean) |
| 133 | + |
| 134 | + // Handle ../ navigation |
| 135 | + while (relativePath.startsWith('../')) { |
| 136 | + relativePath = relativePath.slice(3) |
| 137 | + dirParts.pop() |
| 138 | + } |
| 139 | + |
| 140 | + // Reconstruct the path |
| 141 | + if (dirParts.length > 0) { |
| 142 | + relativePath = `${dirParts.join('/')}/${relativePath}` |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + return `${repoInfo.rawBaseUrl}/${relativePath}` |
| 147 | + } |
| 148 | + |
| 149 | + // Fallback: relative URLs → jsdelivr CDN (may 404 if asset not in npm tarball) |
75 | 150 | return `https://cdn.jsdelivr.net/npm/${packageName}/${url.replace(/^\.\//, '')}` |
76 | 151 | } |
77 | 152 |
|
78 | 153 | // Convert GitHub blob URLs to raw URLs for images |
79 | 154 | // e.g. https://github.com/nuxt/nuxt/blob/main/.github/assets/banner.svg |
80 | 155 | // → https://github.com/nuxt/nuxt/raw/main/.github/assets/banner.svg |
81 | | -function resolveImageUrl(url: string, packageName: string): string { |
82 | | - const resolved = resolveUrl(url, packageName) |
| 156 | +function resolveImageUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { |
| 157 | + const resolved = resolveUrl(url, packageName, repoInfo) |
83 | 158 | // GitHub blob → raw |
84 | 159 | if (resolved.includes('github.com') && resolved.includes('/blob/')) { |
85 | 160 | return resolved.replace('/blob/', '/raw/') |
86 | 161 | } |
87 | 162 | return resolved |
88 | 163 | } |
89 | 164 |
|
90 | | -export async function renderReadmeHtml(content: string, packageName: string): Promise<string> { |
| 165 | +export async function renderReadmeHtml( |
| 166 | + content: string, |
| 167 | + packageName: string, |
| 168 | + repoInfo?: RepositoryInfo, |
| 169 | +): Promise<string> { |
91 | 170 | if (!content) return '' |
92 | 171 |
|
93 | 172 | const shiki = await getShikiHighlighter() |
@@ -127,15 +206,15 @@ export async function renderReadmeHtml(content: string, packageName: string): Pr |
127 | 206 |
|
128 | 207 | // Resolve image URLs (with GitHub blob → raw conversion) |
129 | 208 | renderer.image = ({ href, title, text }: Tokens.Image) => { |
130 | | - const resolvedHref = resolveImageUrl(href, packageName) |
| 209 | + const resolvedHref = resolveImageUrl(href, packageName, repoInfo) |
131 | 210 | const titleAttr = title ? ` title="${title}"` : '' |
132 | 211 | const altAttr = text ? ` alt="${text}"` : '' |
133 | 212 | return `<img src="${resolvedHref}"${altAttr}${titleAttr}>` |
134 | 213 | } |
135 | 214 |
|
136 | 215 | // Resolve link URLs and add security attributes |
137 | 216 | renderer.link = function ({ href, title, tokens }: Tokens.Link) { |
138 | | - const resolvedHref = resolveUrl(href, packageName) |
| 217 | + const resolvedHref = resolveUrl(href, packageName, repoInfo) |
139 | 218 | const text = this.parser.parseInline(tokens) |
140 | 219 | const titleAttr = title ? ` title="${title}"` : '' |
141 | 220 |
|
@@ -169,11 +248,11 @@ export async function renderReadmeHtml(content: string, packageName: string): Pr |
169 | 248 | allowedTags: ALLOWED_TAGS, |
170 | 249 | allowedAttributes: ALLOWED_ATTR, |
171 | 250 | allowedSchemes: ['http', 'https', 'mailto'], |
172 | | - // Transform img src URLs (GitHub blob → raw, relative → jsdelivr) |
| 251 | + // Transform img src URLs (GitHub blob → raw, relative → GitHub raw) |
173 | 252 | transformTags: { |
174 | 253 | img: (tagName, attribs) => { |
175 | 254 | if (attribs.src) { |
176 | | - attribs.src = resolveImageUrl(attribs.src, packageName) |
| 255 | + attribs.src = resolveImageUrl(attribs.src, packageName, repoInfo) |
177 | 256 | } |
178 | 257 | return { tagName, attribs } |
179 | 258 | }, |
|
0 commit comments