diff --git a/packages/markdown-parser/src/core/render.ts b/packages/markdown-parser/src/core/render.ts
index b0f82f9..dc00400 100644
--- a/packages/markdown-parser/src/core/render.ts
+++ b/packages/markdown-parser/src/core/render.ts
@@ -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 = '';
@@ -96,18 +102,21 @@ export function render(nodes?: ASTNode[]): string {
case 'list_item':
html += `
${render(node.children)}\n`;
break;
- case 'table':
- html += '\n\n\n';
- (node.headers ?? []).forEach((cell) => {
- html += `| ${render(cell.children)} | \n`;
- });
- html += '
\n\n';
+ case 'table': {
+ html += '\n';
+ if (node.headers) {
+ html += '\n\n';
+ node.headers.forEach((cell) => {
+ html += `| ${render(cell.children)} | \n`;
+ });
+ html += '
\n\n';
+ }
if (node.children && node.children.length > 0) {
html += '\n';
node.children.forEach((row) => {
html += '\n';
row.children?.forEach((cell) => {
- html += `| ${render(cell.children)} | \n`;
+ html += `${render(cell.children)} | \n`;
});
html += '
\n';
});
@@ -115,6 +124,7 @@ export function render(nodes?: ASTNode[]): string {
}
html += '
\n';
break;
+ }
case 'hr':
html += '
\n';
break;
diff --git a/packages/markdown-parser/src/index.spec.ts b/packages/markdown-parser/src/index.spec.ts
index fadd95d..061787b 100644
--- a/packages/markdown-parser/src/index.spec.ts
+++ b/packages/markdown-parser/src/index.spec.ts
@@ -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';
@@ -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(`
+
+
+| 属性 |
+类型 |
+说明 |
+
+
+
+
+| name |
+string |
+用户名 |
+
+
+
+`);
+ });
+
+ 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(`
+
+
+| Ray ID |
+a2c5ac427b7aa727 |
+
+
+| IP 地址 |
+172.214.47.18 |
+
+
+
+`);
+ });
+
it('rejects unsafe link schemes', () => {
const ast = parse('[click](javascript:alert(1))');
diff --git a/packages/markdown-parser/src/rules/blocks.ts b/packages/markdown-parser/src/rules/blocks.ts
index c67c8c5..40364df 100644
--- a/packages/markdown-parser/src/rules/blocks.ts
+++ b/packages/markdown-parser/src/rules/blocks.ts
@@ -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
*/
@@ -125,36 +207,43 @@ 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++;
}
@@ -162,10 +251,13 @@ export const tableRule: BlockRule = {
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,