Skip to content
Merged
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
24 changes: 17 additions & 7 deletions packages/markdown-parser/src/core/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const getHeadingLevel = (level: unknown) =>
? level
: null;

// Keep table alignment attributes within the supported HTML grammar.
const getTableAlignment = (align: unknown) =>
align === 'left' || align === 'center' || align === 'right'
? ` align="${align}"`
: '';

export function render(nodes?: ASTNode[]): string {
let html = '';

Expand Down Expand Up @@ -96,25 +102,29 @@ export function render(nodes?: ASTNode[]): string {
case 'list_item':
html += `<li>${render(node.children)}</li>\n`;
break;
case 'table':
html += '<table>\n<thead>\n<tr>\n';
(node.headers ?? []).forEach((cell) => {
html += `<th>${render(cell.children)}</th>\n`;
});
html += '</tr>\n</thead>\n';
case 'table': {
html += '<table>\n';
if (node.headers) {
html += '<thead>\n<tr>\n';
node.headers.forEach((cell) => {
html += `<th${getTableAlignment(cell.align)}>${render(cell.children)}</th>\n`;
});
html += '</tr>\n</thead>\n';
}
if (node.children && node.children.length > 0) {
html += '<tbody>\n';
node.children.forEach((row) => {
html += '<tr>\n';
row.children?.forEach((cell) => {
html += `<td>${render(cell.children)}</td>\n`;
html += `<td${getTableAlignment(cell.align)}>${render(cell.children)}</td>\n`;
});
html += '</tr>\n';
});
html += '</tbody>\n';
}
html += '</table>\n';
break;
}
case 'hr':
html += '<hr />\n';
break;
Expand Down
48 changes: 48 additions & 0 deletions packages/markdown-parser/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { describe, it, expect } from 'vitest';
import { MarkdownParser } from './core/parser';
import { createFuyeorMarkdownParser } from './default';
import { render } from './core/render';
import { headingRule, codeBlockRule, tableRule } from './rules/blocks';
import { boldRule, linkRule } from './rules/inlines';

Expand Down Expand Up @@ -70,6 +71,53 @@ describe('test @fuyeor/markdown-parser', () => {
expect(tableNode.children![0].type).toBe('table_row');
});

it('renders aligned headed tables', () => {
const ast = createFuyeorMarkdownParser()(
'| 属性 | 类型 | 说明 |\n| :--- | :---: | ---: |\n| name | string | 用户名 |',
);

expect(render(ast)).toBe(`<table>
<thead>
<tr>
<th align="left">属性</th>
<th align="center">类型</th>
<th align="right">说明</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">name</td>
<td align="center">string</td>
<td align="right">用户名</td>
</tr>
</tbody>
</table>
`);
});

it('renders separator-first tables without a header', () => {
const ast = createFuyeorMarkdownParser()(
'| :--- | ---: |\n| Ray ID | a2c5ac427b7aa727 |\n| IP 地址 | 172.214.47.18 |',
);
const tableNode = ast.find((node) => node.type === 'table');

expect(tableNode?.headers).toBeUndefined();
expect(tableNode?.children).toHaveLength(2);
expect(render(ast)).toBe(`<table>
<tbody>
<tr>
<td align="left">Ray ID</td>
<td align="right">a2c5ac427b7aa727</td>
</tr>
<tr>
<td align="left">IP 地址</td>
<td align="right">172.214.47.18</td>
</tr>
</tbody>
</table>
`);
});

it('rejects unsafe link schemes', () => {
const ast = parse('[click](javascript:alert(1))');

Expand Down
144 changes: 118 additions & 26 deletions packages/markdown-parser/src/rules/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,88 @@ export function extractFencedBlock(state: BlockState): FencedBlock | null {
return result;
}

const TABLE_SEPARATOR_PATTERN = /^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?\s*$/;
const TABLE_SEPARATOR_CELL_PATTERN = /^:?-+:?$/;

type TableAlignment = 'left' | 'center' | 'right' | undefined;

// Split table rows while treating escaped pipes as cell content.
const extractTableCells = (row: string) => {
if (!row.includes('\\')) {
const cells = row.split('|');
if (cells[0]?.trim() === '') cells.shift();
if (cells.at(-1)?.trim() === '') cells.pop();
return cells.map((value) => value.trim());
}

const cells: string[] = [];
let cell = '';
let escaped = false;

for (const char of row) {
if (escaped) {
cell += char === '|' || char === '\\' ? char : `\\${char}`;
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '|') {
cells.push(cell);
cell = '';
} else {
cell += char;
}
}

if (escaped) cell += '\\';
cells.push(cell);

if (cells[0]?.trim() === '') cells.shift();
if (cells.at(-1)?.trim() === '') cells.pop();
return cells.map((value) => value.trim());
};

// Convert separator cells into the alignment metadata used by table cells.
const parseTableAlignments = (line: string): TableAlignment[] | null => {
if (!TABLE_SEPARATOR_PATTERN.test(line)) return null;

const separators = extractTableCells(line);
if (
separators.length === 0 ||
separators.some((cell) => !TABLE_SEPARATOR_CELL_PATTERN.test(cell))
)
return null;

return separators.map((separator) => {
const startsWithColon = separator.startsWith(':');
const endsWithColon = separator.endsWith(':');
if (startsWithColon && endsWithColon) return 'center';
if (startsWithColon) return 'left';
if (endsWithColon) return 'right';
return undefined;
});
};

// Normalize rows to the separator-defined column count.
const normalizeTableCells = (cells: string[], columnCount: number) => {
if (cells.length === columnCount) return cells;
if (cells.length > columnCount) return cells.slice(0, columnCount);
return cells.concat(Array(columnCount - cells.length).fill(''));
};

// Create table cells without allocating an alignment property for left-default columns.
const createTableCell = (
content: string,
alignment: TableAlignment,
parseInline: (content: string) => ASTNode[],
): ASTNode => {
const cell: ASTNode = {
type: 'table_cell',
children: parseInline(content),
};
if (alignment) cell.align = alignment;
return cell;
};

/**
* parse table |...| syntax
*/
Expand All @@ -125,47 +207,57 @@ export const tableRule: BlockRule = {
const line = state.currentLine;
if (!line || !line.includes('|')) return null;

// check if the second line is a line separator |---|---|
if (state.lineIndex + 1 >= state.lineCount) return null;
const nextLine = state.lines[state.lineIndex + 1];
if (!/^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?\s*$/.test(nextLine))
return null;

let consumedLines = 2;
// split header
const extractCells = (row: string) => {
const parts = row.split('|');
if (parts[0] !== undefined && parts[0].trim() === '') parts.shift();
if (parts.length > 0 && parts[parts.length - 1].trim() === '')
parts.pop();
return parts.map((s) => s.trim());
};
const currentAlignments = parseTableAlignments(line);
let alignments: TableAlignment[] | null = currentAlignments;
let headerCells: string[] | undefined;
let consumedLines = currentAlignments ? 1 : 0;

if (!currentAlignments) {
// A separator on the second line identifies a standard headed table.
if (state.lineIndex + 1 >= state.lineCount) return null;
const nextLine = state.lines[state.lineIndex + 1];
alignments = parseTableAlignments(nextLine);
if (!alignments) return null;

headerCells = extractTableCells(line);
consumedLines = 2;
if (headerCells.length !== alignments.length) return null;
}

const headers = extractCells(line);
if (!alignments) return null;
const columnCount = alignments.length;
const headers = headerCells
? normalizeTableCells(headerCells, columnCount)
: undefined;
const rows = [];

// scan subsequent lines
// Scan subsequent rows until a line without a pipe is encountered.
while (state.lineIndex + consumedLines < state.lineCount) {
const rowLine = state.lines[state.lineIndex + consumedLines];
// the table ends when a row without a | is encountered
if (!rowLine.includes('|')) break;

rows.push({
type: 'table_row',
children: extractCells(rowLine).map((cell) => ({
type: 'table_cell',
children: ctx.parseInline(cell),
})),
children: normalizeTableCells(
extractTableCells(rowLine),
columnCount,
).map((cell, index) =>
createTableCell(cell, alignments[index], ctx.parseInline),
),
});
consumedLines++;
}

return {
node: {
type: 'table',
headers: headers.map((h) => ({
type: 'table_cell',
children: ctx.parseInline(h),
})),
...(headers
? {
headers: headers.map((header, index) =>
createTableCell(header, alignments[index], ctx.parseInline),
),
}
: {}),
children: rows,
},
consumedLines,
Expand Down
Loading