I built a Markdown table generator this week and my first instinct was to reach for a Markdown library. Then I actually looked at what a GitHub-Flavored table is — three kinds of line, cells fenced by pipes — and realized the whole engine is a plain data model plus a handful of pure functions. Maybe 70 lines, no dependencies. You fill an editable grid, set per-column alignment, paste CSV/TSV to auto-fill, and it emits properly padded Markdown with a live rendered preview. Here's how it fits together.
The data model is three flat arrays
Everything is driven by state you already understand — headers, rows, and one alignment per column:
const STATE = {
headers: ["Feature", "Free", "Pro"],
aligns: ["left", "center", "right"], // one per column
rows: [["Projects","3","Unlimited"], ["Storage","1 GB","100 GB"]],
};
Keeping the model this flat is what makes add-row, add-column, alignment and CSV-load each a one-line mutation. The grid only ever reads and writes this object; the emitter turns it into text.
Escape first, or user data breaks the row
Two characters structurally matter in a table: a pipe and a newline. A | inside cell text would start a new column and shift everything after it; a raw line-break would end the row early. So every value gets escaped before it's placed:
function escapeCell(v){
return String(v ?? "")
.replace(/\|/g, "\\|") // pipe → \| (would start a new column)
.replace(/\r?\n|\r/g, " "); // newline → space (a raw break ends the row)
}
This one function is the difference between a tool that works on real data and one that only survives tidy demos.
Colons carry the alignment — nothing else does
The separator row is the line that actually makes it a table (a header alone is just a paragraph), and its colons are the only place alignment lives:
function sep(w, align){
if (align === "left") return ":" + "-".repeat(w - 1); // :----
if (align === "right") return "-".repeat(w - 1) + ":"; // ----:
if (align === "center") return ":" + "-".repeat(w - 2) + ":"; // :--:
return "-".repeat(w); // ---- (default)
}
Aligning your source spaces does nothing to the rendered output — only these colons move the content. GitHub even reads the column count off this row, not the header.
Pad for humans; the renderer ignores it
To measure widths I walk each column and take the longest escaped string (header included), with a floor of 3 so --- always fits. Then I pad each cell on the side that matches its alignment. The thing to internalize: padding is purely cosmetic — every renderer trims leading/trailing whitespace, so a padded table and a crammed one render identically. It's there so the raw source lines up in a diff, which is why the tool offers it as a toggle. Assembling is then just glue: escape everything, measure, and emit the header row, the separator, and one line per data row joined with " | " and fenced by outer pipes.
The bonus: CSV paste is a real parser, and the preview is a self-test
You can't split(",") a CSV — a field can be quoted and legally contain commas, newlines and doubled "" quotes. So the paste handler is a tiny character-by-character state machine with one boolean, "are we inside quotes?", exactly how real CSV libraries work under the hood. Detect the delimiter first (a tab beats a comma → it's TSV), square up ragged rows, and the first row becomes the header.
The touch I'm most pleased with is the preview: instead of rendering the grid a second time, I build it by parsing the emitted Markdown back into a real <table>. If that round-trips cleanly, the emitter is provably correct — the preview is a live unit test I can see. The lesson underneath the whole thing: reach for a data model plus small pure functions before you reach for a dependency.
Fill the grid, set alignment, paste some CSV and watch the Markdown emit:
https://dev48v.infy.uk/solve/day46-markdown-table-generator.html
Top comments (0)