Public: anyone can read this; the crew can change it.

Files render in the browser: CSV/TSV as a real table — sticky header, sort, filter, row count #7388

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7334-files-render-in-the-browser-csv-tsv-as-a → main 11 commits fork 5 threads · 5 unresolved

  • Merged. scout/7334-files-render-in-the-browser-csv-tsv-as-a was merged into main as 6cc5f85.

Review

Sign in to review this change.
9 files changed +523 −4
README.md +1
@@ -426,6 +426,7 @@ This page is the front door. Everything else is a file of its own under
426426 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload.
427427 - [`docs/PULLS.md`](docs/PULLS.md) — resolving a conflicted pull without a clone.
428428 - [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories.
429 - [`docs/features/csv-view.md`](docs/features/csv-view.md) — open a `.csv` or `.tsv` in the code view to read it as a table; `?all=1` draws more rows, `?view=source` shows the text. The terminal has its own table view for them (see the `.csv` paragraph above).
outdated

The terminal now shows CSVs as a table too (#7401, README line 80). Remove "The terminal still shows CSVs as source." and point at that section.

429430 - [`docs/features/file-media.md`](docs/features/file-media.md) — a file in the browser: every file page has **Raw** and **Download** (any size), and `/:owner/:name/media/:ref/*path` serves images, audio, video and PDFs under their real type, sandboxed, with byte ranges.
430431 - [`docs/ROLES.md`](docs/ROLES.md) — guest, member and admin, and what each may do.
431432 - [`docs/MCP.md`](docs/MCP.md) — the forge as a connector; [`docs/STANDARDS.md`](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest.
docs/design/areas/csv.md added +6
@@ -0,0 +1,6 @@
1 # CSV view
2
3 New classes, all in `src/web/style/content.ts`, drawn from existing tokens:
4 `.csv-view` (the card around a table), `.csv-tools` (filter row, shown only when script runs),
5 `.csv-scroll` (the scrolling box that holds the sticky header), `.csv-sort` (the real button inside each sortable header), `.csv-more` (the "Showing N of M rows" line).
6 The table itself is the existing `.table` with `data-dense` and `data-sticky-lead`.
docs/features/csv-view.md added +8
@@ -0,0 +1,8 @@
1 # CSV and TSV as a table
2
3 `.csv`/`.tsv` files open as a table on the blob page (`src/web/render/csv.ts`, parser in `csv-parse.ts`).
4 RFC 4180 with quotes, embedded newlines, doubled quotes and a BOM; the delimiter (`,` `;` tab `|`) is detected from the first record.
5 The first 1,000 rows are drawn on the server with a sticky header and (on phones) a sticky first column; numeric columns are right-aligned.
6 `?all=1` ("Show all N rows", no JavaScript needed) draws up to 20,000. The header line shows rows × columns and the delimiter.
7 A small script adds sort (header click or Enter) and a filter box over the rows drawn; no library.
8 A file that is not valid CSV shows the source with a one-line reason. `?view=source`, Raw and Download work as for any file.
src/web/pages/repo.ts +19 −4
@@ -71,6 +71,7 @@ import { field, readForm } from "../form.ts";
7171 import { html, when, type Html } from "../html.ts";
7272 import { flashTo, fragment, page, redirect } from "../layout.ts";
7373 import { renderMarkdown } from "../markdown.ts";
74 import { csvJs, isCsvPath } from "../render/csv.ts";
7475 import { HEAD_BYTES } from "../render/kinds.ts";
7576 import { rendererFor } from "../render/renderers.ts";
7677 import { mediaHref } from "./media.ts";
@@ -1027,14 +1028,21 @@ export function renderBlob(
10271028 // a kind with no renderer yet, or one over its cap, keeps the source view.
10281029 const renderer = rendererFor(o.path, blob);
10291030 const view: BlobView = renderer ? (o.view ?? "rendered") : "source";
1030 const { content, lines, summary } =
1031 const drawn =
10311032 view === "rendered" && renderer
10321033 ? renderer.render({
10331034 path: o.path,
10341035 size: blob.size,
10351036 body: renderer.body,
10361037 media: mediaHref(base, o.ref, o.path),
1038 pathHref: `${base}/blob/${encoded}/${o.path}`,
1039 all: o.all === true,
10371040 })
1041 : undefined;
1042 const fellBack = drawn?.fallback;
1043 const { content, lines, summary } =
1044 drawn !== undefined && fellBack === undefined
1045 ? drawn
10381046 : { ...blobBody(base, o.ref, o.path, blob, o.highlighted, o.all), summary: undefined };
10391047 const sizeText =
10401048 blob.kind === "too_large" ? "" : sizeWithTokens(blob.size, o.path);
@@ -1066,12 +1074,12 @@ export function renderBlob(
10661074 {
10671075 label: "Rendered",
10681076 href: fileHref,
1069 current: view === "rendered",
1077 current: view === "rendered" && fellBack === undefined,
10701078 },
10711079 {
10721080 label: "Source",
10731081 href: `${fileHref}?view=source`,
1074 current: view === "source",
1082 current: view === "source" || fellBack !== undefined,
10751083 },
10761084 ])}</span
10771085 >`,
@@ -1095,6 +1103,10 @@ export function renderBlob(
10951103 })}
10961104 ${editAction(scope, o.ref, o.path)}
10971105 </div>
1106 ${when(
1107 fellBack !== undefined,
1108 () => html`<p class="muted small mb">Showing the source: ${fellBack}</p>`,
1109 )}
10981110 ${content}
10991111 </div>`;
11001112 }
@@ -1350,6 +1362,8 @@ export function codePage(
13501362 content: Html;
13511363 header?: Html;
13521364 before?: Html;
1365 /** Script only some files need (the CSV table's sort and filter). */
1366 extraJs?: string;
13531367 },
13541368 ): Response {
13551369 return page({
@@ -1360,7 +1374,7 @@ export function codePage(
13601374 body: html`${o.header ?? scopeHeader(ctx, scope, "code", { compact: true })}
13611375 ${o.before ?? null} ${codeLayout(o.explorer, o.content)}
13621376 ${codeMount(scope, o.ref)} ${finderDialog()}`,
1363 scriptJs: codeJs,
1377 scriptJs: o.extraJs === undefined ? codeJs : `${codeJs}\n${o.extraJs}`,
13641378 wide: true,
13651379 });
13661380 }
@@ -1487,6 +1501,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
14871501 ),
14881502 }),
14891503 content: pane,
1504 ...(isCsvPath(path) ? { extraJs: csvJs } : {}),
14901505 });
14911506 notePhase(ctx.request, "render", paneMs + performance.now() - pageStart);
14921507 return response;
src/web/render/csv-parse.ts added +148
@@ -0,0 +1,148 @@
1 /**
2 * RFC 4180 as plain data: text in, rows out. Quoted fields, embedded
3 * newlines, doubled quotes, a leading BOM, CRLF or LF. The delimiter is
4 * detected from the first record. A file that cannot be read as a table says
5 * why in one line instead of guessing.
6 */
7
8 export const DELIMITERS = [",", ";", "\t", "|"] as const;
9 export type Delimiter = (typeof DELIMITERS)[number];
10
11 export interface Table {
12 readonly delimiter: Delimiter;
13 /** The first `rows` records, each cut to `columns` fields. */
14 readonly rows: string[][];
15 /** The widest record in the whole file, kept or not. */
16 readonly columns: number;
17 /** Every record in the file, the header included. */
18 readonly records: number;
19 }
20
21 export type Parsed = Table | { readonly error: string };
22
23 /** What to keep. The rest of the file is still scanned, only counted. */
24 export interface Limits {
25 /** Records kept whole (default: all). */
26 readonly rows?: number;
27 /** Fields kept per record (default: all). */
28 readonly columns?: number;
29 readonly delimiter?: Delimiter;
30 }
31
32 /** Each candidate delimiter's count in the first record, quotes respected. */
33 export function detectDelimiter(text: string): Delimiter {
34 const counts = new Map<string, number>();
35 let quoted = false;
36 for (let i = 0; i < text.length; i++) {
37 const c = text[i]!;
38 if (c === '"') quoted = !quoted;
39 else if (!quoted && (c === "\n" || c === "\r")) break;
40 else if (!quoted) counts.set(c, (counts.get(c) ?? 0) + 1);
41 }
42 let best: Delimiter = ",";
outdated

MEDIUM: this builds every field of a 10 MB file on every page view, about 400 ms for 400k rows × 5 columns. Accept a row limit: keep fields for the first N records and only count the rest.

43 let most = 0;
44 for (const d of DELIMITERS) {
45 const n = counts.get(d) ?? 0;
46 if (n > most) {
47 best = d;
48 most = n;
49 }
50 }
51 return best;
52 }
53
54 export function parseCsv(input: string, limits: Limits = {}): Parsed {
55 const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
56 const delimiter = limits.delimiter ?? detectDelimiter(text);
57 const keepRows = limits.rows ?? Infinity;
58 const keepCols = limits.columns ?? Infinity;
59 const rows: string[][] = [];
60 let row: string[] = [];
61 let fields = 0;
62 let records = 0;
63 let columns = 0;
64 let i = 0;
65 const n = text.length;
66 const keeping = (): boolean => records < keepRows;
67 const addField = (value: string): void => {
68 if (keeping() && fields < keepCols) row.push(value);
69 fields++;
70 };
71 const endRow = (): void => {
72 if (fields > columns) columns = fields;
73 if (keeping()) rows.push(row);
74 records++;
75 row = [];
76 fields = 0;
77 };
78 let field = "";
79 while (i < n) {
80 const c = text[i]!;
81 if (c === '"' && field === "") {
82 // A quoted field runs to its closing quote; "" inside is one quote.
83 const wanted = keeping() && fields < keepCols;
84 i++;
85 for (;;) {
86 const close = text.indexOf('"', i);
87 if (close === -1) return { error: "A quoted field is never closed." };
88 if (wanted) field += text.slice(i, close);
89 if (text[close + 1] === '"') {
90 if (wanted) field += '"';
91 i = close + 2;
92 continue;
93 }
94 i = close + 1;
95 break;
96 }
97 const after = text[i];
98 if (after !== undefined && after !== delimiter && after !== "\n" && after !== "\r")
99 return { error: `Text follows a closing quote on row ${records + 1}.` };
100 } else if (c === delimiter) {
101 addField(field);
102 field = "";
103 i++;
104 } else if (c === "\n" || c === "\r") {
105 addField(field);
106 field = "";
107 endRow();
108 i += c === "\r" && text[i + 1] === "\n" ? 2 : 1;
109 } else {
110 // Unquoted run up to the next special character.
111 let j = i + 1;
112 while (j < n) {
113 const d = text[j]!;
114 if (d === delimiter || d === "\n" || d === "\r") break;
115 j++;
116 }
117 if (keeping() && fields < keepCols) field += text.slice(i, j);
118 i = j;
119 }
120 }
121 // A final record with no newline; a trailing newline adds none.
122 if (n > 0 && text[n - 1] !== "\n" && text[n - 1] !== "\r") {
123 addField(field);
124 endRow();
125 }
126 if (records === 0 || columns === 0) return { error: "The file has no rows." };
127 return { delimiter, rows, columns, records };
128 }
129
130 const NUMBER = /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i;
131
132 /** True for each column whose every non-empty cell, header aside, is a number. */
133 export function numericColumns(table: Table, columns = table.columns): boolean[] {
134 const out: boolean[] = [];
135 const end = table.rows.length;
136 for (let c = 0; c < Math.min(columns, table.columns); c++) {
137 let seen = 0;
138 let ok = true;
139 for (let r = 1; r < end && ok; r++) {
140 const cell = (table.rows[r]![c] ?? "").trim();
141 if (cell === "") continue;
142 seen++;
143 ok = NUMBER.test(cell);
144 }
145 out.push(ok && seen > 0);
146 }
147 return out;
148 }
src/web/render/csv.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * A CSV or TSV file as a table: the first thousand rows drawn on the server,
3 * a sticky header, numbers right-aligned. Without JavaScript `?all=1` draws
4 * more, up to a cap; the script below adds sort and filter and nothing else.
5 */
6
7 import { escapeHtml, html, raw } from "../html.ts";
8 import { fileSize } from "../ui.ts";
9 import { numericColumns, parseCsv, type Delimiter } from "./csv-parse.ts";
10 import type { Rendered, RenderInput } from "./renderers.ts";
11
12 /** Rows drawn by default, and by `?all=1` at most. */
13 export const ROW_WINDOW = 1_000;
14 export const ROW_CAP = 20_000;
15 /** Columns drawn, and cells (rows × columns) drawn, whatever the file holds. */
16 export const COLUMN_CAP = 200;
17 export const CELL_CAP = 200_000;
18
19 const NAMES: Record<Delimiter, string> = {
20 ",": "comma",
21 ";": "semicolon",
22 "\t": "tab",
23 "|": "pipe",
24 };
25
26 const cells = (tag: "th" | "td", row: string[], width: number, numeric: boolean[]): string => {
27 let out = "";
28 for (let c = 0; c < width; c++) {
29 const v = row[c] ?? "";
30 const cls = numeric[c] ? ' class="num"' : "";
31 out += `<${tag}${cls}>${escapeHtml(v)}</${tag}>`;
32 }
33 return out;
34 };
35
36 export function csv(input: RenderInput): Rendered {
37 const want = input.all ? ROW_CAP : ROW_WINDOW;
outdated

HIGH (same cause): numericColumns(parsed) loops columns × all rows. With a wide row and many short rows it does billions of iterations. Pass the drawn row limit and the drawn column cap.

38 const parsed = parseCsv(new TextDecoder().decode(input.body), {
39 rows: want + 1,

Non-blocking: for a wide file with ?all=1, the parser keeps 20,001 rows × 200 fields, but CELL_CAP only draws 1,000 of those rows. That is about 4M field strings built and then thrown away: an 8 MB file of 20k × 200 parses in about 250 ms instead of about 170 ms. To fix it, give parseCsv a cell budget: stop keeping rows once kept rows × the widest kept row reaches CELL_CAP, and only count after that.

40 columns: COLUMN_CAP,
41 });
42 if ("error" in parsed) return { content: html``, lines: 0, fallback: parsed.error };
43 const total = parsed.records - 1;
outdated

HIGH: every drawn row is padded to parsed.columns, the widest row anywhere in the file. One row of 100k commas with 1,000 short rows (102 KB) gives 900 MB of HTML and blocks for 27 s. At the 10 MB cap it runs out of memory. Cap the drawn columns and the total cells drawn, and say so in the .csv-more line.

44 const width = Math.min(parsed.columns, COLUMN_CAP);
45 const numeric = numericColumns(parsed, width);
46 // A wide table shrinks its row window so the page stays bounded.
47 const shown = Math.min(total, want, Math.floor(CELL_CAP / width));
48 const [head, ...rest] = parsed.rows;
49 let body = "";
50 for (let r = 0; r < shown; r++) body += `<tr>${cells("td", rest[r]!, width, numeric)}</tr>`;
51 const href = input.pathHref ?? "";
52 const wide =
53 parsed.columns > width
54 ? `Showing ${width.toLocaleString()} of ${parsed.columns.toLocaleString()} columns. `
55 : "";
56 const more =
57 shown >= total && wide === ""
58 ? ""
59 : `<p class="muted small csv-more">${wide}${
60 shown >= total ? "" : `Showing ${shown.toLocaleString()} of ${total.toLocaleString()} rows. `
61 }${
62 shown >= total
63 ? ""
64 : !input.all && shown === Math.min(total, want)
65 ? `<a id="csv-all" href="${escapeHtml(href)}?all=1">Show all ${total.toLocaleString()} rows</a>${
66 total > ROW_CAP ? ` (the page draws at most ${ROW_CAP.toLocaleString()})` : ""
67 }`
68 : "The rest is in the raw file."
69 }</p>`;
70 const content = html`<div class="card csv-view" data-csv>
71 <div class="csv-tools" hidden>
72 <input class="input" type="search" data-csv-filter placeholder="Filter rows" aria-label="Filter rows" autocomplete="off" />
73 <span class="muted small num" data-csv-count aria-live="polite"></span>
74 </div>
75 <div class="csv-scroll">
76 <table class="table" data-dense data-sticky-lead aria-label="${input.path.slice(input.path.lastIndexOf("/") + 1)} data">
77 <thead><tr>${raw(cells("th", head!, width, numeric))}</tr></thead>
78 <tbody>${raw(body)}</tbody>
79 </table>
80 </div>
81 ${raw(more)}
82 </div>`;
83 return {
84 content,
85 lines: 0,
86 summary: `${total.toLocaleString()} rows × ${parsed.columns.toLocaleString()} columns · ${NAMES[parsed.delimiter]}-separated · ${fileSize(input.size)}`,
87 };
88 }
89
90 /** Whether a path can hold a table, so only those pages carry the script below. */
91 export const isCsvPath = (path: string): boolean => /\.(csv|tsv)$/i.test(path);
92
93 /** Sort on a header click, filter on typing. Trusted static code. */
94 export const csvJs = /* js */ `
95 (function () {
96 var doc = document;
97 function setup(box) {
98 if (box.__csv) return;
99 box.__csv = 1;
100 var tools = box.querySelector('.csv-tools');
101 if (tools) tools.hidden = false;
102 var heads = box.querySelectorAll('thead th');
103 for (var i = 0; i < heads.length; i++) {
104 var th = heads[i];
105 var btn = doc.createElement('button');
106 btn.type = 'button';
107 btn.className = 'csv-sort';
108 while (th.firstChild) btn.appendChild(th.firstChild);
109 th.appendChild(btn);
110 th.setAttribute('aria-sort', 'none');
111 }
112 }
113 function sort(th) {
114 var box = th.closest('[data-csv]');
115 var tbody = box.querySelector('tbody');
116 var idx = Array.prototype.indexOf.call(th.parentNode.children, th);
117 var num = th.classList.contains('num');
118 var dir = th.getAttribute('aria-sort') === 'ascending' ? -1 : 1;
119 var all = th.parentNode.children;
120 for (var k = 0; k < all.length; k++) all[k].setAttribute('aria-sort', 'none');
121 th.setAttribute('aria-sort', dir === 1 ? 'ascending' : 'descending');
122 var rows = Array.prototype.slice.call(tbody.rows);
123 var keys = rows.map(function (r) {
124 var t = r.cells[idx] ? r.cells[idx].textContent : '';
125 return num ? (t.trim() === '' ? null : parseFloat(t)) : t.toLowerCase();
126 });
127 var order = rows.map(function (_, j) { return j; });
128 order.sort(function (a, b) {
129 var x = keys[a], y = keys[b];
130 if (x === null || y === null) return x === y ? a - b : x === null ? 1 : -1;
131 return (x < y ? -1 : x > y ? 1 : a - b) * dir;
132 });
133 var frag = doc.createDocumentFragment();
134 for (var m = 0; m < order.length; m++) frag.appendChild(rows[order[m]]);
135 tbody.appendChild(frag);
136 }
137 function filter(input) {
138 var box = input.closest('[data-csv]');
139 var q = input.value.toLowerCase();
140 var rows = box.querySelector('tbody').rows;
141 var shown = 0;
142 for (var i = 0; i < rows.length; i++) {
143 var hit = q === '' || rows[i].textContent.toLowerCase().indexOf(q) !== -1;
144 rows[i].hidden = !hit;
145 if (hit) shown++;
146 }
147 var out = box.querySelector('[data-csv-count]');
148 if (out) out.textContent = q === '' ? '' : shown + ' of ' + rows.length + ' rows';
149 }
150 function scan() {
151 var boxes = doc.querySelectorAll('[data-csv]');
152 for (var i = 0; i < boxes.length; i++) setup(boxes[i]);
153 }
154 doc.addEventListener('click', function (e) {
155 var b = e.target.closest && e.target.closest('[data-csv] .csv-sort');
156 if (b) sort(b.parentNode);
157 });
158 doc.addEventListener('input', function (e) {
159 if (e.target.matches && e.target.matches('[data-csv-filter]')) filter(e.target);
160 });
161 scan();
162 // The code view swaps its pane in place and says so.
163 doc.addEventListener('code:swap', scan);
164 })();
165 `;
src/web/render/renderers.ts +8
@@ -7,6 +7,7 @@
77 import type { Html } from "../html.ts";
88 import type { Blob } from "../../view/source.ts";
99 import { audio } from "./audio.ts";
10 import { csv } from "./csv.ts";
1011 import { image } from "./image.ts";
1112 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1213 import { markdown } from "./markdown.ts";
@@ -23,6 +24,10 @@ export interface RenderInput {
2324 readonly body: Uint8Array;
2425 /** `/:owner/:name/media/:ref/*path`, for `<img>`, `<video>` and `fetch()`. */
2526 readonly media: string;
27 /** The file page's own address, for a link back to it (`?all=1`). */
28 readonly pathHref?: string;
29 /** `?all=1`: draw past the default window, up to the renderer's cap. */
30 readonly all?: boolean;
2631 }
2732
2833 export interface Rendered {
@@ -31,6 +36,8 @@ export interface Rendered {
3136 readonly lines: number;
3237 /** The header's facts in place of the line count, as `1.2 MB · WebM`. */
3338 readonly summary?: string;
39 /** Set when the file cannot be drawn: the source view shows, with this reason. */
40 readonly fallback?: string;
3441 }
3542
3643 export type Renderer = (input: RenderInput) => Rendered;
@@ -39,6 +46,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
3946 image,
4047 svg: image,
4148 markdown,
49 csv,
4250 audio,
4351 video,
4452 };
src/web/style/content.ts +18
@@ -247,6 +247,24 @@ form.inline { display: inline; margin-left: auto; }
247247 }
248248 .code tr:target .ln, .code tr[data-hl] .ln { color: var(--attn); }
249249
250 /* ---- csv view: the header stays under the bar, the first column on phones ---- */
251 .csv-tools { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
252 .csv-tools[hidden], .csv-view tr[hidden] { display: none; }
253 .csv-tools .input { max-width: 260px; }
254 .csv-scroll { overflow: auto; max-height: 75vh; scrollbar-width: thin; }
255 .csv-view th { position: sticky; top: 0; z-index: 2; background: var(--card); user-select: none; }
256 .csv-sort {
257 font: inherit; color: inherit; background: none; border: 0; padding: 0;
258 text-align: inherit; cursor: pointer;
259 }
260 .csv-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
261 .csv-view th:first-child { z-index: 3; }
262 .csv-view td { max-width: 40ch; overflow-wrap: anywhere; }
263 .csv-view th[aria-sort="ascending"]::after { content: " \\25B2"; font-size: 12.5px; }
264 .csv-view th[aria-sort="descending"]::after { content: " \\25BC"; font-size: 12.5px; }
265 .csv-view .num { text-align: right; }
266 .csv-more { padding: 10px 12px; margin: 0; }
267
250268 /* ---- syntax tokens (colors come from the active theme) ----
251269 Two layers, exactly as the tokenizer emits them: a colour class every
252270 consumer understands, and — where the web can say something finer — a kind
test/render-csv.test.ts added +150
@@ -0,0 +1,150 @@
1 /** CSV/TSV as a table: the parser, the shape of the page, and the budget. */
2 import { describe, expect, test } from "bun:test";
3 import { parseCsv, numericColumns, type Table } from "../src/web/render/csv-parse.ts";
4 import { COLUMN_CAP, csv, csvJs, isCsvPath, ROW_CAP } from "../src/web/render/csv.ts";
5 import { fileKind } from "../src/web/render/kinds.ts";
6 import { renderBlob } from "../src/web/pages/repo.ts";
7 import type { RepoScope } from "../src/web/scope.ts";
8 import { RENDERERS } from "../src/web/render/renderers.ts";
9
10 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
11 const ok = (s: string) => {
12 const t = parseCsv(s);
13 if ("error" in t) throw new Error(t.error);
14 return t;
15 };
16 const draw = (text: string, all = false) =>
17 csv({ path: "d.csv", size: text.length, body: enc(text), media: "/m", pathHref: "/o/r/blob/main/d.csv", all });
18
19 describe("csv parser", () => {
20 test("quotes, escaped quotes and newline inside a field", () => {
21 const t = ok('a,b\r\n"x, y","say ""hi"""\r\n"line1\nline2",z\r\n');
22 expect(t.rows).toEqual([["a", "b"], ["x, y", 'say "hi"'], ["line1\nline2", "z"]]);
23 });
24 test("a BOM is dropped and the last row needs no newline", () => {
25 expect(ok("a,b\n1,2").rows).toEqual([["a", "b"], ["1", "2"]]);
26 });
27 test("delimiters: semicolon, tab, pipe, and a comma inside quotes", () => {
28 expect(ok("a;b\n1;2\n").delimiter).toBe(";");
29 expect(ok("a\tb\n1\t2\n").delimiter).toBe("\t");
30 expect(ok("a|b|c\n1|2|3\n").delimiter).toBe("|");
31 expect(ok('"a,b,c";d\n1;2\n').delimiter).toBe(";");
32 });
33 test("malformed input says why", () => {
34 expect(parseCsv('a,b\n"open,1\n')).toEqual({ error: "A quoted field is never closed." });
35 expect("error" in parseCsv('a,"b"x\n')).toBe(true);
36 expect("error" in parseCsv("")).toBe(true);
37 });
38 test("a numeric column is detected, a mixed one is not", () => {
39 const t = ok("n,s,m\n1,a,1\n2.5,b,x\n-3e2,c,\n") as Table;
40 expect(numericColumns(t)).toEqual([true, false, false]);
41 });
42 });
43
44 describe("csv bounds", () => {
45 test("one very wide row cannot multiply the page", () => {
46 const text = "h\n" + "x\n".repeat(1_000) + ",".repeat(100_000) + "\n";
47 const r = csv({ path: "w.csv", size: text.length, body: enc(text), media: "/m", pathHref: "/p", all: false });
48 const h = String(r.content);
49 expect(h.length).toBeLessThan(2_000_000);
50 expect(h).toContain(`Showing ${COLUMN_CAP} of 100,001 columns`);
51 expect(r.summary).toContain("100,001 columns");
52 });
53 test("the parser keeps only the window's rows but counts them all", () => {
54 const text = "a,b\n" + "1,2\n".repeat(5_000);
55 const t = ok(text);
56 expect(t.records).toBe(5_001);
57 const cut = parseCsv(text, { rows: 11, columns: 1 });
58 if ("error" in cut) throw new Error(cut.error);
59 expect(cut.rows.length).toBe(11);
60 expect(cut.rows[3]).toEqual(["1"]);
61 expect(cut.records).toBe(5_001);
62 expect(cut.columns).toBe(2);
63 });
64 test("the tail is still checked for an unclosed quote", () => {
65 expect("error" in parseCsv("a\n1\n2\n\"x", { rows: 1 })).toBe(true);
66 });
67 });
68
69 describe("csv page", () => {
70 test("shape: header, counts, delimiter, right-aligned numbers, escaping", () => {
71 const r = draw("name,n\n<b>,1\nc,22\n");
72 const h = String(r.content);
73 expect(r.summary).toBe("2 rows × 2 columns · comma-separated · 18 B");
74 expect(h).toContain("<thead><tr><th>name</th><th class=\"num\">n</th>");
75 expect(h).toContain("<td>&lt;b&gt;</td>");
76 expect(h).toContain('<td class="num">22</td>');
77 expect(h).toContain("data-csv-filter");
78 expect(h).not.toContain("csv-all");
79 });
80 test("50k rows: the first 1,000 drawn, quickly, with a no-JS link", () => {
81 const text = "id,v\n" + Array.from({ length: 50_000 }, (_, i) => `${i},row ${i}`).join("\n");
82 const h = String(draw(text).content);
83 expect(h.split("<tr>").length - 1).toBe(1_001);
84 expect(h).toContain("Show all 50,000 rows");
85 expect(h).toContain('href="/o/r/blob/main/d.csv?all=1"');
86 const all = String(draw(text, true).content);
87 expect(all.split("<tr>").length - 1).toBe(ROW_CAP + 1);
88 expect(all).not.toContain("Show all");
89 });
90 test("a malformed file falls back with a reason", () => {
91 expect(draw('a,b\n"x').fallback).toBe("A quoted field is never closed.");
92 });
93 test("registered, and the script sorts and filters", () => {
94 expect(fileKind("x.tsv", enc("a\tb\n"))).toBe("csv");
95 expect(RENDERERS.csv).toBe(csv);
96 expect(csvJs).toContain("aria-sort");
97 expect(csvJs).toContain("csv-sort");
98 expect(csvJs).not.toContain("MutationObserver");
99 expect(csvJs).not.toContain("style.cursor");
100 expect(csvJs).toContain("data-csv-filter");
101 });
102 });
103
104 describe("csv on the blob page", () => {
105 const scope = { base: "/o/r", facts: { name: "r" }, caps: { write: false }, kind: "local" } as unknown as RepoScope;
106 const page = (name: string, text: string, extra: { view?: "source"; all?: boolean } = {}) => {
107 const body = enc(text);
108 return String(
109 renderBlob(scope, {
110 refs: { branches: ["main"], tags: [] },
111 ref: "main",
112 path: name,
113 blob: { kind: "text", body, size: body.length },
114 ...extra,
115 }),
116 );
117 };
118
119 test("a CSV shows the summary and the table, Rendered current", () => {
120 const h = page("data.csv", "a,b\n1,2\n3,4\n");
121 expect(h).toContain("2 rows × 2 columns · comma-separated");
122 expect(h).toContain("data-csv");
123 expect(h).toContain('aria-label="data.csv data"');
124 expect(h).toMatch(/<[^>]*aria-current[^>]*>\s*Rendered/);
125 expect(h).not.toMatch(/<[^>]*aria-current[^>]*>\s*Source/);
126 });
127 test("a malformed CSV shows the reason and the source, Source current", () => {
128 const h = page("bad.csv", 'a,b\n"x');
129 expect(h).toContain("Showing the source: A quoted field is never closed.");
130 expect(h).not.toContain("data-csv");
131 expect(h).toMatch(/<[^>]*aria-current[^>]*>\s*Source/);
132 expect(h).toContain('id="L1"');
133 });
134 test("?all=1 reaches the renderer", () => {
135 const text = "id\n" + Array.from({ length: 1_500 }, (_, i) => String(i)).join("\n");
136 expect(page("n.csv", text)).toContain("Show all 1,500 rows");
137 const all = page("n.csv", text, { all: true });
138 expect(all).not.toContain("Show all");
139 expect(all.split("<tr>").length - 1).toBe(1_501);
140 });
141 test("?view=source shows the lines", () => {
142 const h = page("data.csv", "a,b\n1,2\n", { view: "source" });
143 expect(h).not.toContain("data-csv");
144 expect(h).toContain('id="L1"');
145 });
146 test("only a CSV path carries the script", () => {
147 expect(isCsvPath("a/b.TSV")).toBe(true);
148 expect(isCsvPath("a/b.ts")).toBe(false);
149 });
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs