Build a CSV to HTML Report Generator
A CLI that reads a CSV file, groups rows by a chosen column, and writes a self-contained HTML report with summary tables, totals, and escaped cell values.
Project goal
Build a tool that converts a CSV file into a standalone HTML report, grouping rows by a key column, computing numeric subtotals, and escaping all values so arbitrary data cannot inject markup.
Final features
- Parse CSV with quoted fields and a header row.
- Group rows by a chosen key column and compute numeric totals per group.
- Emit a self-contained HTML file with an inline stylesheet and summary tables.
- Escape every cell so CSV values cannot inject HTML.
- Report row and group counts to stdout after writing.
Architecture
A CSV parser produces row objects keyed by header; a grouper buckets rows and sums numeric columns; an HTML renderer builds a document from those buckets; and a CLI wires arguments to the pipeline. Rendering escapes values through one function so injection has a single choke point.
Prerequisites
- Node.js 20 LTS or later
- Understanding of CSV quoting rules
- Basic HTML structure knowledge
Dependencies
- node >=20 — File system access and the CLI entry point
- vitest ^2.1.0 — Parser, grouper, and escaping tests
Directory structure
csv-report/
├── src/
│ ├── csv.js
│ ├── group.js
│ ├── render.js
│ └── cli.js
├── test/
│ ├── csv.test.js
│ └── render.test.js
└── package.json
Phases
1. CSV parsing
Parse a CSV string into row objects using the header row, honouring quoted fields and doubled quotes.
Files: src/csv.js
export function parseCsv(text) {
const rows = splitRows(text);
const header = rows.shift();
return rows.filter((r) => r.length > 0).map((cells) => {
const row = {};
header.forEach((h, i) => { row[h] = cells[i] ?? ''; });
return row;
});
}
function splitRows(text) {
const rows = [];
let field = '';
let row = [];
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; }
else if (c !== '\r') field += c;
}
if (field.length || row.length) { row.push(field); rows.push(row); }
return rows;
}Expected behavior: A quoted field containing a comma stays one cell, doubled quotes collapse to one, and a short row fills missing columns with empty strings.
2. Grouping and totals
Bucket rows by a chosen column and accumulate counts and numeric subtotals per bucket.
Files: src/group.js
export function groupRows(rows, keyColumn, sumColumns = []) {
const groups = new Map();
for (const row of rows) {
const key = row[keyColumn] ?? '(blank)';
if (!groups.has(key)) groups.set(key, { key, count: 0, sums: {} });
const bucket = groups.get(key);
bucket.count += 1;
for (const col of sumColumns) {
bucket.sums[col] = (bucket.sums[col] ?? 0) + (Number(row[col]) || 0);
}
}
return [...groups.values()].sort((a, b) => b.count - a.count);
}Expected behavior: Rows bucket by the key column with correct counts, numeric columns sum exactly, and non-numeric input contributes zero.
3. HTML rendering and escaping
Build a self-contained HTML document and escape every interpolated value through one function.
Files: src/render.js
export function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export function renderReport(groups, { title = 'Report', sumColumns = [] } = {}) {
const head = ['Group', 'Count', ...sumColumns].map((h) => '<th>' + escapeHtml(h) + '</th>').join('');
const body = groups.map((g) => {
const cells = [g.key, g.count, ...sumColumns.map((c) => g.sums[c] ?? 0)]
.map((v) => '<td>' + escapeHtml(v) + '</td>').join('');
return '<tr>' + cells + '</tr>';
}).join('');
return '<!doctype html><html><head><meta charset="utf-8"><title>' + escapeHtml(title) +
'</title><style>table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}</style>' +
'</head><body><h1>' + escapeHtml(title) + '</h1><table><thead><tr>' + head +
'</tr></thead><tbody>' + body + '</tbody></table></body></html>';
}Expected behavior: A group key containing a script tag renders as escaped entities, and the output is a complete HTML document with one table.
4. CLI pipeline
Read the input file, choose the grouping column, write the HTML output, and print a short summary.
Files: src/cli.js
import { readFile, writeFile } from 'node:fs/promises';
import { parseCsv } from './csv.js';
import { groupRows } from './group.js';
import { renderReport } from './render.js';
const args = parse(process.argv.slice(2));
if (!args.input || !args.by) {
console.error('usage: report --input <csv> --by <column> [--out report.html]');
process.exit(1);
}
const rows = parseCsv(await readFile(args.input, 'utf8'));
const sumColumns = (args.sum ?? '').split(',').filter(Boolean);
const groups = groupRows(rows, args.by, sumColumns);
await writeFile(args.out ?? 'report.html', renderReport(groups, { title: args.title ?? 'Report', sumColumns }));
console.log('rows=' + rows.length + ' groups=' + groups.length);Expected behavior: A valid invocation writes report.html and prints row and group counts, and a missing --by exits with code 1 and a usage line.
5. Grouping selection and edge cases
Handle a missing key column, empty input, and a column with blank values without throwing.
Files: src/select.js
export function resolveColumns(header, requested) {
const missing = requested.filter((col) => !header.includes(col));
if (missing.length) return { ok: false, error: 'missing columns: ' + missing.join(', ') };
return { ok: true, columns: requested };
}
export function blankPolicy(rows, keyColumn) {
const blanks = rows.filter((r) => !r[keyColumn]).length;
return { blanks, label: blanks ? '(blank)' : null };
}Expected behavior: Requesting a column that does not exist returns an error listing it, and a dataset with blank keys groups them under a labelled bucket.
6. Tests and hardening
Cover parsing, grouping, and escaping, and add an end-to-end run over a temporary CSV.
Files: test/render.test.js
import { renderReport } from '../src/render.js';
test('cell values are escaped and cannot inject markup', () => {
const groups = [{ key: '<script>alert(1)</script>', count: 1, sums: { amount: 5 } }];
const html = renderReport(groups, { sumColumns: ['amount'] });
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
});Expected behavior: The escaping test passes, the end-to-end run produces a parseable HTML file, and an empty CSV renders an empty table without crashing.
Testing
Run npx vitest. CSV tests cover quoted fields, embedded commas and quotes, and rows with missing trailing columns. Grouping tests assert subtotals and ordering. Rendering tests feed a cell containing script tags and assert the output contains escaped entities and no raw markup.
Troubleshooting
A quoted field with a newline splits into two rows
Track the in-quote state across line breaks rather than splitting on newlines first; a stateful character scanner handles multi-line fields correctly.
Numeric subtotals show as zero
Coerce with Number and fall back to zero, and confirm the column name matches the header exactly; a case or whitespace mismatch produces zeros silently.
A CSV value breaks the report layout
Escape every interpolated cell through one function before it reaches the template; the single choke point is what makes the escaping auditable.
Deployment
Integrate into a build step to regenerate a dashboard page from an exported CSV, or distribute as a CLI for ad-hoc reporting. The output is self-contained, so it can be emailed or committed without an asset pipeline.
Known limitations
- Very large CSVs are read fully into memory; streaming would be needed for multi-gigabyte files.
- Numeric detection is naive and treats a numeric-looking identifier as a summable column if you ask it to.
Project overview
Reports are where raw data becomes something a person can read. This project takes a CSV and produces a grouped HTML summary, keeping the two hard parts — correct CSV parsing and safe HTML escaping — explicit and tested.
Grouping and totalling are pure functions over row objects, so the report can be regenerated with different grouping columns without re-reading the file, and the escaping function is exercised with hostile input.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
Grouping is a single reduce that keeps numeric subtotals per bucket, which is the whole summary algorithm:
export function groupRows(rows, keyColumn, sumColumns = []) {
const groups = new Map();
for (const row of rows) {
const key = row[keyColumn] ?? '(blank)';
if (!groups.has(key)) groups.set(key, { key, count: 0, sums: {} });
const bucket = groups.get(key);
bucket.count += 1;
for (const col of sumColumns) {
bucket.sums[col] = (bucket.sums[col] ?? 0) + (Number(row[col]) || 0);
}
}
return [...groups.values()].sort((a, b) => b.count - a.count);
}
Because total columns default to zero for non-numeric input, a mislabelled column shows up as a zero subtotal rather than a NaN breaking the render.
Setup
The project is dependency-free at runtime, which keeps focus on the parsing and escaping logic.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | File system access and the CLI entry point |
| vitest | ^2.1.0 | Parser, grouper, and escaping tests |
Create the project and install:
mkdir csv-report && cd csv-report
npm init -y
npm install -D vitest@^2.1.0
Configure the CLI and tests in package.json:
{
"name": "csv-report",
"type": "module",
"scripts": {
"report": "node src/cli.js",
"test": "vitest run"
}
}
Keep a small fixture CSV with a quoted field containing a comma so the parser test has a realistic case from day one.
Phase 1: CSV parsing
Parse a CSV string into row objects using the header row, honouring quoted fields and doubled quotes. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
export function parseCsv(text) {
const rows = splitRows(text);
const header = rows.shift();
return rows.filter((r) => r.length > 0).map((cells) => {
const row = {};
header.forEach((h, i) => { row[h] = cells[i] ?? ''; });
return row;
});
}
function splitRows(text) {
const rows = [];
let field = '';
let row = [];
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; }
else if (c !== '\r') field += c;
}
if (field.length || row.length) { row.push(field); rows.push(row); }
return rows;
}
Acceptance for this phase: A quoted field containing a comma stays one cell, doubled quotes collapse to one, and a short row fills missing columns with empty strings.
Phase 2: Grouping and totals
Bucket rows by a chosen column and accumulate counts and numeric subtotals per bucket. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function groupRows(rows, keyColumn, sumColumns = []) {
const groups = new Map();
for (const row of rows) {
const key = row[keyColumn] ?? '(blank)';
if (!groups.has(key)) groups.set(key, { key, count: 0, sums: {} });
const bucket = groups.get(key);
bucket.count += 1;
for (const col of sumColumns) {
bucket.sums[col] = (bucket.sums[col] ?? 0) + (Number(row[col]) || 0);
}
}
return [...groups.values()].sort((a, b) => b.count - a.count);
}
Acceptance for this phase: Rows bucket by the key column with correct counts, numeric columns sum exactly, and non-numeric input contributes zero.
Phase 3: HTML rendering and escaping
Build a self-contained HTML document and escape every interpolated value through one function. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
export function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export function renderReport(groups, { title = 'Report', sumColumns = [] } = {}) {
const head = ['Group', 'Count', ...sumColumns].map((h) => '<th>' + escapeHtml(h) + '</th>').join('');
const body = groups.map((g) => {
const cells = [g.key, g.count, ...sumColumns.map((c) => g.sums[c] ?? 0)]
.map((v) => '<td>' + escapeHtml(v) + '</td>').join('');
return '<tr>' + cells + '</tr>';
}).join('');
return '<!doctype html><html><head><meta charset="utf-8"><title>' + escapeHtml(title) +
'</title><style>table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}</style>' +
'</head><body><h1>' + escapeHtml(title) + '</h1><table><thead><tr>' + head +
'</tr></thead><tbody>' + body + '</tbody></table></body></html>';
}
Acceptance for this phase: A group key containing a script tag renders as escaped entities, and the output is a complete HTML document with one table.
Phase 4: CLI pipeline
Read the input file, choose the grouping column, write the HTML output, and print a short summary. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { readFile, writeFile } from 'node:fs/promises';
import { parseCsv } from './csv.js';
import { groupRows } from './group.js';
import { renderReport } from './render.js';
const args = parse(process.argv.slice(2));
if (!args.input || !args.by) {
console.error('usage: report --input <csv> --by <column> [--out report.html]');
process.exit(1);
}
const rows = parseCsv(await readFile(args.input, 'utf8'));
const sumColumns = (args.sum ?? '').split(',').filter(Boolean);
const groups = groupRows(rows, args.by, sumColumns);
await writeFile(args.out ?? 'report.html', renderReport(groups, { title: args.title ?? 'Report', sumColumns }));
console.log('rows=' + rows.length + ' groups=' + groups.length);
Acceptance for this phase: A valid invocation writes report.html and prints row and group counts, and a missing –by exits with code 1 and a usage line.
Phase 5: Grouping selection and edge cases
Handle a missing key column, empty input, and a column with blank values without throwing. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
export function resolveColumns(header, requested) {
const missing = requested.filter((col) => !header.includes(col));
if (missing.length) return { ok: false, error: 'missing columns: ' + missing.join(', ') };
return { ok: true, columns: requested };
}
export function blankPolicy(rows, keyColumn) {
const blanks = rows.filter((r) => !r[keyColumn]).length;
return { blanks, label: blanks ? '(blank)' : null };
}
Acceptance for this phase: Requesting a column that does not exist returns an error listing it, and a dataset with blank keys groups them under a labelled bucket.
Phase 6: Tests and hardening
Cover parsing, grouping, and escaping, and add an end-to-end run over a temporary CSV. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { renderReport } from '../src/render.js';
test('cell values are escaped and cannot inject markup', () => {
const groups = [{ key: '<script>alert(1)</script>', count: 1, sums: { amount: 5 } }];
const html = renderReport(groups, { sumColumns: ['amount'] });
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
});
Acceptance for this phase: The escaping test passes, the end-to-end run produces a parseable HTML file, and an empty CSV renders an empty table without crashing.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. CSV tests cover quoted fields, embedded commas and quotes, and rows with missing trailing columns. Grouping tests assert subtotals and ordering. Rendering tests feed a cell containing script tags and assert the output contains escaped entities and no raw markup.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The injection test is non-negotiable: any tool that renders user data into HTML must prove that a value cannot become an executable tag.
Extending the project
Additional chart output, per-group detail tables, and a JSON export are new renderers over the same grouped data. Choosing the grouping column from the header means the pipeline is already generic over the dataset.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.