From f88b359bb59983c0c4843eaef2ba50d26d8b8d9c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 13:02:56 +0200 Subject: [PATCH] fix(editor): render wikilinks inside tables --- src/utils/wikilinks.test.ts | 61 +++++++- src/utils/wikilinks.ts | 193 ++++++++++++++++++++------ tests/smoke/table-hover-crash.spec.ts | 60 ++++++++ 3 files changed, 267 insertions(+), 47 deletions(-) diff --git a/src/utils/wikilinks.test.ts b/src/utils/wikilinks.test.ts index d616d7fd..a08fba73 100644 --- a/src/utils/wikilinks.test.ts +++ b/src/utils/wikilinks.test.ts @@ -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[] diff --git a/src/utils/wikilinks.ts b/src/utils/wikilinks.ts index 42ae10f6..30c5d64f 100644 --- a/src/utils/wikilinks.ts +++ b/src/utils/wikilinks.ts @@ -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 +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 | 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, - 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, '') diff --git a/tests/smoke/table-hover-crash.spec.ts b/tests/smoke/table-hover-crash.spec.ts index 4287614d..e01f2383 100644 --- a/tests/smoke/table-hover-crash.spec.ts +++ b/tests/smoke/table-hover-crash.spec.ts @@ -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([]) + }) })