Skip to content
Merged
10 changes: 10 additions & 0 deletions .changeset/tame-tables-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@rocket.chat/message-parser": minor
"@rocket.chat/gazzodown": minor
---

Adds GFM-style table support to the message parser and renders it in gazzodown.

Parser: tables require a leading and trailing pipe on every row, support column alignment via the delimiter row (`:---`, `:--:`, `---:`), and allow inline markup inside cells (a literal pipe must be escaped as `\|`). New `TABLE`, `TABLE_ROW`, and `TABLE_CELL` AST nodes are emitted. The `TABLE` node also carries an optional `fallback` — a `[start, end]` offset span into the original source — so renderers without table support can slice the source to show the raw markup instead of dropping it, without duplicating the text into the AST.

Rendering: gazzodown renders these tables using Fuselage's `Table` components with per-column alignment, and shows a compact single-row preview of the table header in message previews.
17 changes: 17 additions & 0 deletions packages/gazzodown/src/Markup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,18 @@ export const HorizontalRule: StoryObj<typeof Markup> = {
},
};

export const Table: StoryObj<typeof Markup> = {
args: {
tokens: parse(outdent`
| Name | Status | Score |
| :--- | :----: | ----: |
| Alice | **done** | 9.5 |
| Bob | :smile: | 7 |
| Carol | [profile](https://rocket.chat) | 12 |
`),
},
};

export const Example: StoryObj<{ msg: string }> = {
render: ({ msg }) => {
const parseOptions: Options = { katex: { dollarSyntax: true, parenthesisSyntax: true }, colors: true, emoticons: true };
Expand Down Expand Up @@ -299,6 +311,11 @@ export const Example: StoryObj<{ msg: string }> = {
> Sit amet, consectetur adipiscing elit.
> Donec eget ex euismod, euismod nisi euismod, vulputate nisi.

| Name | Status | Score |
| :--- | :----: | ----: |
| Alice | **done** | 9.5 |
| Bob | :smile: | 7 |

\`\`\`
const x = 1;
\`\`\`
Expand Down
4 changes: 4 additions & 0 deletions packages/gazzodown/src/Markup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import OrderedListBlock from './blocks/OrderedListBlock';
import ParagraphBlock from './blocks/ParagraphBlock';
import QuoteBlock from './blocks/QuoteBlock';
import SpoilerBlock from './blocks/SpoilerBlock';
import TableBlock from './blocks/TableBlock';
import TaskList from './blocks/TaskListBlock';
import UnorderedListBlock from './blocks/UnorderedListBlock';
import BigEmojiBlock from './emoji/BigEmojiBlock';
Expand Down Expand Up @@ -63,6 +64,9 @@ const Markup = ({ tokens, source }: MarkupProps) => (
</KatexErrorBoundary>
);

case 'TABLE':
return <TableBlock key={index} header={block.value.header} rows={block.value.rows} />;

case 'LINE_BREAK':
return <br key={index} />;

Expand Down
12 changes: 12 additions & 0 deletions packages/gazzodown/src/PreviewMarkup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ const PreviewMarkup = ({ tokens, source }: PreviewMarkupProps) => {
</KatexErrorBoundary>
);

case 'TABLE':
return (
<>
{firstBlock.value.header.map((cell, index) => (
<span key={index}>
{index > 0 ? ' | ' : null}
<PreviewInlineElements>{cell.value}</PreviewInlineElements>
</span>
))}
</>
);

default: {
// Only the `[start, end]` offset form is rendered (sliced from source); the union
// keeps the original fallback form too, which we intentionally ignore.
Expand Down
51 changes: 51 additions & 0 deletions packages/gazzodown/src/blocks/TableBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';

import InlineElements from '../elements/InlineElements';

type TableBlockProps = {
header: MessageParser.TableCell[];
rows: MessageParser.TableRow[];
};

// Explicit mapping (not an object lookup) so a crafted AST align like `__proto__`
// or `toString` can never resolve to an inherited value.
const toAlign = (align: MessageParser.TableCell['align']): 'start' | 'center' | 'end' | undefined => {
switch (align) {
case 'left':
return 'start';
case 'center':
return 'center';
case 'right':
return 'end';
default:
return undefined;
}
};

const TableBlock = ({ header, rows }: TableBlockProps) => (
<Table striped fixed={false}>
<TableHead>
<TableRow>
{header.map((cell, index) => (
<TableCell key={index} align={toAlign(cell.align)}>
<InlineElements>{cell.value}</InlineElements>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={rowIndex}>
{row.value.map((cell, cellIndex) => (
<TableCell key={cellIndex} align={toAlign(cell.align)}>
<InlineElements>{cell.value}</InlineElements>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);

export default TableBlock;
22 changes: 22 additions & 0 deletions packages/message-parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ The grammar provides support for markdown, mentions and emojis.
- URI's
- mentions users/channels
- timestamps
- tables

## Tables

GFM-style tables. A leading and trailing pipe is required on every row.
Alignment is taken from the delimiter row:

| Marker | Alignment |
| ------ | --------- |
| `:---` | left |
| `:--:` | center |
| `---:` | right |
| `---` | none |

```md
| Header 1 | Header 2 |
| -------- | :------: |
| Cell 1 | Cell 2 |
```

A literal pipe inside a cell must be escaped as `\|`. Cell content supports
inline markup (bold, italic, links, emoji, …).

## Timestamps

Expand Down
33 changes: 31 additions & 2 deletions packages/message-parser/src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ export type CodeLine = {
value: Plain;
};

export type TableCellAlignment = 'left' | 'center' | 'right' | undefined;

export type TableCell = {
type: 'TABLE_CELL';
align: TableCellAlignment;
value: Inlines[];
};

export type TableRow = {
type: 'TABLE_ROW';
value: TableCell[];
};

export type Table = {
type: 'TABLE';
value: {
header: TableCell[];
rows: TableRow[];
};
// New form is a `[start, end]` offset span; the `Plain` form is kept in the
// type only to tolerate previously-persisted data at runtime.
fallback?: SourceRange | Plain;
};

export type Color = {
type: 'COLOR';
value: {
Expand Down Expand Up @@ -218,6 +242,9 @@ export type Types = {
INLINE_KATEX: InlineKaTeX;
TIMESTAMP: Timestamp;
SPOILER_BLOCK: SpoilerBlock;
TABLE: Table;
TABLE_ROW: TableRow;
TABLE_CELL: TableCell;
};

export type ASTNode =
Expand All @@ -240,7 +267,8 @@ export type ASTNode =
| Emoji
| Color
| Tasks
| HorizontalRule;
| HorizontalRule
| Table;

export type TypesKeys = keyof Types;

Expand Down Expand Up @@ -271,6 +299,7 @@ export type Blocks =
| UnorderedList
| LineBreak
| KaTeX
| HorizontalRule;
| HorizontalRule
| Table;

export type Root = Array<Paragraph | Blocks> | [BigEmoji];
33 changes: 33 additions & 0 deletions packages/message-parser/src/grammar.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
spoiler,
spoilerBlock,
strike,
table,
task,
tasks,
unorderedList,
Expand Down Expand Up @@ -59,6 +60,7 @@ Blocks
/ BlockSpoiler
/ Code
/ HorizontalRule
/ Table
/ Heading
/ Tasks
/ OrderedList
Expand Down Expand Up @@ -88,6 +90,37 @@ BlockquoteLine
*/
BlockSpoiler = "||" EndOfLine first:(&(! "||") @Paragraph) rest:(&(! "||") @Paragraph)* EndOfLine? "||" { return spoilerBlock([first, ...rest]); }

/**
*
* Table (GFM)
* e.g:
* | Header 1 | Header 2 |
* | -------- | :------: |
* | Cell 1 | Cell 2 |
*
* v1 requires a leading and trailing pipe on every row. Alignment comes from
* the delimiter row: `:---` left, `:--:` center, `---:` right, `---` none.
* A literal pipe inside a cell must be escaped as `\|`.
*/
Table = header:TableRowLine aligns:TableDelimiterRow body:TableRowLine* { return table(header, aligns, body, [range().start, range().end]); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Implement GFM column-count semantics in the table rule. Malformed header/delimiter counts should fall back to paragraphs, and ragged body rows should be padded/truncated before rendering.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/message-parser/src/grammar.pegjs, line 103:

<comment>Implement GFM column-count semantics in the table rule. Malformed header/delimiter counts should fall back to paragraphs, and ragged body rows should be padded/truncated before rendering.</comment>

<file context>
@@ -86,6 +88,37 @@ BlockquoteLine
+ * the delimiter row: `:---` left, `:--:` center, `---:` right, `---` none.
+ * A literal pipe inside a cell must be escaped as `\|`.
+ */
+Table = header:TableRowLine aligns:TableDelimiterRow body:TableRowLine* { return table(header, aligns, body, [range().start, range().end]); }
+
+TableRowLine = "|" cells:(@TableCell "|")+ EndOfLine? { return cells; }
</file context>


TableRowLine = "|" cells:(@TableCell "|")+ EndOfLine? { return cells; }

TableCell = items:TableCellItem* { return reducePlainTexts(items); }

TableCellItem
= "\\|" { return plain('|'); }
/ !"|" !EndOfLine @(InlineItemPattern / Any)

TableDelimiterRow = "|" aligns:(@TableDelimiterCell "|")+ EndOfLine? { return aligns; }

TableDelimiterCell = [ \t]* left:":"? "-"+ right:":"? [ \t]* {
Comment on lines +105 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject header/delimiter width mismatches.

TableRowLine and TableDelimiterRow are parsed independently here, so input like | a | b |\n| - | still becomes a TABLE. That contradicts the GFM table contract and leaves downstream code with a malformed column/alignment mapping.

Suggested fix
-Table = header:TableRowLine aligns:TableDelimiterRow body:TableRowLine* { return table(header, aligns, body, [range().start, range().end]); }
+Table
+  = header:TableRowLine aligns:TableDelimiterRow &{ return header.length === aligns.length; } body:TableRowLine* {
+      return table(header, aligns, body, [range().start, range().end]);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Table = header:TableRowLine aligns:TableDelimiterRow body:TableRowLine* { return table(header, aligns, body, [range().start, range().end]); }
TableRowLine = "|" cells:(@TableCell "|")+ EndOfLine? { return cells; }
TableCell = items:TableCellItem* { return reducePlainTexts(items); }
TableCellItem
= "\\|" { return plain('|'); }
/ !"|" !EndOfLine @(InlineItemPattern / Any)
TableDelimiterRow = "|" aligns:(@TableDelimiterCell "|")+ EndOfLine? { return aligns; }
TableDelimiterCell = [ \t]* left:":"? "-"+ right:":"? [ \t]* {
Table
= header:TableRowLine aligns:TableDelimiterRow &{ return header.length === aligns.length; } body:TableRowLine* {
return table(header, aligns, body, [range().start, range().end]);
}
TableRowLine = "|" cells:(`@TableCell` "|")+ EndOfLine? { return cells; }
TableCell = items:TableCellItem* { return reducePlainTexts(items); }
TableCellItem
= "\\|" { return plain('|'); }
/ !"|" !EndOfLine @(InlineItemPattern / Any)
TableDelimiterRow = "|" aligns:(`@TableDelimiterCell` "|")+ EndOfLine? { return aligns; }
TableDelimiterCell = [ \t]* left:":"? "-"+ right:":"? [ \t]* {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/message-parser/src/grammar.pegjs` around lines 103 - 115, The table
grammar currently lets Table parse a header row and a delimiter row with
different column counts, which can still produce a TABLE with mismatched
header/alignment mappings. Update the Table rule in grammar.pegjs to validate
that TableRowLine and TableDelimiterRow produce the same number of cells before
calling table(...), and reject the parse when they differ; keep the check close
to the Table/TableRowLine/TableDelimiterRow symbols so the GFM table shape is
enforced consistently.

if (left && right) { return 'center'; }
if (right) { return 'right'; }
if (left) { return 'left'; }
return undefined;
}

// <t:1630360800:?{format}>
// <t:2025-07-22T10:00:00.000Z?:?{format}>
// <t:2025-07-22T10:00:00:?{format}>
Expand Down
52 changes: 52 additions & 0 deletions packages/message-parser/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import type {
Timestamp,
SourceRange,
HorizontalRule,
Table,
TableRow,
TableCell,
} from './definitions';

const generate =
Expand Down Expand Up @@ -139,6 +142,55 @@ export const listItem = (text: Inlines[], number?: number): ListItem => ({
...(number !== undefined && { number }),
});

// GFM trims leading/trailing whitespace of each table cell's content
const trimCellContent = (value: Inlines[]): Inlines[] => {
const result = value.slice();

const first = result[0];
if (first?.type === 'PLAIN_TEXT') {
const trimmed = first.value.replace(/^\s+/, '');
if (trimmed === '') {
result.shift();
} else {
result[0] = { type: 'PLAIN_TEXT', value: trimmed };
}
}

const last = result[result.length - 1];
if (last?.type === 'PLAIN_TEXT') {
const trimmed = last.value.replace(/\s+$/, '');
if (trimmed === '') {
result.pop();
} else {
result[result.length - 1] = { type: 'PLAIN_TEXT', value: trimmed };
}
}

return result;
};

const tableCell = (value: Inlines[], align: TableCell['align']): TableCell => ({
type: 'TABLE_CELL',
align,
value: trimCellContent(value),
});

export const table = (header: Inlines[][], aligns: Array<TableCell['align']>, rows: Inlines[][][], fallback?: SourceRange): Table => ({
type: 'TABLE',
value: {
header: header.map((cell, index) => tableCell(cell, aligns[index])),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
rows: rows.map(
(cells): TableRow => ({
type: 'TABLE_ROW',
// Normalize each row to the header's column count: pad missing cells and
// drop extras, so ragged GFM rows stay aligned with the header/delimiter.
value: header.map((_, index) => tableCell(cells[index] ?? [], aligns[index])),
}),
),
},
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
...(fallback !== undefined && { fallback }),
});

export const mentionUser = (() => {
const fn = generate('MENTION_USER');
return (value: string) => fn(plain(value));
Expand Down
19 changes: 19 additions & 0 deletions packages/message-parser/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ export const horizontalRule = (fallback?: [number, number]) => ({
...(fallback !== undefined ? { fallback } : {}),
});

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

export const tableCell = (value: unknown[], align: Align = undefined) => ({
type: 'TABLE_CELL' as const,
align,
value,
});

export const tableRow = (value: unknown[]) => ({
type: 'TABLE_ROW' as const,
value,
});

export const table = (header: unknown[], rows: unknown[], fallback?: [number, number]) => ({
type: 'TABLE' as const,
value: { header, rows },
...(fallback !== undefined ? { fallback } : {}),
});

export const katex = (value: string) => ({
type: 'KATEX' as const,
value,
Expand Down
Loading
Loading