mirror of
https://github.com/foambubble/foam.git
synced 2026-01-09 14:08:13 -05:00
This commit is contained in:
@@ -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 & 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user