Skip to content
Merged
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
41 changes: 37 additions & 4 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -438,13 +441,43 @@
return Math.min(depth + 2, maxAllowed)
}

/**
* Render YAML frontmatter as a GitHub-style key-value table.
*/
function renderFrontmatterTable(data: Record<string, unknown>): 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 `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(displayValue)}</td></tr>`
})
.join('\n')
return `<table><thead><tr><th>Key</th><th>Value</th></tr></thead><tbody>\n${rows}\n</tbody></table>\n`
}

export async function renderReadmeHtml(

Check warning on line 461 in server/utils/readme.ts

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `extractHeadingAttrs` does not capture any variables from its parent scope
content: string,
packageName: string,
repoInfo?: RepositoryInfo,
): Promise<ReadmeResponse> {
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
}
Comment on lines +472 to +476
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

🧩 Analysis chain

🌐 Web query:

In gray-matter, for input starting with empty YAML frontmatter (---\n---\nBody), does matter(content)returndata = {}andcontent stripped of delimiters?

💡 Result:

Yes, in gray-matter, for input starting with empty YAML frontmatter (---\n---\nBody), matter(content) returns an object with data = {} (empty object) and content stripped of the frontmatter delimiters (i.e., content = "Body"). The frontmatter between the delimiters is empty (all whitespace/nothing), so the YAML parser returns an empty object, and the delimiters are removed from the content. Additionally, the result includes isEmpty: true (or a truthy value for empty matter, as per v4.0+) and possibly empty property with the original matter string.

Citations:


Always strip frontmatter delimiters even when metadata is empty.

When matter(content) parses an empty YAML frontmatter block (---\n---\nBody), it returns data = {} with the delimiters removed from content. However, the current code only assigns markdownBody = body when Object.keys(data).length > 0, leaving the delimiters in place if the frontmatter is empty. These delimiters will then render as horizontal rules (<hr>) when passed to marked. The table generation should remain conditional, but the body assignment must happen unconditionally after a successful parse.

🔧 Proposed fix
   try {
     const { data, content: body } = matter(content)
-    if (data && Object.keys(data).length > 0) {
-      frontmatterHtml = renderFrontmatterTable(data)
-      markdownBody = body
-    }
+    markdownBody = body
+    if (data && Object.keys(data).length > 0) {
+      frontmatterHtml = renderFrontmatterTable(data)
+    }
   } catch {

} catch {
// If frontmatter parsing fails, render the full content as-is
}

const shiki = await getShikiHighlighter()
const renderer = new marked.Renderer()

Expand Down Expand Up @@ -615,8 +648,8 @@
// 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,
Expand Down
Loading