fix: preserve valid bold markdown in rich editor

This commit is contained in:
lucaronin
2026-04-16 22:31:19 +02:00
parent 3bcfedda5b
commit 2b85f4e45f
3 changed files with 82 additions and 22 deletions

View File

@@ -122,7 +122,17 @@ describe('compactMarkdown', () => {
it('decodes   HTML entities from BlockNote bold+code output', () => {
const input = '**Remove **`NoteWindow`** and render the full **`App`** component.**\n'
expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n')
expect(compactMarkdown(input)).toBe('**Remove** `NoteWindow` **and render the full** `App` **component.**\n')
})
it('moves trailing whitespace outside bold markers', () => {
const input = '**Luca **\n'
expect(compactMarkdown(input)).toBe('**Luca** \n')
})
it('moves leading whitespace outside bold markers', () => {
const input = '** Luca**\n'
expect(compactMarkdown(input)).toBe(' **Luca**\n')
})
it('decodes multiple HTML entity types', () => {

View File

@@ -4,6 +4,7 @@
* - Tight lists (no blank lines between consecutive list items)
* - Bullet list markers normalized to `-` (BlockNote outputs `*`)
* - HTML entities like ` ` decoded back to spaces
* - Leading/trailing inline whitespace moved outside bold markers
* - Stray hard-break-only lines removed after a markdown hard break
* - No runs of 3+ blank lines (collapsed to one blank line)
* - No trailing blank lines
@@ -30,6 +31,7 @@ export function compactMarkdown(md: string): string {
const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/
const HARD_BREAK_ONLY_RE = /^\\+$/
const TRAILING_INLINE_CLOSERS_RE = /(?:[*_~`]+)$/
const STRONG_RE = /\*\*([^*\n]*?)\*\*/g
interface MarkdownDocument {
lines: string[]
@@ -40,6 +42,10 @@ interface LinePosition {
idx: number
}
interface MarkdownLineValue {
line: string
}
interface NormalizedLinePosition extends LinePosition {
line: string
}
@@ -53,7 +59,7 @@ function processMarkdownLine(
): { inCodeBlock: boolean; line: string | null } {
const rawLine = doc.lines[idx]
if (isFenceDelimiter(rawLine)) {
if (isFenceDelimiter({ line: rawLine })) {
return { inCodeBlock: !inCodeBlock, line: rawLine }
}
@@ -61,7 +67,7 @@ function processMarkdownLine(
return { inCodeBlock, line: rawLine }
}
const line = normalizeMarkdownLine(rawLine)
const line = normalizeMarkdownLine({ line: rawLine })
if (shouldSkipLine({ doc, idx, line })) {
return { inCodeBlock, line: null }
}
@@ -69,12 +75,14 @@ function processMarkdownLine(
return { inCodeBlock, line }
}
function isFenceDelimiter(line: string): boolean {
function isFenceDelimiter({ line }: MarkdownLineValue): boolean {
return line.trimStart().startsWith('```')
}
function normalizeMarkdownLine(line: string): string {
return decodeHtmlEntities(normalizeBulletMarker(line))
function normalizeMarkdownLine({ line }: MarkdownLineValue): string {
const normalizedBullets = normalizeBulletMarker({ line })
const decodedEntities = decodeHtmlEntities({ line: normalizedBullets })
return normalizeStrongWhitespace({ line: decodedEntities })
}
function shouldSkipLine({ doc, idx, line }: NormalizedLinePosition): boolean {
@@ -116,20 +124,20 @@ function findNextNonBlank({ doc, idx }: LinePosition): number | null {
}
function isRedundantHardBreakLine({ doc, idx, line }: NormalizedLinePosition): boolean {
if (!isHardBreakOnlyLine(line)) return false
if (!isHardBreakOnlyLine({ line })) return false
const prev = findPrevNonBlank({ doc, idx })
if (prev === null) return false
const prevLine = normalizeMarkdownLine(doc.lines[prev])
return isHardBreakOnlyLine(prevLine) || endsWithHardBreakMarker(prevLine)
const prevLine = normalizeMarkdownLine({ line: doc.lines[prev] })
return isHardBreakOnlyLine({ line: prevLine }) || endsWithHardBreakMarker({ line: prevLine })
}
function isHardBreakOnlyLine(line: string): boolean {
function isHardBreakOnlyLine({ line }: MarkdownLineValue): boolean {
return HARD_BREAK_ONLY_RE.test(line.trim())
}
function endsWithHardBreakMarker(line: string): boolean {
function endsWithHardBreakMarker({ line }: MarkdownLineValue): boolean {
const trimmed = line.trimEnd()
if (trimmed.endsWith('\\\\')) return true
return trimmed.replace(TRAILING_INLINE_CLOSERS_RE, '').endsWith('\\\\')
@@ -137,16 +145,36 @@ function endsWithHardBreakMarker(line: string): boolean {
const BULLET_RE = /^(\s*)\*(\s)/
/** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */
function normalizeBulletMarker(line: string): string {
function normalizeBulletMarker({ line }: MarkdownLineValue): string {
return line.replace(BULLET_RE, '$1-$2')
}
/** Decode HTML entities that BlockNote inserts (  & etc.) */
function decodeHtmlEntities(line: string): string {
function decodeHtmlEntities({ line }: MarkdownLineValue): string {
if (!line.includes('&#x')) return line
return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}
function normalizeStrongWhitespace({ line }: MarkdownLineValue): string {
return line.replace(STRONG_RE, (match, content: string) => {
const leadingWhitespace = content.match(/^\s+/)?.[0] ?? ''
const trailingWhitespace = content.match(/\s+$/)?.[0] ?? ''
if (!leadingWhitespace && !trailingWhitespace) {
return match
}
const strongContent = content.slice(
leadingWhitespace.length,
content.length - trailingWhitespace.length,
)
if (!strongContent) {
return match
}
return `${leadingWhitespace}**${strongContent}**${trailingWhitespace}`
})
}
function finalizeMarkdown(doc: MarkdownDocument): string {
while (doc.lines.length > 0 && doc.lines[doc.lines.length - 1].trim() === '') {
doc.lines.pop()