188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
/**
|
|
* Post-process BlockNote's blocksToMarkdownLossy output to produce
|
|
* standard-convention Markdown:
|
|
* - 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
|
|
* - Code block content is never modified
|
|
*/
|
|
export function compactMarkdown(md: string): string {
|
|
if (!md) return md
|
|
|
|
const source = { lines: md.split('\n') }
|
|
const result = { lines: [] as string[] }
|
|
let inCodeBlock = false
|
|
|
|
for (let i = 0; i < source.lines.length; i++) {
|
|
const next = processMarkdownLine({ doc: source, idx: i, inCodeBlock })
|
|
inCodeBlock = next.inCodeBlock
|
|
if (next.line !== null) {
|
|
result.lines.push(next.line)
|
|
}
|
|
}
|
|
|
|
return finalizeMarkdown(result)
|
|
}
|
|
|
|
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[]
|
|
}
|
|
|
|
interface LinePosition {
|
|
doc: MarkdownDocument
|
|
idx: number
|
|
}
|
|
|
|
interface MarkdownLineValue {
|
|
line: string
|
|
}
|
|
|
|
interface NormalizedLinePosition extends LinePosition {
|
|
line: string
|
|
}
|
|
|
|
interface ProcessMarkdownLineArgs extends LinePosition {
|
|
inCodeBlock: boolean
|
|
}
|
|
|
|
function processMarkdownLine(
|
|
{ doc, idx, inCodeBlock }: ProcessMarkdownLineArgs,
|
|
): { inCodeBlock: boolean; line: string | null } {
|
|
const rawLine = doc.lines[idx]
|
|
|
|
if (isFenceDelimiter({ line: rawLine })) {
|
|
return { inCodeBlock: !inCodeBlock, line: rawLine }
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
return { inCodeBlock, line: rawLine }
|
|
}
|
|
|
|
const line = normalizeMarkdownLine({ line: rawLine })
|
|
if (shouldSkipLine({ doc, idx, line })) {
|
|
return { inCodeBlock, line: null }
|
|
}
|
|
|
|
return { inCodeBlock, line }
|
|
}
|
|
|
|
function isFenceDelimiter({ line }: MarkdownLineValue): boolean {
|
|
const trimmed = line.trimStart()
|
|
return trimmed.startsWith('```') || trimmed.startsWith('~~~')
|
|
}
|
|
|
|
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 {
|
|
if (line.trim() === '') {
|
|
return isBlankBetweenListItems({ doc, idx }) || isExcessiveBlankLine({ doc, idx })
|
|
}
|
|
|
|
return isRedundantHardBreakLine({ doc, idx, line })
|
|
}
|
|
|
|
/** True if this blank line sits between two list items (including nested) */
|
|
function isBlankBetweenListItems({ doc, idx }: LinePosition): boolean {
|
|
const prev = findPrevNonBlank({ doc, idx })
|
|
const next = findNextNonBlank({ doc, idx })
|
|
if (prev === null || next === null) return false
|
|
return LIST_RE.test(doc.lines[prev]) && LIST_RE.test(doc.lines[next])
|
|
}
|
|
|
|
/** True if this blank line is part of a run of 2+ consecutive blank lines
|
|
* (i.e. would create 3+ newlines in a row — collapse to just one blank line) */
|
|
function isExcessiveBlankLine({ doc, idx }: LinePosition): boolean {
|
|
// Keep the first blank line in a run, skip subsequent ones
|
|
if (idx > 0 && doc.lines[idx - 1].trim() === '') return true
|
|
return false
|
|
}
|
|
|
|
function findPrevNonBlank({ doc, idx }: LinePosition): number | null {
|
|
for (let i = idx - 1; i >= 0; i--) {
|
|
if (doc.lines[i].trim() !== '') return i
|
|
}
|
|
return null
|
|
}
|
|
|
|
function findNextNonBlank({ doc, idx }: LinePosition): number | null {
|
|
for (let i = idx + 1; i < doc.lines.length; i++) {
|
|
if (doc.lines[i].trim() !== '') return i
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isRedundantHardBreakLine({ doc, idx, line }: NormalizedLinePosition): boolean {
|
|
if (!isHardBreakOnlyLine({ line })) return false
|
|
|
|
const prev = findPrevNonBlank({ doc, idx })
|
|
if (prev === null) return false
|
|
|
|
const prevLine = normalizeMarkdownLine({ line: doc.lines[prev] })
|
|
return isHardBreakOnlyLine({ line: prevLine }) || endsWithHardBreakMarker({ line: prevLine })
|
|
}
|
|
|
|
function isHardBreakOnlyLine({ line }: MarkdownLineValue): boolean {
|
|
return HARD_BREAK_ONLY_RE.test(line.trim())
|
|
}
|
|
|
|
function endsWithHardBreakMarker({ line }: MarkdownLineValue): boolean {
|
|
const trimmed = line.trimEnd()
|
|
if (trimmed.endsWith('\\\\')) return true
|
|
return trimmed.replace(TRAILING_INLINE_CLOSERS_RE, '').endsWith('\\\\')
|
|
}
|
|
|
|
const BULLET_RE = /^(\s*)\*(\s)/
|
|
/** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */
|
|
function normalizeBulletMarker({ line }: MarkdownLineValue): string {
|
|
return line.replace(BULLET_RE, '$1-$2')
|
|
}
|
|
|
|
/** Decode HTML entities that BlockNote inserts (  & etc.) */
|
|
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()
|
|
}
|
|
if (doc.lines.length > 0) {
|
|
doc.lines.push('')
|
|
}
|
|
return doc.lines.join('\n')
|
|
}
|