Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 140 additions & 2 deletions packages/tui/src/components/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.

const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;

class StrictStrikethroughTokenizer extends Tokenizer {
class MarkdownTokenizer extends Tokenizer {
override del(src: string): Tokens.Del | undefined {
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
if (!match) {
Expand All @@ -20,11 +20,149 @@ class StrictStrikethroughTokenizer extends Tokenizer {
tokens: this.lexer.inlineTokens(text),
};
}

override table(src: string): Tokens.Table | undefined {
const cap = this.rules.block.table.exec(src);
if (!cap) {
return;
}
if (!this.rules.other.tableDelimiter.test(cap[2])) {
return;
}

const headers = this.splitCellsRespectingCodeSpans(cap[1]);
const aligns = cap[2].replace(this.rules.other.tableAlignChars, "").split("|");
const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, "").split("\n") : [];

if (headers.length !== aligns.length) {
return;
}

const item: Tokens.Table = {
type: "table",
raw: cap[0],
header: [],
align: [],
rows: [],
};

for (const align of aligns) {
if (this.rules.other.tableAlignRight.test(align)) {
item.align.push("right");
} else if (this.rules.other.tableAlignCenter.test(align)) {
item.align.push("center");
} else if (this.rules.other.tableAlignLeft.test(align)) {
item.align.push("left");
} else {
item.align.push(null);
}
}

for (let i = 0; i < headers.length; i++) {
item.header.push({
text: headers[i],
tokens: this.lexer.inline(headers[i]),
header: true,
align: item.align[i],
});
}

for (const row of rows) {
item.rows.push(
this.splitCellsRespectingCodeSpans(row, item.header.length).map((cell, i) => ({
text: cell,
tokens: this.lexer.inline(cell),
header: false,
align: item.align[i],
})),
);
}

return item;
}

/**
* Split a table row into cells, treating `|` inside backtick code spans as
* literal text rather than column delimiters. Also honors `\|` as an escaped
* pipe, matching marked's default table behavior.
*/
private splitCellsRespectingCodeSpans(tableRow: string, count?: number): string[] {
const cells: string[] = [];
let current = "";
let i = 0;

while (i < tableRow.length) {
const ch = tableRow[i];

if (ch === "\\" && tableRow[i + 1] === "|") {
current += "|";
i += 2;
continue;
}

if (ch === "`") {
const start = i;
let openLen = 0;
while (i < tableRow.length && tableRow[i] === "`") {
openLen++;
i++;
}

while (i < tableRow.length) {
if (tableRow[i] === "`") {
let closeLen = 0;
while (i < tableRow.length && tableRow[i] === "`") {
closeLen++;
i++;
}
if (closeLen === openLen) {
break;
}
} else {
i++;
}
}

current += tableRow.slice(start, i);
continue;
}

if (ch === "|") {
cells.push(current.trim());
current = "";
i++;
continue;
}

current += ch;
i++;
}

cells.push(current.trim());

if (cells[0]?.trim() === "") {
cells.shift();
}
if (cells.length > 0 && cells.at(-1)?.trim() === "") {
cells.pop();
}

if (count !== undefined) {
while (cells.length < count) {
cells.push("");
}
if (cells.length > count) {
cells.length = count;
}
}

return cells;
}
}

const markdownParser = new Marked();
markdownParser.setOptions({
tokenizer: new StrictStrikethroughTokenizer(),
tokenizer: new MarkdownTokenizer(),
});

/**
Expand Down
42 changes: 42 additions & 0 deletions packages/tui/test/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,48 @@ describe("Markdown component", () => {
`Expected table to end without a blank line: ${JSON.stringify(plainLines)}`,
);
});

it("should not split inline code containing pipe characters into extra columns", () => {
// Pipes inside backtick code spans must not be treated as column delimiters.
// The markdown parser (marked) splits table rows on | before resolving
// inline code spans, so `TFile | null` becomes two cells instead of one.
const markdown = new Markdown(
`| API | Returns |
| --- | --- |
| \`app.workspace.getActiveFile()\` | \`TFile | null\` |
| \`app.workspace.getActiveViewOfType(T)\` | \`View | null\` |`,
0,
0,
defaultMarkdownTheme,
);

const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());

// Borders should stay intact: a 2-col table has 3 vertical borders per row.
const tableLines = plainLines.filter((line) => line.startsWith("│"));
assert.ok(tableLines.length > 0, "Expected table rows to render");
for (const line of tableLines) {
const borderCount = line.split("│").length - 1;
assert.strictEqual(borderCount, 3, `Expected 3 borders (2 cols), got ${borderCount}: "${line}"`);
}

// The full type expression must appear in the rendered output, not just the
// part before the pipe.
const allText = plainLines.join(" ");
assert.ok(
allText.includes("TFile | null"),
`Expected "TFile | null" in output, got: ${JSON.stringify(plainLines)}`,
);
assert.ok(
allText.includes("View | null"),
`Expected "View | null" in output, got: ${JSON.stringify(plainLines)}`,
);

// The inline code should be styled (yellow in defaultMarkdownTheme)
const joinedOutput = lines.join("\n");
assert.ok(joinedOutput.includes("\x1b[33m"), "Inline code in table cells should be styled (yellow)");
});
});

describe("Combined features", () => {
Expand Down
Loading