From 5a950adad96e4ffe7a68a45e381052fb6ec018cc Mon Sep 17 00:00:00 2001 From: Vivek R Date: Fri, 21 Aug 2026 17:42:17 +0530 Subject: [PATCH 1/3] feat: ship the frontend bundle gzipped The 207KB frontend script dominated every page, so an otherwise empty output cost 264KB before a single row. It is now gzipped and base64'd into the page and unpacked by a small inflater, taking the floor to 146KB. --no-compress restores the readable inline source for browsers without DecompressionStream, or for grepping the output. The stylesheet stays uncompressed so the page is styled at first paint rather than after the script unpacks. A --js script has to move inside the inflater, since a plain \n" - } themeAttribute := "" if cli.Theme != "" && cli.Theme != "auto" { @@ -606,12 +606,84 @@ func convert(cli options, destination io.Writer) error { } } - if _, err := fmt.Fprintf(output, "]}\n\n\n\n%s\n\n", optionsJSON, inlineScript(tableJS), scriptHTML); err != nil { + scriptsHTML, err := scriptsMarkup(cli.Compress, customJS) + if err != nil { + return err + } + if _, err := fmt.Fprintf(output, "]}\n\n%s\n\n", optionsJSON, scriptsHTML); err != nil { return err } return output.Flush() } +// bootstrap builds the table once the frontend bundle is in scope. Both the +// plain and the compressed paths end by running it. +const bootstrap = `CsvToTable.setupTheme("#csvtotable-theme");CsvToTable.table=CsvToTable.createCsvTable("#csvtotable-table",JSON.parse(document.getElementById("csvtotable-data").textContent),JSON.parse(document.getElementById("csvtotable-options").textContent));` + +// inflater unpacks the gzipped bundle, builds the table, then runs whatever +// --js supplied. Appending \n\n" + if custom != "" { + markup += "\n" + } + return markup, nil + } + packed, err := packAsset(tableJS) + if err != nil { + return "", err + } + markup := `\n" + if custom != "" { + markup += `\n" + } + return markup + "\n", nil +} + +// packAsset gzips and base64-encodes an asset for the inflater. Base64 has no +// HTML-significant characters, so the result needs none of the escaping raw +// source does. +func packAsset(source string) (string, error) { + var packed bytes.Buffer + writer, err := gzip.NewWriterLevel(&packed, gzip.BestCompression) + if err != nil { + return "", err + } + if _, err := io.WriteString(writer, source); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(packed.Bytes()), nil +} + // decompress transparently unwraps gzip input, detected by magic bytes so it // works for files, URLs, and standard input alike. func decompress(input io.ReadCloser) (io.ReadCloser, error) { diff --git a/main_test.go b/main_test.go index 03f3944..d9e2926 100644 --- a/main_test.go +++ b/main_test.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "context" + "encoding/base64" "errors" "fmt" "io" @@ -49,7 +50,7 @@ func TestConverterCompatibility(t *testing.T) { `"headers":["name","value"]`, `"pagination":false`, `"height":"50vh"`, - "DataTables 3.0.2", + `") { + t.Error("custom JS runs on its own, before the bundle is unpacked") + } + + // Decoding the blob has to give back the bundle byte for byte. + encoded := page[blob+len(`")] + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("bundle is not valid base64: %v", err) + } + reader, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("bundle is not gzip: %v", err) + } + unpacked, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if string(unpacked) != tableJS { + t.Error("the unpacked bundle differs from the embedded one") + } +} diff --git a/table/src/table.css b/table/src/table.css index d43aea6..48a92d6 100644 --- a/table/src/table.css +++ b/table/src/table.css @@ -171,6 +171,17 @@ code { .csvtotable-description ol { padding-left: 1.1rem; } .csvtotable-description a { color: var(--ct-accent); } +/* Shown only when the compressed bundle cannot be inflated, so the reader gets + a reason instead of an empty page. */ +.csvtotable-error { + margin: 0 0 1.1rem; + padding: .7rem .9rem; + border-left: 3px solid var(--ct-accent); + background: var(--ct-wash); + color: var(--ct-muted); + font-size: var(--ct-text-base); +} + /* Toolbar ------------------------------------------------------------- */ div.dt-container { color: var(--ct-ink); font-size: var(--ct-text-base); } From e43b0e2a5d46e163aac404aac1c2b9c27a1873ff Mon Sep 17 00:00:00 2001 From: Vivek R Date: Sat, 22 Aug 2026 00:01:02 +0530 Subject: [PATCH 2/3] feat: add --split and serve the preview over HTTP --split writes the output path as a directory of separate files linked by relative path, so a browser caches the frontend once instead of carrying it in every page. Assets are named with a hash of their contents: an unchanged rerun keeps the URL and the cache hit, while changed rows or a newer binary land on a new URL that no cache can serve stale. Everything is staged under a temporary name and renamed in only once the conversion succeeds, so a failure cannot truncate a directory already being served. Loading needs no fetch, so the page still opens from disk. --serve now runs a real local HTTP server rather than opening a file:// URL, and takes an optional [HOST]:PORT. An empty host binds loopback, so exposing the data on the network has to be written out in full and warns when it is. Responses carry Cache-Control: no-store, since the directory is rebuilt every run onto a port the kernel reuses, and the cleanup now also runs on SIGTERM. --- README.md | 117 ++++++++++++--- main.go | 393 ++++++++++++++++++++++++++++++++++++++++++++++----- main_test.go | 219 ++++++++++++++++++++++++++++ 3 files changed, 673 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 028ad6d..6a9fe23 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ CSVtoTable converts CSV, TSV, and Excel files into interactive HTML tables. - Single native binary with embedded frontend assets -- Standalone HTML output that works offline — data, styles, and scripts all inlined +- Standalone HTML output that works offline — data, styles, and scripts all inlined by default - CSV, TSV, and Excel (`.xlsx`) input, gzip archives included - Local files, URLs, standard input, or several files combined into one table - BOM and UTF-16 detected automatically; `--encoding`, `--delimiter`, `--quotechar` for the rest @@ -14,6 +14,7 @@ CSVtoTable converts CSV, TSV, and Excel files into interactive HTML tables. - Themes are just CSS variables — define your own with `--css` and it joins the picker - `--css` and `--js` inline your own stylesheet and script, with the live table API exposed - Self-unpacking output: the frontend ships gzipped, roughly halving every file +- `--split` into separately cacheable assets instead, or `--serve` a preview over HTTP - Mobile-responsive layout ![CSVtoTable demo](demo/table.gif) @@ -38,9 +39,15 @@ csvtotable sales.xlsx sales.html # Fetch CSV directly from a URL csvtotable https://raw.githubusercontent.com/vividvilla/csvtotable/master/demo/meteorite-landings-1.csv meteorites.html -# Open a temporary page in the default browser +# Build and open the page on a local HTTP server csvtotable data.csv --serve +# Serve it on a port you choose +csvtotable data.csv --serve :8080 + +# Write a directory of separate files instead of one page +csvtotable data.csv site/ --split + # Add a title and generate headers for headerless data csvtotable data.csv data.html --title "Sales" --no-header @@ -65,11 +72,12 @@ csvtotable data.csv data.html --css brand.css --js setup.js # Read stdin and write stdout curl -L https://example.com/data.csv | csvtotable - - > data.html - -# Explore all the available options -csvtotable --help ``` +Run `csvtotable --help` for all options or `csvtotable --version` for the version. +For compatibility with version 2, `--caption`, `--display-length`, `--pagination`, +and `--export` still work; the last two disable those features. + ## Install ### uvx, pipx, or pip @@ -117,7 +125,83 @@ on your `PATH`. Prebuilt binaries support Linux x86-64/ARM64, macOS 12+ x86-64/Apple Silicon, and Windows 10+ x86-64. -### Styling +## Output + +### Size + +The frontend script is gzipped and base64'd into the page, which cuts an +otherwise empty file from about 260KB to 145KB. Unpacking it needs +`DecompressionStream` (Chrome 103+, Firefox 113+, Safari 16.4+); older browsers +get a message saying so. `--no-compress` inlines the script as readable source +instead, for those browsers or for grepping the output. + +The stylesheet is left uncompressed either way, so the page is styled at first +paint rather than after the script has unpacked. + +### Separate files + +`--split` writes the output path as a directory instead of a single page: + +``` +site/ + index.html the page, a couple of KB + csvtotable.9bf9d6348cd4.css the stylesheet + csvtotable.47304c323fd7.js the frontend + data.50248c1cfaed.js the rows +``` + +The references are relative, so the directory can be served from any path. The +browser then caches the stylesheet and the script the way it caches any other +asset, and a second page — or a reload — costs only the data. Behind a server +that gzips, the demo data goes over the wire as roughly 105KB the first time and +31KB on a revisit. + +Everything the page links to carries a hash of its contents. Regenerating with +the same rows and the same binary leaves the names alone, so the cache keeps +hitting; change either and the URL changes, so a browser or CDN holding the old +copy cannot serve it against the new page. Superseded files are left in place +rather than deleted, since they may still be wanted by a page someone has open — +clearing them out is yours to do. `index.html` keeps its name, so its freshness +is up to whatever serves it, as with any static site. + +Nothing here needs `fetch`, so `index.html` still renders when opened straight +from disk. `--css` and `--js` stay inline in the page rather than becoming files +of their own: they are usually small, and a `--css` theme has to be inline for +the theme picker to find it over `file://`, where reading rules out of a linked +stylesheet is blocked. + +Compression does not apply in this mode — caching is doing the job that +compressing the bundle stood in for. + +### Serving + +`--serve` builds the page into a temporary directory, serves it over HTTP, and +opens a browser there. It takes an optional `[HOST]:PORT`: + +```sh +csvtotable data.csv --serve # a random loopback port +csvtotable data.csv --serve :8080 # port 8080 on loopback +csvtotable data.csv --serve 0.0.0.0:8080 # every interface +``` + +Leaving the host off binds loopback, so putting the data on the network takes +writing the host out in full, and doing that prints a warning. The address is +printed either way, so the page is still reachable if no browser opens. + +Combined with `--split` it serves each asset separately, which is the same +thing a deployment would do: + +```sh +csvtotable data.csv --serve --split +``` + +The temporary directory is removed on Ctrl-C. Responses carry +`Cache-Control: no-store`: the directory is rebuilt on every run and the port is +reused, so a cached asset from an earlier run would otherwise be mixed into a +later page. That applies to the preview only — a `--split` directory you deploy +yourself caches normally, which is the point of the mode. + +## Styling The page is plain semantic HTML, and every element CSVtoTable owns carries a `csvtotable-` class. These are the stable hooks: @@ -188,8 +272,10 @@ CsvToTable.table.column(0).visible(false); // hide the first column In a compressed page the script is parked in an inert `\n"); err != nil { return err } - if _, err := fmt.Fprintf(output, "]}\n\n%s\n\n", optionsJSON, scriptsHTML); err != nil { + scriptsHTML, err := scriptsMarkup(cli.Compress, customJS, split) + if err != nil { return err } - return output.Flush() + _, err = fmt.Fprintf(page, "\n%s\n\n", optionsJSON, scriptsHTML) + return err } -// bootstrap builds the table once the frontend bundle is in scope. Both the -// plain and the compressed paths end by running it. -const bootstrap = `CsvToTable.setupTheme("#csvtotable-theme");CsvToTable.table=CsvToTable.createCsvTable("#csvtotable-table",JSON.parse(document.getElementById("csvtotable-data").textContent),JSON.parse(document.getElementById("csvtotable-options").textContent));` +// bootstrapFormat builds the table once the frontend bundle is in scope. Every +// path ends by running it; they differ only in where the rows come from. +const bootstrapFormat = `CsvToTable.setupTheme("#csvtotable-theme");CsvToTable.table=CsvToTable.createCsvTable("#csvtotable-table",%s,JSON.parse(document.getElementById("csvtotable-options").textContent));` + +var ( + bootstrap = fmt.Sprintf(bootstrapFormat, `JSON.parse(document.getElementById("csvtotable-data").textContent)`) + splitBootstrap = fmt.Sprintf(bootstrapFormat, "window.csvtotableData") +) + +// indexFile is the only fixed name --split writes: everything it links to +// carries a hash of its contents, so a regenerated directory can never serve a +// stale asset against a fresh page. The entry point has to stay put for the +// URL to keep working, which leaves its freshness to the server, as with any +// static site. +const indexFile = "index.html" + +// splitAssets are the files a --split page links to. The data file is named +// last: its hash is only known once every row has been written. +type splitAssets struct { + css string + js string + dataName func() string +} + +// hashedName inserts a content digest before the extension, so unchanged +// assets keep their URL across runs and changed ones cannot be mistaken for +// them. +func hashedName(stem, extension, content string) string { + digest := sha256.Sum256([]byte(content)) + return fmt.Sprintf("%s.%s%s", stem, hex.EncodeToString(digest[:])[:12], extension) +} // inflater unpacks the gzipped bundle, builds the table, then runs whatever // --js supplied. Appending ` + "\n" + + `` + "\n" + + "\n" + if custom != "" { + markup += "\n" + } + return markup, nil + } if !compress { markup := "\n\n" if custom != "" { @@ -684,6 +914,103 @@ func packAsset(source string) (string, error) { return base64.StdEncoding.EncodeToString(packed.Bytes()), nil } +// convertDirectory writes index.html and its assets into dir as separate +// files, referenced by relative path. Opening index.html from disk still works +// — and `, + "window.csvtotableData", + } { + if !strings.Contains(page, want) { + t.Errorf("index.html is missing %q", want) + } + } + dataRef := regexp.MustCompile(``).FindStringSubmatch(page) + if dataRef == nil { + t.Fatalf("index.html has no hashed data reference:\n%s", page) + } + dataName := dataRef[1] + // Nothing that belongs in a separate file may also be inlined, or the + // caching the mode exists for is wasted. + for _, unwanted := range []string{"DataTables 3.0.2", "--ct-accent", "csvtotable-bundle", `"rows":[`} { + if strings.Contains(page, unwanted) { + t.Errorf("index.html still inlines %q", unwanted) + } + } + if len(page) > 4096 { + t.Errorf("index.html is %d bytes; it should hold no payload", len(page)) + } + + // The rows have to survive the trip into a separate script. + data := read(dataName) + encoded, ok := strings.CutPrefix(strings.TrimSpace(data), "window.csvtotableData=") + if !ok { + t.Fatalf("data.js does not assign the payload: %.60q", data) + } + var payload struct { + Headers []string `json:"headers"` + Rows [][]string `json:"rows"` + } + if err := json.Unmarshal([]byte(strings.TrimSuffix(encoded, ";")), &payload); err != nil { + t.Fatalf("data.js is not valid JSON: %v", err) + } + if len(payload.Headers) != 2 || len(payload.Rows) != 2 || payload.Rows[1][0] != "Kochi" { + t.Errorf("unexpected payload: %+v", payload) + } + + // A rerun overwrites its own files and must not need a prompt when told. + cli, err = parseArgs([]string{input, target, "--split", "--overwrite"}) + if err != nil { + t.Fatal(err) + } + if err := run(cli); err != nil { + t.Fatalf("rerunning with --overwrite failed: %v", err) + } + + // Different rows must land on a different URL, or a browser holding the + // old data.js serves it against the new page. + if err := os.WriteFile(input, []byte("city,temperature\nPune,29\nKochi,31\nDelhi,18\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := run(cli); err != nil { + t.Fatal(err) + } + if again := read(indexFile); strings.Contains(again, dataName) { + t.Errorf("changed rows kept the data URL %q", dataName) + } + + // A conversion that fails must leave the served directory as it was. + before := read(indexFile) + broken := cli + broken.CSS = filepath.Join(directory, "missing.css") + if err := convertDirectory(broken, target); err == nil { + t.Error("a missing --css file was accepted") + } + if after := read(indexFile); after != before { + t.Error("a failed conversion left the previous index.html damaged") + } + entries, err := os.ReadDir(target) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".csvtotable-") { + t.Errorf("a failed conversion left the staging file %q behind", entry.Name()) + } + } +} + +func TestPreviewIsNotCached(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, indexFile), []byte("first"), 0o644); err != nil { + t.Fatal(err) + } + handler := previewHandler(directory) + + // --serve rebuilds its directory every run onto a port the kernel reuses, + // so anything the browser keeps is a stale asset waiting to be mixed into + // a later page. + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + if got := recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control is %q, want no-store", got) + } + if recorder.Body.String() != "first" { + t.Errorf("served %q, want the file contents", recorder.Body.String()) + } + + if err := os.WriteFile(filepath.Join(directory, indexFile), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + recorder = httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.Header.Set("If-Modified-Since", time.Now().UTC().Format(http.TimeFormat)) + handler.ServeHTTP(recorder, request) + if recorder.Body.String() != "second" { + t.Errorf("a rewritten file served %q; the preview must not go stale", recorder.Body.String()) + } +} + +func TestServeAddress(t *testing.T) { + // --serve takes an optional value, which urfave/cli has no notion of, so a + // bare --serve must not swallow the argument after it. + forms := []struct { + args []string + serve bool + address string + inputs int + }{ + {args: []string{"in.csv", "--serve"}, serve: true, inputs: 1}, + {args: []string{"--serve", "in.csv"}, serve: true, inputs: 1}, + {args: []string{"-s", "in.csv"}, serve: true, inputs: 1}, + {args: []string{"--serve", ":8080", "in.csv"}, serve: true, address: ":8080", inputs: 1}, + {args: []string{"--serve=127.0.0.1:8080", "in.csv"}, serve: true, address: "127.0.0.1:8080", inputs: 1}, + {args: []string{"--serve", "[::1]:8080", "in.csv"}, serve: true, address: "[::1]:8080", inputs: 1}, + {args: []string{"in.csv", "out.html"}, serve: false, inputs: 1}, + } + // A -s that belongs to another flag is that flag's value, not this one. + guarded, err := parseArgs([]string{"--title", "-s", "in.csv", "out.html"}) + if err != nil { + t.Fatal(err) + } + if guarded.Title != "-s" || guarded.Serve || len(guarded.InputFiles) != 1 { + t.Errorf("--title -s was rewritten: title=%q serve=%v inputs=%v", + guarded.Title, guarded.Serve, guarded.InputFiles) + } + for _, form := range forms { + cli, err := parseArgs(form.args) + if err != nil { + t.Errorf("%v: %v", form.args, err) + continue + } + if cli.Serve != form.serve || cli.Address != form.address || len(cli.InputFiles) != form.inputs { + t.Errorf("%v: serve=%v address=%q inputs=%v; want %v, %q, %d", + form.args, cli.Serve, cli.Address, cli.InputFiles, form.serve, form.address, form.inputs) + } + } + + // An empty host binds loopback: putting the data on the network should + // take more than leaving the host off. + targets := map[string]string{ + "": "127.0.0.1:0", + ":8080": "127.0.0.1:8080", + "localhost:8080": "localhost:8080", + "0.0.0.0:8080": "0.0.0.0:8080", + "[::1]:8080": "[::1]:8080", + } + for address, want := range targets { + got, err := listenTarget(address) + if err != nil || got != want { + t.Errorf("listenTarget(%q) = %q, %v; want %q", address, got, err, want) + } + } + for _, bad := range []string{"8080", "nonsense", ":99999", "host:port"} { + if _, err := listenTarget(bad); err == nil { + t.Errorf("listenTarget(%q) was accepted", bad) + } + } + + for bind, want := range map[string]bool{ + "127.0.0.1:80": true, "localhost:80": true, "[::1]:80": true, + "0.0.0.0:80": false, "10.0.0.4:80": false, + } { + if got := loopbackOnly(bind); got != want { + t.Errorf("loopbackOnly(%q) = %v, want %v", bind, got, want) + } + } +} From c59a7404761819803a98c4b64484a9b20aae2c2d Mon Sep 17 00:00:00 2001 From: Vivek R Date: Sat, 22 Aug 2026 00:07:37 +0530 Subject: [PATCH 3/3] docs: tighten the output and customisation sections Size, Separate files, Serving, and Custom CSS and JavaScript had grown a paragraph of rationale for every rule they stated. Keep the rules, the figures, and every example, and drop the justification around them. --- README.md | 109 ++++++++++++++++++++---------------------------------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 6a9fe23..998ab11 100644 --- a/README.md +++ b/README.md @@ -129,14 +129,11 @@ and Windows 10+ x86-64. ### Size -The frontend script is gzipped and base64'd into the page, which cuts an -otherwise empty file from about 260KB to 145KB. Unpacking it needs -`DecompressionStream` (Chrome 103+, Firefox 113+, Safari 16.4+); older browsers -get a message saying so. `--no-compress` inlines the script as readable source -instead, for those browsers or for grepping the output. - -The stylesheet is left uncompressed either way, so the page is styled at first -paint rather than after the script has unpacked. +The frontend script is gzipped and base64'd into the page, cutting an otherwise +empty file from about 260KB to 145KB. Unpacking needs `DecompressionStream` +(Chrome 103+, Firefox 113+, Safari 16.4+); older browsers get a message saying +so, and `--no-compress` inlines readable source instead. The stylesheet stays +uncompressed either way, so the page is styled at first paint. ### Separate files @@ -150,28 +147,18 @@ site/ data.50248c1cfaed.js the rows ``` -The references are relative, so the directory can be served from any path. The -browser then caches the stylesheet and the script the way it caches any other -asset, and a second page — or a reload — costs only the data. Behind a server -that gzips, the demo data goes over the wire as roughly 105KB the first time and -31KB on a revisit. - -Everything the page links to carries a hash of its contents. Regenerating with -the same rows and the same binary leaves the names alone, so the cache keeps -hitting; change either and the URL changes, so a browser or CDN holding the old -copy cannot serve it against the new page. Superseded files are left in place -rather than deleted, since they may still be wanted by a page someone has open — -clearing them out is yours to do. `index.html` keeps its name, so its freshness -is up to whatever serves it, as with any static site. - -Nothing here needs `fetch`, so `index.html` still renders when opened straight -from disk. `--css` and `--js` stay inline in the page rather than becoming files -of their own: they are usually small, and a `--css` theme has to be inline for -the theme picker to find it over `file://`, where reading rules out of a linked -stylesheet is blocked. - -Compression does not apply in this mode — caching is doing the job that -compressing the bundle stood in for. +References are relative, so the directory serves from any path, and the browser +caches the frontend like any other asset — behind a gzipping server the demo data +costs roughly 105KB the first time and 31KB on a revisit. + +Filenames carry a content hash: an unchanged rerun keeps the URL and the cache +hit, while changed rows get a new one that cannot be served stale. Superseded +files are left in place for you to clear out. `index.html` keeps its name, so its +freshness is up to whatever serves it. + +Nothing needs `fetch`, so `index.html` still opens from disk. `--css` and `--js` +stay inline — a `--css` theme has to be, for the theme picker to find it over +`file://`. Compression does not apply here; caching does that job. ### Serving @@ -182,24 +169,17 @@ opens a browser there. It takes an optional `[HOST]:PORT`: csvtotable data.csv --serve # a random loopback port csvtotable data.csv --serve :8080 # port 8080 on loopback csvtotable data.csv --serve 0.0.0.0:8080 # every interface +csvtotable data.csv --serve --split # each asset served separately ``` -Leaving the host off binds loopback, so putting the data on the network takes -writing the host out in full, and doing that prints a warning. The address is -printed either way, so the page is still reachable if no browser opens. +An empty host binds loopback; exposing the data on the network takes writing the +host out, and prints a warning. The address is printed either way, so the page is +reachable if no browser opens. -Combined with `--split` it serves each asset separately, which is the same -thing a deployment would do: - -```sh -csvtotable data.csv --serve --split -``` - -The temporary directory is removed on Ctrl-C. Responses carry -`Cache-Control: no-store`: the directory is rebuilt on every run and the port is -reused, so a cached asset from an earlier run would otherwise be mixed into a -later page. That applies to the preview only — a `--split` directory you deploy -yourself caches normally, which is the point of the mode. +Responses carry `Cache-Control: no-store`, since the directory is rebuilt each +run onto a port the kernel reuses and caching it would mix runs together. The +directory is removed on exit. A `--split` directory you deploy yourself caches +normally. ## Styling @@ -250,46 +230,39 @@ beginning `dt-` come from DataTables and may change when it is upgraded. ### Custom CSS and JavaScript `--css` and `--js` inline a stylesheet and a script into the page, keeping the -output a single self-contained file. Both take a file path: +output self-contained. Both take a file path; a leading `@` is accepted but +means nothing here: ```sh csvtotable data.csv data.html --css brand.css --js setup.js ``` -A leading `@` is accepted too, for symmetry with `--description`, but means -nothing here: `--css @brand.css` and `--css brand.css` are the same. - -Placement is what makes them useful. The stylesheet goes last in ``, after -the built-in one, so a single class selector overrides anything above without -`!important`. The script goes last in ``, after the table is built, and -`CsvToTable.table` holds the live [DataTables API](https://datatables.net/reference/api/) -instance: +Placement is the point. The stylesheet goes last in ``, so a single class +selector overrides the built-in one without `!important`. The script goes last +in ``, after the table is built, with `CsvToTable.table` holding the live +[DataTables API](https://datatables.net/reference/api/) instance: ```js CsvToTable.table.order([2, "desc"]).draw(); // sort by the third column CsvToTable.table.column(0).visible(false); // hide the first column ``` -In a compressed page the script is parked in an inert `