217 lines
6.0 KiB
TypeScript
217 lines
6.0 KiB
TypeScript
|
|
const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd'])
|
||
|
|
|
||
|
|
type FenceInfo = {
|
||
|
|
indent: string
|
||
|
|
marker: string
|
||
|
|
fence: string
|
||
|
|
length: number
|
||
|
|
info: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseFence(line: string): FenceInfo | null {
|
||
|
|
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
|
||
|
|
if (!match) return null
|
||
|
|
|
||
|
|
const [, indent, fence, rawInfo = ''] = match
|
||
|
|
const marker = fence[0]
|
||
|
|
const info = rawInfo.trim()
|
||
|
|
|
||
|
|
// CommonMark permits backticks in tilde-fence info strings, but not in
|
||
|
|
// backtick-fence info strings. Keeping this distinction prevents inline-ish
|
||
|
|
// malformed backtick text from being promoted into a fence opener.
|
||
|
|
if (marker === '`' && info.includes('`')) return null
|
||
|
|
|
||
|
|
return {
|
||
|
|
indent,
|
||
|
|
marker,
|
||
|
|
fence,
|
||
|
|
length: fence.length,
|
||
|
|
info,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string {
|
||
|
|
return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}`
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMarkdownFence(fence: FenceInfo): boolean {
|
||
|
|
const language = fence.info.split(/\s+/)[0]?.toLowerCase()
|
||
|
|
return MARKDOWN_FENCE_LANGUAGES.has(language)
|
||
|
|
}
|
||
|
|
|
||
|
|
function isClosingFence(line: string, opener: FenceInfo): boolean {
|
||
|
|
const fence = parseFence(line)
|
||
|
|
return Boolean(
|
||
|
|
fence
|
||
|
|
&& fence.marker === opener.marker
|
||
|
|
&& fence.length >= opener.length
|
||
|
|
&& fence.info === '',
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number {
|
||
|
|
let index = start
|
||
|
|
while (index >= 0 && lines[index].trim() === '') {
|
||
|
|
index -= 1
|
||
|
|
}
|
||
|
|
return index
|
||
|
|
}
|
||
|
|
|
||
|
|
function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number {
|
||
|
|
for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) {
|
||
|
|
if (isClosingFence(lines[i], opener)) {
|
||
|
|
return i
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return -1
|
||
|
|
}
|
||
|
|
|
||
|
|
type OpenFence = {
|
||
|
|
marker: string
|
||
|
|
length: number
|
||
|
|
}
|
||
|
|
|
||
|
|
function canBalanceNestedFences(lines: string[], marker: string): boolean {
|
||
|
|
const stack: OpenFence[] = []
|
||
|
|
let sawFence = false
|
||
|
|
|
||
|
|
for (const line of lines) {
|
||
|
|
const fence = parseFence(line)
|
||
|
|
if (!fence || fence.marker !== marker) continue
|
||
|
|
|
||
|
|
sawFence = true
|
||
|
|
const current = stack[stack.length - 1]
|
||
|
|
if (fence.info === '' && current && fence.length >= current.length) {
|
||
|
|
stack.pop()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
// Inside a Markdown example, an unlabeled fence can be either a closing
|
||
|
|
// fence or a literal nested unlabeled example opener. If there is no nested
|
||
|
|
// opener waiting to close, treat it as the latter while evaluating a later
|
||
|
|
// candidate closing fence for the outer example.
|
||
|
|
stack.push({ marker: fence.marker, length: fence.length })
|
||
|
|
}
|
||
|
|
|
||
|
|
return sawFence && stack.length === 0
|
||
|
|
}
|
||
|
|
|
||
|
|
function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number {
|
||
|
|
const candidates: number[] = []
|
||
|
|
|
||
|
|
for (let i = start; i < lines.length; i += 1) {
|
||
|
|
const fence = parseFence(lines[i])
|
||
|
|
if (
|
||
|
|
fence
|
||
|
|
&& fence.marker === opener.marker
|
||
|
|
&& fence.info === ''
|
||
|
|
&& fence.length >= opener.length
|
||
|
|
) {
|
||
|
|
candidates.push(i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
||
|
|
const candidate = candidates[i]
|
||
|
|
if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) {
|
||
|
|
return candidate
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return candidates[0] ?? -1
|
||
|
|
}
|
||
|
|
|
||
|
|
function maxFenceLength(lines: string[], marker: string): number {
|
||
|
|
let maxLength = 0
|
||
|
|
for (const line of lines) {
|
||
|
|
const fence = parseFence(line)
|
||
|
|
if (fence?.marker === marker) {
|
||
|
|
maxLength = Math.max(maxLength, fence.length)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return maxLength
|
||
|
|
}
|
||
|
|
|
||
|
|
function promoteMarkdownExampleFences(lines: string[]): string[] {
|
||
|
|
const output: string[] = []
|
||
|
|
|
||
|
|
for (let i = 0; i < lines.length; i += 1) {
|
||
|
|
const opener = parseFence(lines[i])
|
||
|
|
if (!opener || !isMarkdownFence(opener)) {
|
||
|
|
output.push(lines[i])
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
const balancedClose = findBalancedClosingFence(lines, opener, i + 1)
|
||
|
|
if (balancedClose === -1) {
|
||
|
|
output.push(lines[i])
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = lines.slice(i + 1, balancedClose)
|
||
|
|
const innerMaxLength = maxFenceLength(body, opener.marker)
|
||
|
|
if (innerMaxLength >= opener.length) {
|
||
|
|
const promotedLength = innerMaxLength + 1
|
||
|
|
output.push(serializeFence(opener, promotedLength))
|
||
|
|
output.push(...body)
|
||
|
|
output.push(serializeFence(opener, promotedLength, ''))
|
||
|
|
} else {
|
||
|
|
output.push(lines[i])
|
||
|
|
output.push(...body)
|
||
|
|
output.push(lines[balancedClose])
|
||
|
|
}
|
||
|
|
|
||
|
|
i = balancedClose
|
||
|
|
}
|
||
|
|
|
||
|
|
return output
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* LLMs often wrap a complete PR draft or Markdown answer in an outer
|
||
|
|
* ```md fence. Showing that outer wrapper as a code block makes the UI look
|
||
|
|
* like Markdown rendering is broken: headings, lists, and inline code remain
|
||
|
|
* literal text. Strip only that outer draft wrapper before handing content to
|
||
|
|
* markdown-it.
|
||
|
|
*
|
||
|
|
* The unwrapped draft can still contain Markdown examples that themselves
|
||
|
|
* contain fenced examples. CommonMark closes fences at the first same-marker
|
||
|
|
* line with at least the opener length, so a malformed example like
|
||
|
|
* ```md ... ```md ... ``` ... ``` must be normalized by making the example's
|
||
|
|
* outer fence longer than the literal fences inside it.
|
||
|
|
*/
|
||
|
|
export function repairNestedMarkdownFences(content: string): string {
|
||
|
|
if (!content.includes('```') && !content.includes('~~~')) return content
|
||
|
|
|
||
|
|
const lines = content.split('\n')
|
||
|
|
const output: string[] = []
|
||
|
|
let changed = false
|
||
|
|
|
||
|
|
for (let i = 0; i < lines.length; i += 1) {
|
||
|
|
const opener = parseFence(lines[i])
|
||
|
|
if (!opener || !isMarkdownFence(opener)) {
|
||
|
|
output.push(lines[i])
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
const finalClose = findFinalClosingFence(lines, opener, i + 1)
|
||
|
|
if (finalClose === -1) {
|
||
|
|
output.push(lines[i])
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
const lastNonEmpty = findLastNonEmptyLine(lines)
|
||
|
|
if (finalClose !== lastNonEmpty) {
|
||
|
|
output.push(lines[i])
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose)))
|
||
|
|
output.push(...lines.slice(finalClose + 1))
|
||
|
|
changed = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
|
||
|
|
return changed ? output.join('\n') : content
|
||
|
|
}
|