fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0)
This commit is contained in:
@@ -11,7 +11,7 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// Should contain bullet points with header:value format
|
||||
expect(ir.text).toContain("• Value: 1");
|
||||
expect(ir.text).toContain("• Value: 2");
|
||||
@@ -29,7 +29,7 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// First column becomes row label
|
||||
expect(ir.text).toContain("Speed");
|
||||
expect(ir.text).toContain("Scale");
|
||||
@@ -40,22 +40,20 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
expect(ir.text).toContain("• Postgres: Large");
|
||||
});
|
||||
|
||||
it("preserves flat mode as default", () => {
|
||||
it("leaves table syntax untouched by default", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md); // default is flat
|
||||
|
||||
// Flat mode uses tabs
|
||||
expect(ir.text).toContain("A");
|
||||
expect(ir.text).toContain("B");
|
||||
expect(ir.text).toContain("1");
|
||||
expect(ir.text).toContain("2");
|
||||
// Should not have bullet formatting
|
||||
const ir = markdownToIR(md);
|
||||
|
||||
// No table conversion by default
|
||||
expect(ir.text).toContain("| A | B |");
|
||||
expect(ir.text).toContain("| 1 | 2 |");
|
||||
expect(ir.text).not.toContain("•");
|
||||
expect(ir.styles.some((style) => style.style === "code_block")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty cells gracefully", () => {
|
||||
@@ -67,7 +65,7 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// Should handle empty cell without crashing
|
||||
expect(ir.text).toContain("B");
|
||||
expect(ir.text).toContain("• Value: 2");
|
||||
@@ -81,11 +79,41 @@ describe("markdownToIR tableMode bullets", () => {
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
|
||||
// Should have bold style for row label
|
||||
const hasRowLabelBold = ir.styles.some(
|
||||
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1"
|
||||
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1",
|
||||
);
|
||||
expect(hasRowLabelBold).toBe(true);
|
||||
});
|
||||
|
||||
it("renders tables as code blocks in code mode", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
expect(ir.text).toContain("| A | B |");
|
||||
expect(ir.text).toContain("| 1 | 2 |");
|
||||
expect(ir.styles.some((style) => style.style === "code_block")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves inline styles and links in bullets mode", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| _Row_ | [Link](https://example.com) |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
const hasItalic = ir.styles.some(
|
||||
(s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row",
|
||||
);
|
||||
expect(hasItalic).toBe(true);
|
||||
expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
|
||||
type ListState = {
|
||||
type: "bullet" | "ordered";
|
||||
@@ -12,24 +13,8 @@ type LinkState = {
|
||||
labelStart: number;
|
||||
};
|
||||
|
||||
type TableCell = {
|
||||
content: string;
|
||||
isHeader: boolean;
|
||||
};
|
||||
|
||||
type TableRow = TableCell[];
|
||||
|
||||
type TableState = {
|
||||
headers: string[];
|
||||
rows: TableRow[];
|
||||
currentRow: TableCell[];
|
||||
currentCell: string;
|
||||
inHeader: boolean;
|
||||
};
|
||||
|
||||
type RenderEnv = {
|
||||
listStack: ListState[];
|
||||
linkStack: LinkState[];
|
||||
};
|
||||
|
||||
type MarkdownToken = {
|
||||
@@ -65,19 +50,36 @@ type OpenStyle = {
|
||||
start: number;
|
||||
};
|
||||
|
||||
export type TableRenderMode = "flat" | "bullets";
|
||||
|
||||
type RenderState = {
|
||||
type RenderTarget = {
|
||||
text: string;
|
||||
styles: MarkdownStyleSpan[];
|
||||
openStyles: OpenStyle[];
|
||||
links: MarkdownLinkSpan[];
|
||||
linkStack: LinkState[];
|
||||
};
|
||||
|
||||
type TableCell = {
|
||||
text: string;
|
||||
styles: MarkdownStyleSpan[];
|
||||
links: MarkdownLinkSpan[];
|
||||
};
|
||||
|
||||
type TableState = {
|
||||
headers: TableCell[];
|
||||
rows: TableCell[][];
|
||||
currentRow: TableCell[];
|
||||
currentCell: RenderTarget | null;
|
||||
inHeader: boolean;
|
||||
};
|
||||
|
||||
type RenderState = RenderTarget & {
|
||||
env: RenderEnv;
|
||||
headingStyle: "none" | "bold";
|
||||
blockquotePrefix: string;
|
||||
enableSpoilers: boolean;
|
||||
tableMode: TableRenderMode;
|
||||
tableMode: MarkdownTableMode;
|
||||
table: TableState | null;
|
||||
hasTables: boolean;
|
||||
};
|
||||
|
||||
export type MarkdownParseOptions = {
|
||||
@@ -86,8 +88,8 @@ export type MarkdownParseOptions = {
|
||||
headingStyle?: "none" | "bold";
|
||||
blockquotePrefix?: string;
|
||||
autolink?: boolean;
|
||||
/** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */
|
||||
tableMode?: TableRenderMode;
|
||||
/** How to render tables (off|bullets|code). Default: off. */
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
||||
@@ -98,7 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
||||
typographer: false,
|
||||
});
|
||||
md.enable("strikethrough");
|
||||
md.enable("table");
|
||||
if (options.tableMode && options.tableMode !== "off") {
|
||||
md.enable("table");
|
||||
} else {
|
||||
md.disable("table");
|
||||
}
|
||||
if (options.autolink === false) {
|
||||
md.disable("autolink");
|
||||
}
|
||||
@@ -166,28 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function initRenderTarget(): RenderTarget {
|
||||
return {
|
||||
text: "",
|
||||
styles: [],
|
||||
openStyles: [],
|
||||
links: [],
|
||||
linkStack: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRenderTarget(state: RenderState): RenderTarget {
|
||||
return state.table?.currentCell ?? state;
|
||||
}
|
||||
|
||||
function appendText(state: RenderState, value: string) {
|
||||
if (!value) return;
|
||||
// If we're inside a table cell in bullets mode, collect into cell buffer
|
||||
if (state.table && state.tableMode === "bullets") {
|
||||
state.table.currentCell += value;
|
||||
return;
|
||||
}
|
||||
state.text += value;
|
||||
const target = resolveRenderTarget(state);
|
||||
target.text += value;
|
||||
}
|
||||
|
||||
function openStyle(state: RenderState, style: MarkdownStyle) {
|
||||
state.openStyles.push({ style, start: state.text.length });
|
||||
const target = resolveRenderTarget(state);
|
||||
target.openStyles.push({ style, start: target.text.length });
|
||||
}
|
||||
|
||||
function closeStyle(state: RenderState, style: MarkdownStyle) {
|
||||
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
if (state.openStyles[i]?.style === style) {
|
||||
const start = state.openStyles[i].start;
|
||||
state.openStyles.splice(i, 1);
|
||||
const end = state.text.length;
|
||||
const target = resolveRenderTarget(state);
|
||||
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
if (target.openStyles[i]?.style === style) {
|
||||
const start = target.openStyles[i].start;
|
||||
target.openStyles.splice(i, 1);
|
||||
const end = target.text.length;
|
||||
if (end > start) {
|
||||
state.styles.push({ start, end, style });
|
||||
target.styles.push({ start, end, style });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -212,39 +230,37 @@ function appendListPrefix(state: RenderState) {
|
||||
|
||||
function renderInlineCode(state: RenderState, content: string) {
|
||||
if (!content) return;
|
||||
// In bullets mode inside table, just add text without styling
|
||||
if (state.table && state.tableMode === "bullets") {
|
||||
state.table.currentCell += content;
|
||||
return;
|
||||
}
|
||||
const start = state.text.length;
|
||||
state.text += content;
|
||||
state.styles.push({ start, end: start + content.length, style: "code" });
|
||||
const target = resolveRenderTarget(state);
|
||||
const start = target.text.length;
|
||||
target.text += content;
|
||||
target.styles.push({ start, end: start + content.length, style: "code" });
|
||||
}
|
||||
|
||||
function renderCodeBlock(state: RenderState, content: string) {
|
||||
let code = content ?? "";
|
||||
if (!code.endsWith("\n")) code = `${code}\n`;
|
||||
const start = state.text.length;
|
||||
state.text += code;
|
||||
state.styles.push({ start, end: start + code.length, style: "code_block" });
|
||||
const target = resolveRenderTarget(state);
|
||||
const start = target.text.length;
|
||||
target.text += code;
|
||||
target.styles.push({ start, end: start + code.length, style: "code_block" });
|
||||
if (state.env.listStack.length === 0) {
|
||||
state.text += "\n";
|
||||
target.text += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkClose(state: RenderState) {
|
||||
const link = state.env.linkStack.pop();
|
||||
const target = resolveRenderTarget(state);
|
||||
const link = target.linkStack.pop();
|
||||
if (!link?.href) return;
|
||||
const href = link.href.trim();
|
||||
if (!href) return;
|
||||
const start = link.labelStart;
|
||||
const end = state.text.length;
|
||||
const end = target.text.length;
|
||||
if (end <= start) {
|
||||
state.links.push({ start, end, href });
|
||||
target.links.push({ start, end, href });
|
||||
return;
|
||||
}
|
||||
state.links.push({ start, end, href });
|
||||
target.links.push({ start, end, href });
|
||||
}
|
||||
|
||||
function initTableState(): TableState {
|
||||
@@ -252,14 +268,72 @@ function initTableState(): TableState {
|
||||
headers: [],
|
||||
rows: [],
|
||||
currentRow: [],
|
||||
currentCell: "",
|
||||
currentCell: null,
|
||||
inHeader: false,
|
||||
};
|
||||
}
|
||||
|
||||
function finishTableCell(cell: RenderTarget): TableCell {
|
||||
closeRemainingStyles(cell);
|
||||
return {
|
||||
text: cell.text,
|
||||
styles: cell.styles,
|
||||
links: cell.links,
|
||||
};
|
||||
}
|
||||
|
||||
function trimCell(cell: TableCell): TableCell {
|
||||
const text = cell.text;
|
||||
let start = 0;
|
||||
let end = text.length;
|
||||
while (start < end && /\s/.test(text[start] ?? "")) start += 1;
|
||||
while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1;
|
||||
if (start === 0 && end === text.length) return cell;
|
||||
const trimmedText = text.slice(start, end);
|
||||
const trimmedLength = trimmedText.length;
|
||||
const trimmedStyles: MarkdownStyleSpan[] = [];
|
||||
for (const span of cell.styles) {
|
||||
const sliceStart = Math.max(0, span.start - start);
|
||||
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
||||
if (sliceEnd > sliceStart) {
|
||||
trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style });
|
||||
}
|
||||
}
|
||||
const trimmedLinks: MarkdownLinkSpan[] = [];
|
||||
for (const span of cell.links) {
|
||||
const sliceStart = Math.max(0, span.start - start);
|
||||
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
||||
if (sliceEnd > sliceStart) {
|
||||
trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href });
|
||||
}
|
||||
}
|
||||
return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks };
|
||||
}
|
||||
|
||||
function appendCell(state: RenderState, cell: TableCell) {
|
||||
if (!cell.text) return;
|
||||
const start = state.text.length;
|
||||
state.text += cell.text;
|
||||
for (const span of cell.styles) {
|
||||
state.styles.push({
|
||||
start: start + span.start,
|
||||
end: start + span.end,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
for (const link of cell.links) {
|
||||
state.links.push({
|
||||
start: start + link.start,
|
||||
end: start + link.end,
|
||||
href: link.href,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableAsBullets(state: RenderState) {
|
||||
if (!state.table) return;
|
||||
const { headers, rows } = state.table;
|
||||
const headers = state.table.headers.map(trimCell);
|
||||
const rows = state.table.rows.map((row) => row.map(trimCell));
|
||||
|
||||
// If no headers or rows, skip
|
||||
if (headers.length === 0 && rows.length === 0) return;
|
||||
@@ -273,22 +347,31 @@ function renderTableAsBullets(state: RenderState) {
|
||||
for (const row of rows) {
|
||||
if (row.length === 0) continue;
|
||||
|
||||
const rowLabel = row[0]?.content?.trim() || "";
|
||||
if (rowLabel) {
|
||||
// Bold the row label
|
||||
const start = state.text.length;
|
||||
state.text += rowLabel;
|
||||
state.styles.push({ start, end: state.text.length, style: "bold" });
|
||||
const rowLabel = row[0];
|
||||
if (rowLabel?.text) {
|
||||
const labelStart = state.text.length;
|
||||
appendCell(state, rowLabel);
|
||||
const labelEnd = state.text.length;
|
||||
if (labelEnd > labelStart) {
|
||||
state.styles.push({ start: labelStart, end: labelEnd, style: "bold" });
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
|
||||
// Add each column as a bullet point
|
||||
for (let i = 1; i < row.length; i++) {
|
||||
const header = headers[i]?.trim() || `Column ${i}`;
|
||||
const value = row[i]?.content?.trim() || "";
|
||||
if (value) {
|
||||
state.text += `• ${header}: ${value}\n`;
|
||||
const header = headers[i];
|
||||
const value = row[i];
|
||||
if (!value?.text) continue;
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
} else {
|
||||
state.text += `Column ${i}: `;
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
@@ -296,37 +379,77 @@ function renderTableAsBullets(state: RenderState) {
|
||||
// Simple table: just list headers and values
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const header = headers[i]?.trim() || "";
|
||||
const value = row[i]?.content?.trim() || "";
|
||||
if (header && value) {
|
||||
state.text += `• ${header}: ${value}\n`;
|
||||
} else if (value) {
|
||||
state.text += `• ${value}\n`;
|
||||
const header = headers[i];
|
||||
const value = row[i];
|
||||
if (!value?.text) continue;
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableAsFlat(state: RenderState) {
|
||||
function renderTableAsCode(state: RenderState) {
|
||||
if (!state.table) return;
|
||||
const { headers, rows } = state.table;
|
||||
const headers = state.table.headers.map(trimCell);
|
||||
const rows = state.table.rows.map((row) => row.map(trimCell));
|
||||
|
||||
// Render headers
|
||||
for (const header of headers) {
|
||||
state.text += header.trim() + "\t";
|
||||
}
|
||||
if (headers.length > 0) {
|
||||
state.text = state.text.trimEnd() + "\n";
|
||||
}
|
||||
const columnCount = Math.max(headers.length, ...rows.map((row) => row.length));
|
||||
if (columnCount === 0) return;
|
||||
|
||||
// Render rows
|
||||
for (const row of rows) {
|
||||
for (const cell of row) {
|
||||
state.text += cell.content.trim() + "\t";
|
||||
const widths = Array.from({ length: columnCount }, () => 0);
|
||||
const updateWidths = (cells: TableCell[]) => {
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
const cell = cells[i];
|
||||
const width = cell?.text.length ?? 0;
|
||||
if (widths[i] < width) widths[i] = width;
|
||||
}
|
||||
state.text = state.text.trimEnd() + "\n";
|
||||
};
|
||||
updateWidths(headers);
|
||||
for (const row of rows) updateWidths(row);
|
||||
|
||||
const codeStart = state.text.length;
|
||||
|
||||
const appendRow = (cells: TableCell[]) => {
|
||||
state.text += "|";
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
state.text += " ";
|
||||
const cell = cells[i];
|
||||
if (cell) appendCell(state, cell);
|
||||
const pad = widths[i] - (cell?.text.length ?? 0);
|
||||
if (pad > 0) state.text += " ".repeat(pad);
|
||||
state.text += " |";
|
||||
}
|
||||
state.text += "\n";
|
||||
};
|
||||
|
||||
const appendDivider = () => {
|
||||
state.text += "|";
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
const dashCount = Math.max(3, widths[i]);
|
||||
state.text += ` ${"-".repeat(dashCount)} |`;
|
||||
}
|
||||
state.text += "\n";
|
||||
};
|
||||
|
||||
appendRow(headers);
|
||||
appendDivider();
|
||||
for (const row of rows) {
|
||||
appendRow(row);
|
||||
}
|
||||
|
||||
const codeEnd = state.text.length;
|
||||
if (codeEnd > codeStart) {
|
||||
state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" });
|
||||
}
|
||||
if (state.env.listStack.length === 0) {
|
||||
state.text += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
break;
|
||||
case "link_open": {
|
||||
const href = getAttr(token, "href") ?? "";
|
||||
state.env.linkStack.push({ href, labelStart: state.text.length });
|
||||
const target = resolveRenderTarget(state);
|
||||
target.linkStack.push({ href, labelStart: target.text.length });
|
||||
break;
|
||||
}
|
||||
case "link_close":
|
||||
@@ -428,15 +552,18 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
|
||||
// Table handling
|
||||
case "table_open":
|
||||
if (state.tableMode === "bullets") {
|
||||
if (state.tableMode !== "off") {
|
||||
state.table = initTableState();
|
||||
state.hasTables = true;
|
||||
}
|
||||
break;
|
||||
case "table_close":
|
||||
if (state.tableMode === "bullets" && state.table) {
|
||||
renderTableAsBullets(state);
|
||||
} else if (state.tableMode === "flat" && state.table) {
|
||||
renderTableAsFlat(state);
|
||||
if (state.table) {
|
||||
if (state.tableMode === "bullets") {
|
||||
renderTableAsBullets(state);
|
||||
} else if (state.tableMode === "code") {
|
||||
renderTableAsCode(state);
|
||||
}
|
||||
}
|
||||
state.table = null;
|
||||
break;
|
||||
@@ -461,33 +588,24 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
case "tr_close":
|
||||
if (state.table) {
|
||||
if (state.table.inHeader) {
|
||||
state.table.headers = state.table.currentRow.map((c) => c.content);
|
||||
state.table.headers = state.table.currentRow;
|
||||
} else {
|
||||
state.table.rows.push(state.table.currentRow);
|
||||
}
|
||||
state.table.currentRow = [];
|
||||
} else if (state.tableMode === "flat") {
|
||||
// Legacy flat mode without table state
|
||||
state.text += "\n";
|
||||
}
|
||||
break;
|
||||
case "th_open":
|
||||
case "td_open":
|
||||
if (state.table) {
|
||||
state.table.currentCell = "";
|
||||
state.table.currentCell = initRenderTarget();
|
||||
}
|
||||
break;
|
||||
case "th_close":
|
||||
case "td_close":
|
||||
if (state.table) {
|
||||
state.table.currentRow.push({
|
||||
content: state.table.currentCell,
|
||||
isHeader: token.type === "th_close",
|
||||
});
|
||||
state.table.currentCell = "";
|
||||
} else if (state.tableMode === "flat") {
|
||||
// Legacy flat mode without table state
|
||||
state.text += "\t";
|
||||
if (state.table?.currentCell) {
|
||||
state.table.currentRow.push(finishTableCell(state.table.currentCell));
|
||||
state.table.currentCell = null;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -501,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
}
|
||||
}
|
||||
|
||||
function closeRemainingStyles(state: RenderState) {
|
||||
for (let i = state.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
const open = state.openStyles[i];
|
||||
const end = state.text.length;
|
||||
function closeRemainingStyles(target: RenderTarget) {
|
||||
for (let i = target.openStyles.length - 1; i >= 0; i -= 1) {
|
||||
const open = target.openStyles[i];
|
||||
const end = target.text.length;
|
||||
if (end > open.start) {
|
||||
state.styles.push({
|
||||
target.styles.push({
|
||||
start: open.start,
|
||||
end,
|
||||
style: open.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
state.openStyles = [];
|
||||
target.openStyles = [];
|
||||
}
|
||||
|
||||
function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] {
|
||||
@@ -594,26 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
|
||||
}
|
||||
|
||||
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
|
||||
const env: RenderEnv = { listStack: [], linkStack: [] };
|
||||
return markdownToIRWithMeta(markdown, options).ir;
|
||||
}
|
||||
|
||||
export function markdownToIRWithMeta(
|
||||
markdown: string,
|
||||
options: MarkdownParseOptions = {},
|
||||
): { ir: MarkdownIR; hasTables: boolean } {
|
||||
const env: RenderEnv = { listStack: [] };
|
||||
const md = createMarkdownIt(options);
|
||||
const tokens = md.parse(markdown ?? "", env as unknown as object);
|
||||
if (options.enableSpoilers) {
|
||||
applySpoilerTokens(tokens as MarkdownToken[]);
|
||||
}
|
||||
|
||||
const tableMode = options.tableMode ?? "flat";
|
||||
const tableMode = options.tableMode ?? "off";
|
||||
|
||||
const state: RenderState = {
|
||||
text: "",
|
||||
styles: [],
|
||||
openStyles: [],
|
||||
links: [],
|
||||
linkStack: [],
|
||||
env,
|
||||
headingStyle: options.headingStyle ?? "none",
|
||||
blockquotePrefix: options.blockquotePrefix ?? "",
|
||||
enableSpoilers: options.enableSpoilers ?? false,
|
||||
tableMode,
|
||||
table: null,
|
||||
hasTables: false,
|
||||
};
|
||||
|
||||
renderTokens(tokens as MarkdownToken[], state);
|
||||
@@ -631,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
|
||||
finalLength === state.text.length ? state.text : state.text.slice(0, finalLength);
|
||||
|
||||
return {
|
||||
text: finalText,
|
||||
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
|
||||
links: clampLinkSpans(state.links, finalLength),
|
||||
ir: {
|
||||
text: finalText,
|
||||
styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)),
|
||||
links: clampLinkSpans(state.links, finalLength),
|
||||
},
|
||||
hasTables: state.hasTables,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
34
src/markdown/tables.ts
Normal file
34
src/markdown/tables.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
import { markdownToIRWithMeta } from "./ir.js";
|
||||
import { renderMarkdownWithMarkers } from "./render.js";
|
||||
|
||||
const MARKDOWN_STYLE_MARKERS = {
|
||||
bold: { open: "**", close: "**" },
|
||||
italic: { open: "_", close: "_" },
|
||||
strikethrough: { open: "~~", close: "~~" },
|
||||
code: { open: "`", close: "`" },
|
||||
code_block: { open: "```\n", close: "```" },
|
||||
} as const;
|
||||
|
||||
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
|
||||
if (!markdown || mode === "off") return markdown;
|
||||
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
|
||||
linkify: false,
|
||||
autolink: false,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: mode,
|
||||
});
|
||||
if (!hasTables) return markdown;
|
||||
return renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: MARKDOWN_STYLE_MARKERS,
|
||||
escapeText: (text) => text,
|
||||
buildLink: (link, text) => {
|
||||
const href = link.href.trim();
|
||||
if (!href) return null;
|
||||
const label = text.slice(link.start, link.end);
|
||||
if (!label) return null;
|
||||
return { start: link.start, end: link.end, open: "[", close: `](${href})` };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user