fix: preserve wikilinks inside markdown tables

This commit is contained in:
lucaronin
2026-04-28 18:00:15 +02:00
parent d51b76f992
commit 1aede09834
3 changed files with 105 additions and 16 deletions

View File

@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', async () => {
it('filters large datasets by search query', { timeout: 15000 }, async () => {
vi.useFakeTimers()
try {
const entries = [

View File

@@ -34,6 +34,23 @@ describe('preProcessWikilinks', () => {
expect(preProcessWikilinks(input)).toBe(input)
})
it('preserves wikilinks and inline formatting inside markdown tables', () => {
const input = [
'| Topic | Reference |',
'| --- | --- |',
'| [[Project Alpha]] | **bold** and [[Project Beta]] |',
'',
'Outside [[Project Gamma]]',
].join('\n')
const result = preProcessWikilinks(input)
expect(result).toContain('| [[Project Alpha]] | **bold** and [[Project Beta]] |')
expect(result).toContain('WIKILINK:Project Gamma')
expect(result).not.toContain('WIKILINK:Project Alpha')
expect(result).not.toContain('WIKILINK:Project Beta')
})
it('handles empty string', () => {
expect(preProcessWikilinks('')).toBe('')
})

View File

@@ -2,10 +2,16 @@
const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
const WL_RE = new RegExp(`${WL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^${WL_END}]+)${WL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g')
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g
const FORMAT_MARKERS = new Set(['*', '_', '`', '~'])
/** Pre-process markdown: replace [[target]] with placeholder tokens */
export function preProcessWikilinks(md: string): string {
return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`)
const lines = md.split('\n')
const tableLines = findMarkdownTableLines(lines)
return lines.map((line, index) => (
tableLines[index] ? line : replaceWikilinksWithPlaceholders(line)
)).join('\n')
}
// Minimal shape of a BlockNote block for wikilink processing
@@ -25,6 +31,54 @@ interface InlineItem {
type ContentTransform = (content: InlineItem[]) => InlineItem[]
function replaceWikilinksWithPlaceholders(line: string): string {
return line.replace(WIKILINK_RE, (_match, target) => `${WL_START}${target}${WL_END}`)
}
function findMarkdownTableLines(lines: string[]): boolean[] {
const tableLines = lines.map(() => false)
for (let index = 0; index < lines.length - 1; index++) {
if (!isPotentialTableRow(lines[index]) || !isMarkdownTableSeparator(lines[index + 1])) {
continue
}
tableLines[index] = true
tableLines[index + 1] = true
index = markTableBodyLines(lines, tableLines, index + 2) - 1
}
return tableLines
}
function markTableBodyLines(lines: string[], tableLines: boolean[], start: number): number {
let index = start
while (index < lines.length && isPotentialTableRow(lines[index])) {
tableLines[index] = true
index++
}
return index
}
function isPotentialTableRow(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('|') && trimmed !== '|'
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = splitTableCells(line)
return cells.length > 1 && cells.every(isMarkdownTableSeparatorCell)
}
function splitTableCells(line: string): string[] {
let trimmed = line.trim()
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
return trimmed.split('|').map((cell) => cell.trim()).filter(Boolean)
}
function isMarkdownTableSeparatorCell(cell: string): boolean {
return /^:?-{3,}:?$/.test(cell)
}
/** Walk blocks recursively, applying a transform to each block's inline content */
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
return (blocks as BlockLike[]).map(block => {
@@ -188,21 +242,15 @@ function stripMarkdownChars(s: string): string {
let result = ''
let i = 0
while (i < s.length) {
if (s[i] === '[' && s[i + 1] === '[') {
i += 2
let inner = ''
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
if (i < s.length - 1) i += 2
const pipe = inner.indexOf('|')
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
if (s.startsWith('[[', i)) {
const parsed = readUntilSequence(s, i + 2, ']]')
result += wikilinkDisplayText(parsed.text)
i = parsed.nextIndex
} else if (s[i] === '[') {
i++
let text = ''
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
if (i < s.length) i++
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
result += text
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
const parsed = readUntilChar(s, i + 1, ']')
result += parsed.text
i = skipMarkdownLinkDestination(s, parsed.nextIndex)
} else if (FORMAT_MARKERS.has(s[i])) {
i++
} else {
result += s[i]
@@ -212,6 +260,30 @@ function stripMarkdownChars(s: string): string {
return result
}
function readUntilSequence(value: string, start: number, sequence: string): { text: string, nextIndex: number } {
const end = value.indexOf(sequence, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + sequence.length }
}
function readUntilChar(value: string, start: number, char: string): { text: string, nextIndex: number } {
const end = value.indexOf(char, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + 1 }
}
function skipMarkdownLinkDestination(value: string, start: number): number {
if (value[start] !== '(') return start
const end = value.indexOf(')', start + 1)
return end === -1 ? value.length : end + 1
}
function wikilinkDisplayText(inner: string): string {
const pipe = inner.indexOf('|')
return pipe === -1 ? inner : inner.slice(pipe + 1)
}
/** Extract sub-heading text (## , ### , etc.) stripped of the # prefix. */
function extractSubheadingText(line: string): string | null {
const t = line.trim()