Code & DataData

Data

Feed charts and sortable tables from real data files instead of hand-typed Markdown tables. The data rune reads an external tabular source at build time, shapes it (filter, sort, select, type), and emits a plain <table> — which {% chart %} and {% datatable %} consume with no extra work, and which stands on its own as the no-JS fallback.

note

data is an AST preprocessor, like {% snippet %}: by the time the transform phase runs, every {% data %} tag has been replaced with a Markdoc table node. That is why it composes transparently inside {% chart %} and {% datatable %} — they see an ordinary table, exactly as if you had typed one. The read goes through the same sandbox as snippet (project-root bounded, via the SPEC-113 ProjectFiles seam), so data is safe on sites that accept untrusted author content and works in fully in-memory/hosted builds.

Ingest a file

The minimum case — a src attribute relative to the project root. The format is inferred from the extension.

{% data src="site/examples/revenue.csv" /%}
<div class="rf-table-wrapper">
  <table>
    <thead>
      <tr>
        <th>product</th>
        <th>revenue</th>
        <th>region</th>
        <th>units</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Widget</td>
        <td data-value="1200">$1,200</td>
        <td>EMEA</td>
        <td data-value="340">340</td>
      </tr>
      <tr>
        <td>Gadget</td>
        <td data-value="900">$900</td>
        <td>AMER</td>
        <td data-value="210">210</td>
      </tr>
      <tr>
        <td>Gizmo</td>
        <td data-value="1500">$1,500</td>
        <td>EMEA</td>
        <td data-value="120">120</td>
      </tr>
      <tr>
        <td>Sprocket</td>
        <td data-value="1050">$1,050</td>
        <td>APAC</td>
        <td data-value="260">260</td>
      </tr>
    </tbody>
  </table>
</div>
productrevenueregionunits
Widget$1,200EMEA340
Gadget$900AMER210
Gizmo$1,500EMEA120
Sprocket$1,050APAC260
<div class="rf-table-wrapper">
  <table>
    <thead>
      <tr>
        <th>product</th>
        <th>revenue</th>
        <th>region</th>
        <th>units</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Widget</td>
        <td data-value="1200">$1,200</td>
        <td>EMEA</td>
        <td data-value="340">340</td>
      </tr>
      <tr>
        <td>Gadget</td>
        <td data-value="900">$900</td>
        <td>AMER</td>
        <td data-value="210">210</td>
      </tr>
      <tr>
        <td>Gizmo</td>
        <td data-value="1500">$1,500</td>
        <td>EMEA</td>
        <td data-value="120">120</td>
      </tr>
      <tr>
        <td>Sprocket</td>
        <td data-value="1050">$1,050</td>
        <td>APAC</td>
        <td data-value="260">260</td>
      </tr>
    </tbody>
  </table>
</div>

That is a real file in this repository, read at build time. Change the file and the table updates on the next build — no copy-paste drift. Note the emitted table is a normal table: it renders with or without JavaScript.

Feeding a chart

Because chart treats an authored <table> as its single source of truth, dropping a {% data %} inside it is all it takes. numeric types the value column so the chart plots real numbers (see Typing).

{% chart type="bar" title="Revenue by product" %}
{% data src="site/examples/revenue.csv" columns="product, revenue" numeric="revenue" /%}
{% /chart %}
<rf-chart data-rune="chart" data-rune-fields="{&quot;type&quot;:&quot;bar&quot;,&quot;stacked&quot;:&quot;false&quot;}">
  <table data-name="data">
    <caption>Revenue by product</caption>
    <thead>
      <tr>
        <th>product</th>
        <th>revenue</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Widget</td>
        <td data-value="1200">$1,200</td>
      </tr>
      <tr>
        <td>Gadget</td>
        <td data-value="900">$900</td>
      </tr>
      <tr>
        <td>Gizmo</td>
        <td data-value="1500">$1,500</td>
      </tr>
      <tr>
        <td>Sprocket</td>
        <td data-value="1050">$1,050</td>
      </tr>
    </tbody>
  </table>
</rf-chart>
Revenue by product
productrevenue
Widget$1,200
Gadget$900
Gizmo$1,500
Sprocket$1,050
<rf-chart class="rf-chart" data-type="bar" data-stacked="false" data-elevation="sunken" data-rune="chart" data-density="compact">
  <table data-name="data" class="rf-chart__data">
    <caption>Revenue by product</caption>
    <thead>
      <tr>
        <th>product</th>
        <th>revenue</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Widget</td>
        <td data-value="1200">$1,200</td>
      </tr>
      <tr>
        <td>Gadget</td>
        <td data-value="900">$900</td>
      </tr>
      <tr>
        <td>Gizmo</td>
        <td data-value="1500">$1,500</td>
      </tr>
      <tr>
        <td>Sprocket</td>
        <td data-value="1050">$1,050</td>
      </tr>
    </tbody>
  </table>
</rf-chart>

Feeding a datatable

Same table, handed to datatable for runtime search + sort. Typed-numeric columns sort by their real value, not the formatted text ($1,200 sorts above $900, not below it):

{% datatable sortable="all" searchable=true %}
{% data src="site/examples/revenue.csv" numeric="revenue,units" /%}
{% /datatable %}
<div data-rune="data-table" typeof="Dataset" data-rune-fields="{&quot;sortable&quot;:&quot;all&quot;,&quot;searchable&quot;:&quot;true&quot;,&quot;pageSize&quot;:&quot;0&quot;,&quot;defaultSort&quot;:&quot;&quot;}">
  <div data-name="scroll">
    <table data-name="table">
      <thead>
        <tr>
          <th>product</th>
          <th>revenue</th>
          <th>region</th>
          <th>units</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>Widget</td>
          <td data-value="1200">$1,200</td>
          <td>EMEA</td>
          <td data-value="340">340</td>
        </tr>
        <tr>
          <td>Gadget</td>
          <td data-value="900">$900</td>
          <td>AMER</td>
          <td data-value="210">210</td>
        </tr>
        <tr>
          <td>Gizmo</td>
          <td data-value="1500">$1,500</td>
          <td>EMEA</td>
          <td data-value="120">120</td>
        </tr>
        <tr>
          <td>Sprocket</td>
          <td data-value="1050">$1,050</td>
          <td>APAC</td>
          <td data-value="260">260</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>
productrevenueregionunits
Widget$1,200EMEA340
Gadget$900AMER210
Gizmo$1,500EMEA120
Sprocket$1,050APAC260
<div typeof="Dataset" class="rf-datatable rf-datatable--true rf-datatable--all rf-datatable--0" data-searchable="true" data-sortable="all" data-page-size="0" data-default-sort="" data-elevation="sunken" data-rune="data-table" data-density="compact">
  <div data-name="scroll" class="rf-datatable__scroll">
    <table data-name="table" class="rf-datatable__table" data-section="body">
      <thead>
        <tr>
          <th>product</th>
          <th>revenue</th>
          <th>region</th>
          <th>units</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>Widget</td>
          <td data-value="1200">$1,200</td>
          <td>EMEA</td>
          <td data-value="340">340</td>
        </tr>
        <tr>
          <td>Gadget</td>
          <td data-value="900">$900</td>
          <td>AMER</td>
          <td data-value="210">210</td>
        </tr>
        <tr>
          <td>Gizmo</td>
          <td data-value="1500">$1,500</td>
          <td>EMEA</td>
          <td data-value="120">120</td>
        </tr>
        <tr>
          <td>Sprocket</td>
          <td data-value="1050">$1,050</td>
          <td>APAC</td>
          <td data-value="260">260</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Shaping the data

Every knob below runs at build time on a single intermediate shape, identically regardless of source format. Order: wheresortcolumnslimit/offset, then typing.

{% data src="site/examples/revenue.csv"
   where="region:EMEA"
   sort="-units"
   columns="product as Product, units as Units"
   numeric="units" /%}
<div class="rf-table-wrapper">
  <table>
    <thead>
      <tr>
        <th>Product</th>
        <th>Units</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Widget</td>
        <td data-value="340">340</td>
      </tr>
      <tr>
        <td>Gizmo</td>
        <td data-value="120">120</td>
      </tr>
    </tbody>
  </table>
</div>
ProductUnits
Widget340
Gizmo120
<div class="rf-table-wrapper">
  <table>
    <thead>
      <tr>
        <th>Product</th>
        <th>Units</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Widget</td>
        <td data-value="340">340</td>
      </tr>
      <tr>
        <td>Gizmo</td>
        <td data-value="120">120</td>
      </tr>
    </tbody>
  </table>
</div>
  • where filters rows with the field:value grammar (the same one collection and aggregate use — exact, glob*, or /regex/). Repeat a field to OR; combine fields to AND.
  • sort orders by a column; a - prefix descends. A column whose cells are all numeric (even when formatted, like $1,200) sorts numerically.
  • columns selects, reorders, and renames: "revenue as 'Revenue ($)'". Quote an alias that contains spaces or punctuation.
  • limit / offset slice the rows — the data analogue of snippet's lines=.

Typing and the data-value channel

A Markdown table carries only text, so a formatted number ($1,200, 1,500, 98%) is just a string. numeric types a column: every value cell keeps its human-formatted text and gains a normalized data-value ("$1,200"data-value="1200").

That one attribute is the typed channel both host runes honour:

  • chart reads data-value for the plotted number (falling back to the cell text).
  • datatable's sort prefers data-value, so currency and thousands-separated columns sort correctly.

Auto-inference handles the common case — a column whose non-empty cells all parse as numbers becomes numeric automatically. Use numeric="col" to force it (e.g. a column with a few blanks or footnote markers) and text="col" to keep something like a zero-padded code (007) as text. data-value is invisible to a reader of the bare table — a pure enhancement.

JSON and NDJSON

JSON isn't inherently tabular, so its adapter owns three extra knobs; everything downstream is identical to CSV.

Nested JSON, plucked with a root locator and dotted column paths:

{% data src="data/api-dump.json" root="data.results" where="region:EMEA"
   columns="product as Product, geo.country as Country, units as Units"
   numeric="units" sort="-units" /%}
  • root — a dotted path (data.results) or JSON Pointer (/data/results) to the array or map inside the document. Defaults to the document itself when it is already an array.
  • orient — how each element maps to a row:
    • records (default, auto-detected) — [{name, revenue}, …], keys become headers.
    • values (auto-detected) — [["name","revenue"],["a",10]], the first inner array is the header row.
    • index (explicit) — { "us": {…}, … }, an object map; the key becomes a column named by key-column.
  • dotted column paths — nested fields flatten to dotted headers (geo.country), so columns plucks them by name.

Object-map JSON into a datatable:

{% datatable sortable="all" searchable=true %}
{% data src="data/inventory.json" orient="index" key-column="sku"
   columns="sku as SKU, name as Item, stock as Stock" numeric="stock" /%}
{% /datatable %}

NDJSON (newline-delimited JSON) parses one record per line; the union of record keys becomes the headers:

{% data src="data/events.ndjson" columns="ts as Time, type as Event" /%}

Build time vs. runtime — data and datatable

data's where/sort/columns overlap conceptually with what datatable offers, but they run at a different time, and they compose:

datadatatable
WhenBuild timeRuntime (client-side)
EffectBakes the shaped rows into the static HTMLLets the reader filter/sort/paginate live
DefinesThe no-JS table + what exists on the pageAn interactive view over what's there

Use data to scope and type what lands on the page (the honest fallback); wrap it in datatable to let readers explore that set. Reach for data's knobs to remove rows from the output, and datatable's to let readers hide rows they still could reveal.

Format inference

format is inferred from the file extension and can always be overridden.

ExtensionFormat
.csvcsv
.tsvtsv
.jsonjson
.ndjson, .jsonlndjson
(other)set format= explicitly

When something goes wrong

A sandbox escape, a missing file, a parse error, or an empty result renders a visible error callout in place of the table and emits a build warning — the build keeps going, and the failure is obvious on the page rather than silently producing a broken table.

SQLite — a later tier

A SQLite adapter is specified but not yet implemented. It will slot into the same { headers, rows } contract, but it is active rather than passive — its path to a result set is a query (table="sales" or query="SELECT …" with params for safe binding), and rows arrive pre-typed. Because a query engine can reach outside the file, the adapter will open the database read-only, disable extension loading, and reject ATTACH / file-touching pragmas on top of the path sandbox — build-time only, never exposed to the client. Tracked as its own work item.

Attributes

Core

AttributeTypeRequiredDescription
srcStringYesPath to the source file, relative to the project root (sandboxed).
formatStringNocsv | tsv | json | ndjson. Inferred from the extension; override for ambiguity.

CSV / TSV

AttributeTypeRequiredDescription
delimiterStringNoOverride the field separator.
headerBooleanNoWhether the first row is the header (default true). false synthesizes col1….

JSON

AttributeTypeRequiredDescription
rootStringNoDotted path / JSON Pointer to the array or map within the document.
orientStringNorecords | values | index. records/values auto-detected; index is explicit.
key-columnStringNoWhen orient=index, the header for the synthesized key column (default key).

Shared (all formats)

AttributeTypeRequiredDescription
columnsStringNoSelect + order + rename: "name as Product, revenue as 'Revenue ($)'". Dotted paths for JSON.
whereStringNoFilter rows with the field:value grammar.
sortStringNoSort by a column; - prefix descends.
limitNumberNoMaximum number of rows.
offsetNumberNoSkip this many rows before limiting.
numericStringNoComma-separated columns to force to numeric typing (emits data-value).
textStringNoComma-separated columns to force to text typing.

See also

  • Chart — plots the emitted table; reads data-value for real numbers.
  • Datatable — runtime search/sort over the emitted table; sorts on data-value.
  • Snippet — the sibling preprocess rune (a file → a code fence).
  • Hosted & in-memory builds — the ProjectFiles sandbox data's src reads through.