fix(editor): render wikilinks inside tables

This commit is contained in:
lucaronin
2026-05-05 13:02:56 +02:00
parent 67ada08e05
commit f88b359bb5
3 changed files with 267 additions and 47 deletions

View File

@@ -34,21 +34,24 @@ describe('preProcessWikilinks', () => {
expect(preProcessWikilinks(input)).toBe(input)
})
it('preserves wikilinks and inline formatting inside markdown tables', () => {
it('replaces wikilinks inside markdown tables without introducing extra cell delimiters', () => {
const input = [
'| Topic | Reference |',
'| --- | --- |',
'| [[Project Alpha]] | **bold** and [[Project Beta]] |',
'| [[Project Alpha]] | **bold** and [[project/beta|Project Beta]] |',
'',
'Outside [[Project Gamma]]',
].join('\n')
const result = preProcessWikilinks(input)
const row = result.split('\n')[2]
expect(result).toContain('| [[Project Alpha]] | **bold** and [[Project Beta]] |')
expect(row.match(/\|/g)).toHaveLength(3)
expect(row).toContain('WIKILINK:ENC:Project%20Alpha')
expect(row).toContain('WIKILINK:ENC:project%2Fbeta%7CProject%20Beta')
expect(result).toContain('WIKILINK:Project Gamma')
expect(result).not.toContain('WIKILINK:Project Alpha')
expect(result).not.toContain('WIKILINK:Project Beta')
expect(result).not.toContain('[[Project Alpha]]')
expect(result).not.toContain('[[project/beta|Project Beta]]')
})
it('handles empty string', () => {
@@ -150,6 +153,32 @@ describe('injectWikilinks', () => {
expect(result[0].content![0].text).toBe('text ')
expect(result[0].content![1].type).toBe('wikilink')
})
it('converts encoded placeholder text inside table cells into wikilink nodes', () => {
const blocks = [{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [
{ type: 'text', text: `${WL_START}ENC:project%2Fbeta%7CProject%20Beta${WL_END}` },
],
}],
}],
},
children: [],
}]
const result = injectWikilinks(blocks) as TestBlock[]
const tableContent = result[0].content as unknown as { rows: Array<{ cells: Array<{ content: TestBlock[] }> }> }
expect(tableContent.rows[0].cells[0].content).toEqual([{
type: 'wikilink',
props: { target: 'project/beta|Project Beta' },
content: undefined,
}])
})
})
describe('splitFrontmatter', () => {
@@ -379,6 +408,28 @@ describe('restoreWikilinksInBlocks', () => {
expect(result[0].content![1]).toEqual({ type: 'link', text: 'a link', href: 'http://example.com' })
})
it('converts wikilink nodes inside table cells back to markdown text', () => {
const blocks = [{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [
{ type: 'wikilink', props: { target: 'Project Alpha' }, content: undefined },
],
}],
}],
},
children: [],
}]
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
const tableContent = result[0].content as unknown as { rows: Array<{ cells: Array<{ content: TestBlock[] }> }> }
expect(tableContent.rows[0].cells[0].content).toEqual([{ type: 'text', text: '[[Project Alpha]]' }])
})
it('handles blocks without content', () => {
const blocks = [{ type: 'heading', props: { level: 1 } }]
const result = restoreWikilinksInBlocks(blocks as unknown[]) as TestBlock[]

View File

@@ -3,24 +3,43 @@ const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
const WL_RE = /\u2039WIKILINK:([^\u203A]+)\u203A/g
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g
const TABLE_PLACEHOLDER_PREFIX = 'ENC:'
const FORMAT_MARKERS = new Set(['*', '_', '`', '~'])
type MarkdownSource = string
type MarkdownLine = string
type MarkdownLines = MarkdownLine[]
type TableLineMap = boolean[]
type PlaceholderPayload = string
type WikilinkTarget = string
type FrontmatterSplit = [MarkdownSource, MarkdownSource]
type CharacterCount = number
type LineIndex = number
type TextOffset = number
type TokenSequence = string
type ParsedTextRange = { text: MarkdownSource, nextIndex: TextOffset }
type MatchTargets = Set<WikilinkTarget>
type WordCount = number
/** Pre-process markdown: replace [[target]] with placeholder tokens */
export function preProcessWikilinks(md: string): string {
export function preProcessWikilinks(md: MarkdownSource): MarkdownSource {
const lines = md.split('\n')
const tableLines = findMarkdownTableLines(lines)
return lines.map((line, index) => (
tableLines[index] ? line : replaceWikilinksWithPlaceholders(line)
replaceWikilinksWithPlaceholders(line, { encodePayload: tableLines[index] })
)).join('\n')
}
// Minimal shape of a BlockNote block for wikilink processing
interface BlockLike {
content?: InlineItem[]
content?: BlockContent
children?: BlockLike[]
[key: string]: unknown
}
type ScalarBlockContent = string | number | boolean | null | undefined
type BlockContent = InlineItem[] | TableContentLike | Record<string, unknown> | ScalarBlockContent
interface InlineItem {
type: string
text?: string
@@ -29,13 +48,59 @@ interface InlineItem {
[key: string]: unknown
}
type ContentTransform = (content: InlineItem[]) => InlineItem[]
function replaceWikilinksWithPlaceholders(line: string): string {
return line.replace(WIKILINK_RE, (_match, target) => `${WL_START}${target}${WL_END}`)
interface TableContentLike {
type: 'tableContent'
rows?: TableRowLike[]
[key: string]: unknown
}
function findMarkdownTableLines(lines: string[]): boolean[] {
interface TableRowLike {
cells?: TableCellLike[]
[key: string]: unknown
}
type TableCellLike = string | TableCellObjectLike
interface TableCellObjectLike {
content?: InlineItem[]
[key: string]: unknown
}
type ContentTransform = (content: InlineItem[]) => InlineItem[]
interface WikilinkReplacementOptions {
encodePayload: boolean
}
function replaceWikilinksWithPlaceholders(
line: MarkdownLine,
options: WikilinkReplacementOptions,
): MarkdownLine {
return line.replace(WIKILINK_RE, (_match, target) => wikilinkPlaceholder(target, options))
}
function wikilinkPlaceholder(
target: WikilinkTarget,
options: WikilinkReplacementOptions,
): string {
const payload = options.encodePayload
? `${TABLE_PLACEHOLDER_PREFIX}${encodeURIComponent(target)}`
: target
return `${WL_START}${payload}${WL_END}`
}
function decodePlaceholderPayload(payload: PlaceholderPayload): WikilinkTarget {
if (!payload.startsWith(TABLE_PLACEHOLDER_PREFIX)) return payload
const encoded = payload.slice(TABLE_PLACEHOLDER_PREFIX.length)
try {
return decodeURIComponent(encoded)
} catch {
return encoded
}
}
function findMarkdownTableLines(lines: MarkdownLines): TableLineMap {
const tableLines = lines.map(() => false)
for (let index = 0; index < lines.length - 1; index++) {
if (!isPotentialTableRow(lines[index]) || !isMarkdownTableSeparator(lines[index + 1])) {
@@ -49,7 +114,11 @@ function findMarkdownTableLines(lines: string[]): boolean[] {
return tableLines
}
function markTableBodyLines(lines: string[], tableLines: boolean[], start: number): number {
function markTableBodyLines(
lines: MarkdownLines,
tableLines: TableLineMap,
start: LineIndex,
): LineIndex {
let index = start
while (index < lines.length && isPotentialTableRow(lines[index])) {
tableLines[index] = true
@@ -58,24 +127,24 @@ function markTableBodyLines(lines: string[], tableLines: boolean[], start: numbe
return index
}
function isPotentialTableRow(line: string): boolean {
function isPotentialTableRow(line: MarkdownLine): boolean {
const trimmed = line.trim()
return trimmed.includes('|') && trimmed !== '|'
}
function isMarkdownTableSeparator(line: string): boolean {
function isMarkdownTableSeparator(line: MarkdownLine): boolean {
const cells = splitTableCells(line)
return cells.length > 1 && cells.every(isMarkdownTableSeparatorCell)
}
function splitTableCells(line: string): string[] {
function splitTableCells(line: MarkdownLine): MarkdownLines {
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 {
function isMarkdownTableSeparatorCell(cell: MarkdownLine): boolean {
return /^:?-{3,}:?$/.test(cell)
}
@@ -83,9 +152,7 @@ function isMarkdownTableSeparatorCell(cell: string): boolean {
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
return (blocks as BlockLike[]).map(block => {
const b = clone ? { ...block } : block
if (b.content && Array.isArray(b.content)) {
b.content = transform(b.content)
}
b.content = transformBlockContent(b.content, transform)
if (b.children && Array.isArray(b.children)) {
b.children = walkBlocks(b.children, transform, clone) as BlockLike[]
}
@@ -93,11 +160,45 @@ function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = fals
})
}
function textSegment(item: InlineItem, text: string): InlineItem {
function transformBlockContent(content: BlockContent, transform: ContentTransform): BlockContent {
if (Array.isArray(content)) return transform(content)
if (isTableContent(content)) return transformTableContent(content, transform)
return content
}
function isTableContent(content: BlockContent): content is TableContentLike {
return Boolean(
content
&& typeof content === 'object'
&& !Array.isArray(content)
&& (content as TableContentLike).type === 'tableContent'
&& Array.isArray((content as TableContentLike).rows),
)
}
function transformTableContent(
content: TableContentLike,
transform: ContentTransform,
): TableContentLike {
return {
...content,
rows: content.rows?.map((row) => ({
...row,
cells: row.cells?.map((cell) => transformTableCell(cell, transform)),
})),
}
}
function transformTableCell(cell: TableCellLike, transform: ContentTransform): TableCellLike {
if (typeof cell === 'string' || !Array.isArray(cell.content)) return cell
return { ...cell, content: transform(cell.content) }
}
function textSegment(item: InlineItem, text: MarkdownSource): InlineItem {
return { ...item, text }
}
function wikilinkItem(target: string): InlineItem {
function wikilinkItem(target: WikilinkTarget): InlineItem {
return {
type: 'wikilink',
props: { target },
@@ -136,7 +237,7 @@ function expandWikilinksInItem(item: InlineItem): InlineItem[] {
let match
while ((match = WL_RE.exec(item.text)) !== null) {
if (match.index > lastIndex) result.push(textSegment(item, item.text.slice(lastIndex, match.index)))
result.push(wikilinkItem(match[1]))
result.push(wikilinkItem(decodePlaceholderPayload(match[1])))
lastIndex = match.index + match[0].length
}
if (lastIndex < item.text.length) result.push(textSegment(item, item.text.slice(lastIndex)))
@@ -155,17 +256,17 @@ function collapseWikilinksInContent(content: InlineItem[]): InlineItem[] {
return result
}
function frontmatterOpeningLength(content: string): number | null {
function frontmatterOpeningLength(content: MarkdownSource): CharacterCount | null {
if (content.startsWith('---\r\n')) return 5
if (content.startsWith('---\n')) return 4
return null
}
function precedingLineEndingLength(value: string): number {
function precedingLineEndingLength(value: MarkdownSource): CharacterCount {
return value.startsWith('\r\n') ? 2 : value.startsWith('\n') ? 1 : 0
}
function frontmatterCloseLength(value: string): number {
function frontmatterCloseLength(value: MarkdownSource): CharacterCount {
const lineEndingLength = precedingLineEndingLength(value)
if (value.endsWith('\r\n')) return lineEndingLength + 5
if (value.endsWith('\n')) return lineEndingLength + 4
@@ -173,7 +274,7 @@ function frontmatterCloseLength(value: string): number {
}
/** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
export function splitFrontmatter(content: string): [string, string] {
export function splitFrontmatter(content: MarkdownSource): FrontmatterSplit {
const openLength = frontmatterOpeningLength(content)
if (openLength === null) return ['', content]
@@ -188,8 +289,8 @@ export function splitFrontmatter(content: string): [string, string] {
/** Extract all outgoing wikilink targets from content.
* Finds [[target]] and [[target|display]] patterns, returning just the target part.
* Returns a sorted, deduplicated array. */
export function extractOutgoingLinks(content: string): string[] {
const links: string[] = []
export function extractOutgoingLinks(content: MarkdownSource): WikilinkTarget[] {
const links: WikilinkTarget[] = []
const re = /\[\[([^\]]+)\]\]/g
let match
while ((match = re.exec(content)) !== null) {
@@ -205,10 +306,10 @@ export function extractOutgoingLinks(content: string): string[] {
* Searches for any target in the set, returns the first matching paragraph trimmed
* to a max length. Returns null if no match found. */
export function extractBacklinkContext(
content: string,
matchTargets: Set<string>,
maxLength = 120,
): string | null {
content: MarkdownSource,
matchTargets: MatchTargets,
maxLength: CharacterCount = 120,
): MarkdownSource | null {
const [, body] = splitFrontmatter(content)
// Remove the H1 title line
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
@@ -236,13 +337,13 @@ export function extractBacklinkContext(
}
/** Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule). */
function isSnippetLine(line: string): boolean {
function isSnippetLine(line: MarkdownLine): boolean {
const t = line.trim()
return t !== '' && !t.startsWith('#') && !t.startsWith('```') && !t.startsWith('---')
}
/** Strip leading list markers (*, -, +, 1.) from a line. */
function stripListMarker(line: string): string {
function stripListMarker(line: MarkdownLine): MarkdownLine {
const t = line.trimStart()
for (const prefix of ['* ', '- ', '+ ']) {
if (t.startsWith(prefix)) return t.slice(prefix.length)
@@ -254,13 +355,13 @@ function stripListMarker(line: string): string {
return t
}
function isOrderedListMarker(line: string, dotPos: number): boolean {
function isOrderedListMarker(line: MarkdownLine, dotPos: TextOffset): boolean {
if (dotPos < 1 || dotPos > 3) return false
return /^\d+$/.test(line.slice(0, dotPos))
}
/** Remove the first H1 heading line, allowing leading blank lines. */
function removeH1Line(body: string): string {
function removeH1Line(body: MarkdownSource): MarkdownSource {
const lines = body.split('\n')
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith('# ')) return lines.slice(i + 1).join('\n')
@@ -270,7 +371,7 @@ function removeH1Line(body: string): string {
}
/** Strip markdown formatting chars: bold, italic, code, strikethrough, and resolve links. */
function stripMarkdownChars(s: string): string {
function stripMarkdownChars(s: MarkdownSource): MarkdownSource {
let result = ''
let i = 0
while (i < s.length) {
@@ -292,32 +393,40 @@ function stripMarkdownChars(s: string): string {
return result
}
function readUntilSequence(value: string, start: number, sequence: string): { text: string, nextIndex: number } {
function readUntilSequence(
value: MarkdownSource,
start: TextOffset,
sequence: TokenSequence,
): ParsedTextRange {
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 } {
function readUntilChar(
value: MarkdownSource,
start: TextOffset,
char: TokenSequence,
): ParsedTextRange {
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 {
function skipMarkdownLinkDestination(value: MarkdownSource, start: TextOffset): TextOffset {
if (value[start] !== '(') return start
const end = value.indexOf(')', start + 1)
return end === -1 ? value.length : end + 1
}
function wikilinkDisplayText(inner: string): string {
function wikilinkDisplayText(inner: WikilinkTarget): MarkdownSource {
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 {
function extractSubheadingText(line: MarkdownLine): MarkdownSource | null {
const t = line.trim()
const stripped = t.replace(/^#+/, '')
if (stripped.length < t.length && stripped.startsWith(' ')) {
@@ -329,7 +438,7 @@ function extractSubheadingText(line: string): string | null {
/** Extract a snippet: first ~160 chars of body content, stripped of markdown.
* Mirrors the Rust extract_snippet() logic for frontend use. */
export function extractSnippet(content: string): string {
export function extractSnippet(content: MarkdownSource): MarkdownSource {
const [, body] = splitFrontmatter(content)
const withoutH1 = removeH1Line(body)
const clean = withoutH1.split('\n').filter(isSnippetLine).map(stripListMarker).join(' ')
@@ -341,7 +450,7 @@ export function extractSnippet(content: string): string {
// Fallback: collect sub-heading text when no paragraph content exists
const headingText = withoutH1.split('\n')
.map(extractSubheadingText)
.filter((t): t is string => t !== null)
.filter((t): t is MarkdownSource => t !== null)
.join(' ')
const headingStripped = stripMarkdownChars(headingText).trim()
if (!headingStripped) return ''
@@ -349,7 +458,7 @@ export function extractSnippet(content: string): string {
return headingStripped.slice(0, 160) + '...'
}
export function countWords(content: string): number {
export function countWords(content: MarkdownSource): WordCount {
const [, body] = splitFrontmatter(content)
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
const withoutWikilinks = withoutTitle.replace(/\[\[[^\]]*\]\]/g, '')

View File

@@ -7,6 +7,10 @@ import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
const TABLE_RELOAD_NOTE_PATH = '/project/table-reload-regression.md'
const TABLE_RELOAD_NOTE_TITLE = 'Table Reload Regression'
const TABLE_WIKILINK_NOTE_PATH = '/table-wikilink-regression.md'
const TABLE_WIKILINK_NOTE_TITLE = 'Table Wikilink Regression'
const TABLE_WIKILINK_TARGET = 'application-design-and-build'
const TABLE_WIKILINK_TARGET_TITLE = 'Application Design and Build'
function writeTableReloadNote(vaultDir: string): void {
fs.writeFileSync(
@@ -26,6 +30,37 @@ status: draft
)
}
function writeTableWikilinkNotes(vaultDir: string): void {
fs.writeFileSync(
path.join(vaultDir, `${TABLE_WIKILINK_TARGET}.md`),
`---
title: ${TABLE_WIKILINK_TARGET_TITLE}
type: Note
---
# ${TABLE_WIKILINK_TARGET_TITLE}
Target note for table wikilink navigation.
`,
)
fs.writeFileSync(
path.join(vaultDir, TABLE_WIKILINK_NOTE_PATH.slice(1)),
`---
title: ${TABLE_WIKILINK_NOTE_TITLE}
type: Note
---
# ${TABLE_WIKILINK_NOTE_TITLE}
| Domain | Weight |
| --- | --- |
| [[${TABLE_WIKILINK_TARGET}]] | 99% |
## Domain Links
- [[${TABLE_WIKILINK_TARGET}]]
`,
)
}
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
@@ -292,4 +327,29 @@ test.describe('table hover crash regression', () => {
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
test('@smoke wikilinks inside table cells render and navigate', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
writeTableWikilinkNotes(tempVaultDir)
await openFixtureVaultTauri(page, tempVaultDir)
const tableNote = page
.getByTestId('note-list-container')
.locator('[data-note-path]')
.filter({ hasText: TABLE_WIKILINK_NOTE_TITLE })
.first()
await expect(tableNote).toBeVisible({ timeout: 5_000 })
await tableNote.click()
const tableWikilink = page
.locator('table .wikilink')
.filter({ hasText: TABLE_WIKILINK_TARGET_TITLE })
.first()
await expect(tableWikilink).toBeVisible({ timeout: 5_000 })
await expect(tableWikilink).toHaveAttribute('data-target', TABLE_WIKILINK_TARGET)
await tableWikilink.click({ modifiers: ['Meta'] })
await expect(page.locator('.bn-editor h1').first()).toHaveText(TABLE_WIKILINK_TARGET_TITLE, { timeout: 5_000 })
expect(errors).toEqual([])
})
})