|
1 | | -import { marked, type Tokens } from 'marked' |
| 1 | +import type { ReadmeResponse, TocItem } from '#shared/types/readme' |
| 2 | +import type { Tokens } from 'marked' |
| 3 | +import matter from 'gray-matter' |
| 4 | +import { marked } from 'marked' |
2 | 5 | import sanitizeHtml from 'sanitize-html' |
3 | 6 | import { hasProtocol } from 'ufo' |
4 | | -import type { ReadmeResponse, TocItem } from '#shared/types/readme' |
5 | 7 | import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' |
6 | 8 | import { decodeHtmlEntities, stripHtmlTags } from '#shared/utils/html' |
7 | 9 | import { convertToEmoji } from '#shared/utils/emoji' |
8 | 10 | import { toProxiedImageUrl } from '#server/utils/image-proxy' |
9 | 11 |
|
10 | 12 | import { highlightCodeSync } from './shiki' |
| 13 | +import { escapeHtml } from './docs/text' |
11 | 14 |
|
12 | 15 | /** |
13 | 16 | * Playground provider configuration |
@@ -438,13 +441,43 @@ function calculateSemanticDepth(depth: number, lastSemanticLevel: number) { |
438 | 441 | return Math.min(depth + 2, maxAllowed) |
439 | 442 | } |
440 | 443 |
|
| 444 | +/** |
| 445 | + * Render YAML frontmatter as a GitHub-style key-value table. |
| 446 | + */ |
| 447 | +function renderFrontmatterTable(data: Record<string, unknown>): string { |
| 448 | + const entries = Object.entries(data) |
| 449 | + if (entries.length === 0) return '' |
| 450 | + |
| 451 | + const rows = entries |
| 452 | + .map(([key, value]) => { |
| 453 | + const displayValue = |
| 454 | + typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '') |
| 455 | + return `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(displayValue)}</td></tr>` |
| 456 | + }) |
| 457 | + .join('\n') |
| 458 | + return `<table><thead><tr><th>Key</th><th>Value</th></tr></thead><tbody>\n${rows}\n</tbody></table>\n` |
| 459 | +} |
| 460 | + |
441 | 461 | export async function renderReadmeHtml( |
442 | 462 | content: string, |
443 | 463 | packageName: string, |
444 | 464 | repoInfo?: RepositoryInfo, |
445 | 465 | ): Promise<ReadmeResponse> { |
446 | 466 | if (!content) return { html: '', playgroundLinks: [], toc: [] } |
447 | 467 |
|
| 468 | + // Parse and strip YAML frontmatter, render as table if present |
| 469 | + let markdownBody = content |
| 470 | + let frontmatterHtml = '' |
| 471 | + try { |
| 472 | + const { data, content: body } = matter(content) |
| 473 | + if (data && Object.keys(data).length > 0) { |
| 474 | + frontmatterHtml = renderFrontmatterTable(data) |
| 475 | + markdownBody = body |
| 476 | + } |
| 477 | + } catch { |
| 478 | + // If frontmatter parsing fails, render the full content as-is |
| 479 | + } |
| 480 | + |
448 | 481 | const shiki = await getShikiHighlighter() |
449 | 482 | const renderer = new marked.Renderer() |
450 | 483 |
|
@@ -615,8 +648,8 @@ ${html} |
615 | 648 | // Strip trailing whitespace (tabs/spaces) from code block closing fences. |
616 | 649 | // While marky-markdown handles these gracefully, marked fails to recognize |
617 | 650 | // the end of a code block if the closing fences are followed by unexpected whitespaces. |
618 | | - const normalizedContent = content.replace(/^( {0,3}(?:`{3,}|~{3,}))\s*$/gm, '$1') |
619 | | - const rawHtml = marked.parse(normalizedContent) as string |
| 651 | + const normalizedContent = markdownBody.replace(/^( {0,3}(?:`{3,}|~{3,}))\s*$/gm, '$1') |
| 652 | + const rawHtml = frontmatterHtml + (marked.parse(normalizedContent) as string) |
620 | 653 |
|
621 | 654 | const sanitized = sanitizeHtml(rawHtml, { |
622 | 655 | allowedTags: ALLOWED_TAGS, |
|
0 commit comments