fix: prevent mobile markdown parser from stalling on unsupported blocks (#20313)

This commit is contained in:
Neil
2026-09-12 01:29:09 -07:00
committed by GitHub
parent 57b0355f72
commit cd8e98fdf9
2 changed files with 53 additions and 4 deletions
@@ -0,0 +1,46 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
function parseWithDeadline(input: string) {
// A synchronous parser loop must fail without hanging the test worker.
return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 })
}
describe('mobile Markdown parser progress', () => {
it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])(
'retains unsupported fence %s as paragraph text',
(fence) => {
expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }])
expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([
{ type: 'paragraph', text: `before\n${fence}\nafter` }
])
}
)
it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => {
expect(parseWithDeadline(`${heading}\nnext`)).toEqual([
{ type: 'paragraph', text: `${heading}\nnext` }
])
})
it('handles unsupported fences through the production preview normalization', () => {
const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;')
expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }])
})
it('recognizes a supported fence after unsupported fence text', () => {
expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([
{ type: 'paragraph', text: '```c++' },
{ type: 'code', text: 'const x = 1', language: 'ts', closed: true }
])
})
it('preserves supported fences and their streaming state', () => {
expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([
{ type: 'paragraph', text: 'before' },
{ type: 'code', text: 'const x = 1', language: 'ts', closed: false }
])
})
})
@@ -8,6 +8,9 @@ export type MobileMarkdownBlock =
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'rule' }
const HEADING = /^(#{1,6})\s+(.+)$/
const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/
function splitTableRow(line: string): string[] {
return line
.trim()
@@ -34,7 +37,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
continue
}
const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/)
const fence = line.match(CODE_FENCE)
if (fence) {
index += 1
const code: string[] = []
@@ -80,7 +83,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
continue
}
const heading = line.match(/^(#{1,6})\s+(.+)$/)
const heading = line.match(HEADING)
if (heading) {
blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() })
index += 1
@@ -121,8 +124,8 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
while (
index < lines.length &&
lines[index]?.trim() &&
!(lines[index] ?? '').startsWith('```') &&
!/^(#{1,6})\s+/.test(lines[index] ?? '') &&
!CODE_FENCE.test(lines[index] ?? '') &&
!HEADING.test(lines[index] ?? '') &&
!/^>\s?/.test(lines[index] ?? '') &&
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') &&
!/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '')