Writing a table directly in Markdown works for a few rows, but it becomes awkward when the data changes often or comes from a spreadsheet. I wanted the writing surface to stay readable while keeping the structured data in a format that is easy to edit, validate, and reuse.
That led to remark-csv-table, a Remark plugin that converts local CSV files referenced by a Markdown directive into
GitHub Flavored Markdown (GFM) tables during the build. It started as a custom plugin in this Astro site and is now a
published package that other Remark pipelines can use.
The Markdown Table Problem
Markdown tables keep content and presentation together, but they are not a great editing format for larger datasets. Adding a column means adjusting every row. Copying data from a spreadsheet can introduce alignment mistakes. Reviewing changes in a long table is also harder than reviewing a focused data file.
The workflow I wanted was simple: keep a post’s data in a nearby CSV file and let the Markdown refer to it.
::csv{src="./model-results.csv"}
At build time, the directive becomes a regular GFM table. The source article remains focused on the explanation, while the data remains in a CSV file that can be opened in a spreadsheet or edited as plain text.
From A Site Helper To A Package
My articles have become increasingly data-heavy, with longer tables and more content in the cells. Eventually, I decided writing the tables directly into the Markdown source for the articles was consuming more time than it was worth. I decided to store the data in CSV and inject that into the Markdown as part of the rendering process.
The original implementation lived in this site’s Markdown pipeline as remark-csv-tables.ts. It did more than parse a
file: it resolved each src value relative to the current Markdown file, rejected paths outside the post directory,
required a non-empty header and at least one data row, and replaced the directive with an mdast table node.
Those checks are important. A directive should not be able to read an arbitrary file from a build machine, and a broken CSV should fail clearly rather than silently produce a malformed article.
Once the plugin was useful in more than one place, keeping it tied to this site’s directory structure stopped making
sense. The reusable version exposes a contentDirectory option instead of assuming this repository’s content path.
Once I got the plugin working, I realized it would be potentially helpful to other developers or even useful to myself on future projects. Also, I have not had a chance to publish my own public package to npm yet, so this seemed like a good candidate for a learning experience to do that. The main thing I learned from creating this package is that publishing is the easy part, and the hard part is getting the project ready for the first release.
Install And Configure remark-csv-table
remark-csv-table is ESM-only and requires Node 24 or newer. It works in a Remark pipeline that also includes
remark-directive for the ::csv syntax and remark-gfm for GFM table output.
bun add remark-csv-table remark-directive remark-gfm unified remark-parse remark-stringify
Here is a minimal Unified pipeline:
import { unified } from "unified";
import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import remarkCsvTable from "remark-csv-table";
const result = await unified()
.use(remarkParse)
.use(remarkDirective)
.use(remarkGfm)
.use(remarkCsvTable, { contentDirectory: "content" })
.use(remarkStringify)
.process({
path: "content/posts/example.md",
value: '::csv{src="./table.csv"}',
});
The CSV path must remain inside both the Markdown file’s directory and contentDirectory. By default,
contentDirectory is the current working directory. CSV files need a header and at least one data row, headers cannot
be empty, and cells support single-line inline Markdown.
Using It In Astro
Astro can load the package in the same Remark plugin list as other Markdown transforms. This site imports its configured
unified processor from @astrojs/markdown-remark and includes the plugin after remark-directive.
import { unified } from "@astrojs/markdown-remark";
import remarkDirective from "remark-directive";
import remarkCsvTable from "remark-csv-table";
export const markdownConfig = {
processor: unified({
remarkPlugins: [remarkDirective, remarkCsvTable],
}),
};
With that configuration, an article and its CSV can stay together:
src/data/blog/example-post/
index.md
model-results.csv
The following results compare the available configurations.
::csv{src="./model-results.csv"}
This is deliberately a build-time transformation. Readers receive normal table HTML, not a client-side CSV parser or a network request for the source data.
What Changed On This Site
The extraction let this site remove its custom remark-csv-tables.ts file and replace it with the published package in
the Markdown configuration. Existing article directives keep the same ::csv{src="./file.csv"} syntax, so the content
authoring workflow stays the same.
The difference is maintenance scope. The CSV-to-table behavior now has its own package, documentation, tests, and release cycle instead of being coupled to one Astro project. That makes it useful to other sites while keeping this site’s Markdown configuration smaller.
Try The Plugin
If you keep structured data beside Markdown content, install remark-csv-table on npm and see the source at github.com/h93xV2/remark-csv-table. The package is intended for the specific case where local, build-time CSV tables are easier to maintain than hand-written Markdown tables.

