diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 997a882acc..67345a65cd 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -1,13 +1,16 @@ -import { marked, type Tokens } from 'marked' +import type { ReadmeResponse, TocItem } from '#shared/types/readme' +import type { Tokens } from 'marked' +import matter from 'gray-matter' +import { marked } from 'marked' import sanitizeHtml from 'sanitize-html' import { hasProtocol } from 'ufo' -import type { ReadmeResponse, TocItem } from '#shared/types/readme' import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' import { decodeHtmlEntities, stripHtmlTags } from '#shared/utils/html' import { convertToEmoji } from '#shared/utils/emoji' import { toProxiedImageUrl } from '#server/utils/image-proxy' import { highlightCodeSync } from './shiki' +import { escapeHtml } from './docs/text' /** * Playground provider configuration @@ -438,6 +441,23 @@ function calculateSemanticDepth(depth: number, lastSemanticLevel: number) { return Math.min(depth + 2, maxAllowed) } +/** + * Render YAML frontmatter as a GitHub-style key-value table. + */ +function renderFrontmatterTable(data: Record): string { + const entries = Object.entries(data) + if (entries.length === 0) return '' + + const rows = entries + .map(([key, value]) => { + const displayValue = + typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '') + return `${escapeHtml(key)}${escapeHtml(displayValue)}` + }) + .join('\n') + return `\n${rows}\n
KeyValue
\n` +} + export async function renderReadmeHtml( content: string, packageName: string, @@ -445,6 +465,19 @@ export async function renderReadmeHtml( ): Promise { if (!content) return { html: '', playgroundLinks: [], toc: [] } + // Parse and strip YAML frontmatter, render as table if present + let markdownBody = content + let frontmatterHtml = '' + try { + const { data, content: body } = matter(content) + if (data && Object.keys(data).length > 0) { + frontmatterHtml = renderFrontmatterTable(data) + markdownBody = body + } + } catch { + // If frontmatter parsing fails, render the full content as-is + } + const shiki = await getShikiHighlighter() const renderer = new marked.Renderer() @@ -615,8 +648,8 @@ ${html} // Strip trailing whitespace (tabs/spaces) from code block closing fences. // While marky-markdown handles these gracefully, marked fails to recognize // the end of a code block if the closing fences are followed by unexpected whitespaces. - const normalizedContent = content.replace(/^( {0,3}(?:`{3,}|~{3,}))\s*$/gm, '$1') - const rawHtml = marked.parse(normalizedContent) as string + const normalizedContent = markdownBody.replace(/^( {0,3}(?:`{3,}|~{3,}))\s*$/gm, '$1') + const rawHtml = frontmatterHtml + (marked.parse(normalizedContent) as string) const sanitized = sanitizeHtml(rawHtml, { allowedTags: ALLOWED_TAGS,