Skip to content

Rendering a table

Draw an aligned table into a message with renderTable(). Covers picking a frame, aligning and capping columns, wide characters and emoji, filling and spacing, and paging past Discord's character limit.

Discord doesn't render table markup, so a leaderboard or a settings dump comes out as a ragged list. renderTable draws aligned columns into a string you can send.

const table = renderTable([
    ['Command', 'Uses'],
    ['/ban', '412'],
    ['/kick', '87'],
    ['/award', '1203']
]);
╭─────────┬──────╮
│ Command │ Uses │
├─────────┼──────┤
│ /ban    │ 412  │
├─────────┼──────┤
│ /kick   │ 87   │
├─────────┼──────┤
│ /award  │ 1203 │
╰─────────┴──────╯

The first argument is rows of strings, and row zero becomes the header. Each column grows to fit its widest cell.

Tip

Discord shows a message in a proportional font, so the columns don't line up. Pass fence: true to wrap the output in a code block.

Picking a frame

The frame decides where the table can go. A box frame reads well in a Discord code block, and markdown suits a file or a gist. Every table so far used the default frame, and border picks another one.

renderTable(rows, { border: 'rounded' }); // the default
renderTable(rows, { border: 'double' });
renderTable(rows, { border: 'ascii' });
renderTable(rows, { border: 'markdown' });

double draws a heavier frame. It's the only style that gives the header its own separator glyphs. ascii draws with +, -, and |.

double            ascii

╔═══════╦═════╗   +-------+-----+
║ Name  ║ Age ║   | Name  | Age |
╠═══════╬═════╣   +-------+-----+
║ Alice ║ 30  ║   | Alice | 30  |
╚═══════╩═════╝   +-------+-----+

markdown drops the outer frame and emits a GitHub-flavored table, the kind with a | --- | delimiter row.

| Name  | Age |
| --- | --- |
| Alice | 30  |

Warning

Discord doesn't render GitHub-flavored tables in a message. Use markdown for output headed somewhere else, a gist or a repo file. Take a box border with fence: true for a Discord message.

Aligning columns

Pass align one value to set every column, or an array to set them one at a time. Any column the array doesn't cover falls back to left.

renderTable(rows, { align: 'center' });
renderTable(rows, { align: ['left', 'right', 'center'] });

numericAlign right-aligns any column whose body cells all read as numbers, which lines the digits up on a scoreboard. An explicit align for that column wins.

renderTable(
    [
        ['Player', 'Score'],
        ['ava', '100'],
        ['ben', '9'],
        ['cy', '42']
    ],
    { numericAlign: true }
);
╭────────┬───────╮
│ Player │ Score │
├────────┼───────┤
│ ava    │   100 │
├────────┼───────┤
│ ben    │     9 │
├────────┼───────┤
│ cy     │    42 │
╰────────┴───────╯

The test skips an empty cell. A cell holding one space passes it, because Number(' ') is 0. If you set emptyCell to something like '-', the test reads a '-' in every empty cell, which makes the column non-numeric.

Capping a column

A column grows to fit its widest cell, so one long description stretches every row of the table with it. maxWidth sets the widest a column can get, counted in display columns. overflow picks what happens to a cell over that cap.

renderTable(rows, { maxWidth: 20 }); // wraps, the default
renderTable(rows, { maxWidth: 20, overflow: 'truncate' });

wrap breaks the cell across several lines inside its row. truncate cuts it and adds a trailing ellipsis, which counts toward the cap.

wrap             truncate

╭────────────╮   ╭────────────╮
│ hello      │   │ hello wor… │
│ world this │   ╰────────────╯
│ is long    │
╰────────────╯

A markdown row holds one line, so that border truncates whatever overflow says.

Warning

maxWidth throws a RangeError on zero, a negative, or a fraction. padding throws on a negative or a fraction. Both checks run before any drawing.

Wide characters and emoji

Latin letters, digits, and punctuation line up everywhere. Emoji break the frame in Discord. CJK comes close enough to read.

renderTable counts a CJK character and an emoji as two columns, then pads on that count. A terminal whose font covers those characters draws the result correctly.

renderTable([
    ['Name', 'Label'],
    ['Tokyo', '東京'],
    ['Party', '🎉🎉']
]);
╭───────┬───────╮
│ Name  │ Label │
├───────┼───────┤
│ Tokyo │ 東京  │
├───────┼───────┤
│ Party │ 🎉🎉  │
╰───────┴───────╯

That block is the terminal render. Discord draws an emoji as an image sized to the line height, which is wider than two characters. Discord then draws that row's right border further right. Padding can't correct it, since the count is in columns and the client is placing pictures.

Put emoji in a line above or below the table. Keep the columns to text.

Leave trimming to maxWidth. Your own trim counts characters differently, so the widths come out wrong.

Filling and spacing

optiontypeholds
headerbooleantreats row 0 as a header, true by default
paddingnumberspaces on each side of every cell, 1 by default
emptyCellstringfills a missing or empty cell, '' by default
fencebooleanwraps the output in a code block, false by default

Your rows can differ in length. The longest row sets the column count, so emptyCell fills the gaps in every shorter row.

A newline inside a cell becomes a space, which keeps the frame from splitting across lines. Multi-line cells come from wrap.

Paging past the character limit

Discord caps a message at 2000 characters. Passing budget returns an array of pages, each one carrying its own header.

const pages = renderTable(rows, { budget: 2000, fence: true });
const pages: string[]

You get the array whenever the options object has a budget key, whatever its value. Passing budget: undefined still returns an array.

budget is a target. A single row longer than the whole budget still gets a page of its own, since a page smaller than one row cannot exist. With fence on, each page is measured with its fence included, so every page still fits.

Note

This paging splits a string by length. Pagination is the separate button-driven system for a message someone clicks through. Feed these pages into a paginator to get both.