Fix #1544 - add support for wikilink with aliases within tables (#1552)

This commit is contained in:
Riccardo
2025-11-13 15:03:48 +01:00
committed by GitHub
parent df3cd90ce7
commit 4ee065ff9a
4 changed files with 393 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
/* @unit-ready */
import MarkdownIt from 'markdown-it';
import {
default as escapeWikilinkPipes,
PIPE_PLACEHOLDER,
} from './escape-wikilink-pipes';
describe('escape-wikilink-pipes plugin', () => {
it('should render table with wikilink alias correctly', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Column |
| --- |
| [[note|alias]] |`;
const html = md.render(markdown);
// Should have proper table structure
expect(html).toContain('<table>');
expect(html).toContain('<tbody>');
// Should preserve the wikilink with pipe character intact
expect(html).toContain('[[note|alias]]');
// Should NOT split into multiple cells (would see extra <td> tags)
const tdCount = (html.match(/<td>/g) || []).length;
expect(tdCount).toBe(1);
});
it('should render table with multiple wikilink aliases in same row', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Col1 | Col2 | Col3 |
| --- | --- | --- |
| [[a|A]] | [[b|B]] | [[c|C]] |`;
const html = md.render(markdown);
// All three wikilinks should be preserved
expect(html).toContain('[[a|A]]');
expect(html).toContain('[[b|B]]');
expect(html).toContain('[[c|C]]');
// Should have exactly 3 cells in body row
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
expect(bodyMatch).toBeTruthy();
const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;
expect(bodyCells).toBe(3);
});
it('should render table with wikilink containing section and alias', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Link |
| --- |
| [[note#section|alias text]] |`;
const html = md.render(markdown);
// Wikilink with section and alias should be intact
expect(html).toContain('[[note#section|alias text]]');
// Should be in a single cell
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;
expect(bodyCells).toBe(1);
});
it('should render table with embed wikilink alias correctly', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Embed |
| --- |
| ![[image|caption]] |`;
const html = md.render(markdown);
// Embed wikilink should be preserved
expect(html).toContain('![[image|caption]]');
});
it('should not affect wikilinks without aliases', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Link |
| --- |
| [[note-without-alias]] |`;
const html = md.render(markdown);
// Regular wikilink should still work
expect(html).toContain('[[note-without-alias]]');
});
it('should not affect wikilinks outside of tables', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `
Paragraph with [[note|alias]] link.
| Column |
| --- |
| [[table-note|table-alias]] |
Another [[note2|alias2]] paragraph.
`;
const html = md.render(markdown);
// All wikilinks should be preserved
expect(html).toContain('[[note|alias]]');
expect(html).toContain('[[table-note|table-alias]]');
expect(html).toContain('[[note2|alias2]]');
});
it('should handle table with mixed content and wikilinks', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Text | Link | Mixed |
| --- | --- | --- |
| plain text | [[note|alias]] | text [[link|L]] more |`;
const html = md.render(markdown);
// Both wikilinks should be preserved
expect(html).toContain('[[note|alias]]');
expect(html).toContain('[[link|L]]');
// Should have 3 cells
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;
expect(bodyCells).toBe(3);
});
it('should handle tables without wikilinks', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Col1 | Col2 |
| --- | --- |
| text | more |`;
const html = md.render(markdown);
// Should render normal table
expect(html).toContain('<table>');
expect(html).toContain('text');
expect(html).toContain('more');
});
it('should not leave placeholder character in rendered output', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Col1 | Col2 |
| --- | --- |
| [[a|A]] | [[b|B]] |`;
const html = md.render(markdown);
// Should not contain the internal placeholder
expect(html).not.toContain(PIPE_PLACEHOLDER);
});
it('should handle complex wikilink aliases with special characters', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Link |
| --- |
| [[note-with-dashes|Alias with spaces & special!]] |`;
const html = md.render(markdown);
expect(html).toContain(
'[[note-with-dashes|Alias with spaces &amp; special!]]'
);
});
it('should handle multiple rows with wikilink aliases', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Links |
| --- |
| [[note1|alias1]] |
| [[note2|alias2]] |
| [[note3|alias3]] |`;
const html = md.render(markdown);
// All three should be preserved
expect(html).toContain('[[note1|alias1]]');
expect(html).toContain('[[note2|alias2]]');
expect(html).toContain('[[note3|alias3]]');
// Should have 3 rows in tbody
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
const bodyRows = (bodyMatch[0].match(/<tr>/g) || []).length;
expect(bodyRows).toBe(3);
});
it('should work when markdown-it does not have table support', () => {
const md = MarkdownIt();
md.disable(['table']);
// Should not throw when table rule doesn't exist
expect(() => escapeWikilinkPipes(md)).not.toThrow();
});
});

View File

@@ -0,0 +1,105 @@
/*global markdownit:readonly*/
/**
* Markdown-it plugin to handle wikilink aliases in tables by wrapping the table parser.
*
* This plugin addresses issue #1544 where wikilink aliases (e.g., [[note|alias]])
* are incorrectly split into separate table cells because the pipe character `|`
* is used both as a wikilink alias separator and a table column separator.
*
* The plugin works by wrapping the table block parser:
* 1. Before the table parser runs, temporarily replace pipes in wikilinks with a placeholder
* 2. Let the table parser create the table structure and inline tokens
* 3. After the table parser returns, restore pipes in the inline token content
* 4. Later inline parsing will see the correct wikilink syntax with pipes
*
* This approach keeps all encoding/decoding logic localized to this single function,
* making it invisible to the rest of the codebase.
*/
// Unique placeholder that's unlikely to appear in normal markdown text
// Note: We've tested various text-based placeholders but all fail:
// - "___FOAM_ALIAS_DIVIDER___" - underscores interpreted as emphasis markers
// - "FOAM__INTERNAL__..." - double underscores cause strong emphasis issues
// - "FOAMINTERNALALIASDIVIDERPLACEHOLDER" - gets truncated (output: "[[noteFOAMINTERN")
// Solution: Use a single Unicode character (U+F8FF Private Use Area) that:
// - Has no markdown meaning
// - Won't be split or modified by parsers
// - Is extremely unlikely to appear in user content
export const PIPE_PLACEHOLDER = '\uF8FF';
/**
* Regex to match wikilinks with pipes (aliases or multiple pipes)
* Matches:
* - [[note|alias]]
* - ![[note|alias]] (embeds)
* - [[note#section|alias]]
*/
const WIKILINK_WITH_PIPE_REGEX = /!?\[\[([^\]]*?\|[^\]]*?)\]\]/g;
/**
* Replace pipes within wikilinks with placeholder
*/
function encodePipesInWikilinks(text: string): string {
return text.replace(WIKILINK_WITH_PIPE_REGEX, match => {
return match.replace(/\|/g, PIPE_PLACEHOLDER);
});
}
/**
* Restore pipes from placeholder in text
*/
function decodePipesInWikilinks(text: string): string {
return text.replace(new RegExp(PIPE_PLACEHOLDER, 'g'), '|');
}
export const escapeWikilinkPipes = (md: markdownit) => {
// Get the original table parser function
// Note: __find__ and __rules__ are internal APIs but necessary for wrapping
const ruler = md.block.ruler as any;
const tableRuleIndex = ruler.__find__('table');
if (tableRuleIndex === -1) {
// Table rule not found (maybe GFM tables not enabled), skip wrapping
return md;
}
const originalTableRule = ruler.__rules__[tableRuleIndex].fn;
// Create wrapped table parser
const wrappedTableRule = function (state, startLine, endLine, silent) {
// Store the token count before parsing to identify new tokens
const tokensBefore = state.tokens.length;
// 1. ENCODE: Replace pipes in wikilinks with placeholder in source
const originalSrc = state.src;
state.src = encodePipesInWikilinks(state.src);
// 2. Call the original table parser
// It will create tokens with encoded content (pipes replaced)
const result = originalTableRule(state, startLine, endLine, silent);
// 3. DECODE: Restore pipes in the newly created inline tokens
if (result) {
// Only process tokens that were created by this table parse
for (let i = tokensBefore; i < state.tokens.length; i++) {
const token = state.tokens[i];
// Inline tokens contain the cell content that needs decoding
if (token.type === 'inline' && token.content) {
token.content = decodePipesInWikilinks(token.content);
}
}
}
// 4. Restore original source
state.src = originalSrc;
return result;
};
// Replace the table rule with our wrapped version
md.block.ruler.at('table', wrappedTableRule);
return md;
};
export default escapeWikilinkPipes;

View File

@@ -6,6 +6,7 @@ import { default as markdownItFoamTags } from './tag-highlight';
import { default as markdownItWikilinkNavigation } from './wikilink-navigation';
import { default as markdownItRemoveLinkReferences } from './remove-wikilink-references';
import { default as markdownItWikilinkEmbed } from './wikilink-embed';
import { default as escapeWikilinkPipes } from './escape-wikilink-pipes';
export default async function activate(
context: vscode.ExtensionContext,
@@ -16,6 +17,7 @@ export default async function activate(
return {
extendMarkdownIt: (md: markdownit) => {
return [
escapeWikilinkPipes,
markdownItWikilinkEmbed,
markdownItFoamTags,
markdownItWikilinkNavigation,

View File

@@ -5,6 +5,7 @@ import { createTestNote } from '../../test/test-utils';
import { getUriInWorkspace } from '../../test/test-utils-vscode';
import { default as markdownItWikilinkNavigation } from './wikilink-navigation';
import { default as markdownItRemoveLinkReferences } from './remove-wikilink-references';
import { default as escapeWikilinkPipes } from './escape-wikilink-pipes';
describe('Link generation in preview', () => {
const noteA = createTestNote({
@@ -23,6 +24,7 @@ describe('Link generation in preview', () => {
const ws = new FoamWorkspace().set(noteA).set(noteB);
const md = [
escapeWikilinkPipes,
markdownItWikilinkNavigation,
markdownItRemoveLinkReferences,
].reduce((acc, extension) => extension(acc, ws), MarkdownIt());
@@ -91,4 +93,71 @@ describe('Link generation in preview', () => {
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">this note</a></p>\n`
);
});
describe('wikilinks with aliases in tables', () => {
it('generates a link with alias inside a table cell', () => {
const table = `| Week | Week again |
| --- | --- |
| [[note-a|W44]] | [[note-b|W45]] |`;
const result = md.render(table);
// Should contain proper links with aliases
expect(result).toContain(
`<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>W44</a>`
);
expect(result).toContain(
`<a class='foam-note-link' title='${noteB.title}' href='/path2/to/note-b.md' data-href='/path2/to/note-b.md'>W45</a>`
);
});
it('generates a link with alias and section inside a table cell', () => {
const table = `| Week |
| --- |
| [[note-b#sec1|Week 1]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-note-link' title='${noteB.title}#sec1' href='/path2/to/note-b.md#sec1' data-href='/path2/to/note-b.md#sec1'>Week 1</a>`
);
});
it('generates placeholder link with alias inside a table cell', () => {
const table = `| Week |
| --- |
| [[nonexistent|Placeholder]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">Placeholder</a>`
);
});
it('handles multiple wikilinks with aliases in the same table row', () => {
const table = `| Col1 | Col2 | Col3 |
| --- | --- | --- |
| [[note-a|A]] | [[note-b|B]] | [[placeholder|P]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>A</a>`
);
expect(result).toContain(
`<a class='foam-note-link' title='${noteB.title}' href='/path2/to/note-b.md' data-href='/path2/to/note-b.md'>B</a>`
);
expect(result).toContain(
`<a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">P</a>`
);
});
it('handles wikilinks without aliases in tables (should still work)', () => {
const table = `| Week |
| --- |
| [[note-a]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>${noteA.title}</a>`
);
});
});
});