Working with CSV files

Overview

A CSV (comma-separated values) file is a table, where each row is a record, and each column is a field. The file typically has a header row, where each field in the row describes the data in its column. The fields are typically separated by a comma. For example:

"name","type","breed","age"
"Spot","dog","Collie",3
"Rover","dog","Boxer",5
"Felix","cat","Malicious",7

CSV files are often used to manually transfer data between systems. Spreadsheet applications such as Microsoft Excel and LibreOffice Math are common sources. CSV files are an ideal data source for creating tables in documentation, and for performing queries.

Do not place CSV files in Hugo’s data directory. You may place JSON, TOML, XML, and YAML files in the data directory, but no other file types.

With the transform.Unmarshal function Hugo can unmarshal CSV data to an array of arrays. From this starting point we can use a shortcode to insert an HTML table, or a partial to access the data for queries and iteration.

Insert a table

Use a shortcode to insert a table on a page.

Arguments

path
(string) The path to the CSV file: a remote resource, a page resource, or a global resource in the assets directory.
caption
(string) Optional. The table caption.
class
(string) Optional. The class attribute of the table element.
hasHeaderRow
(bool) Optional. Set to false if the CSV file does not have a header row. The default value is true.
id
(string) Optional. The id attribute of the table element.
delimiter
(string) Optional. The delimiting character between the fields in each row. The default value is a comma.

Example

{{< csv-to-table path="pets.csv" >}}
nametypebreedage
SpotdogCollie3
RoverdogBoxer5
FelixcatMalicious7

Source code

layouts/shortcodes/csv-to-table.html
{{- /* Last modified: 2023-12-10T09:27:03-08:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Renders an HTML table from a CSV file.

This shortcode resolves internal paths by looking for a matching:

  1. Page resource (a file in the current page bundle)
  2. Section resource (a file in the current section)
  3. Global resource (a file in the assets directory)

It skips the section resource lookup if the current page is a leaf bundle, and
captures external files as resources.

You must place global resources in the assets directory. If you have placed
your resources in the static directory, and you are unable or unwilling to move
them, you must mount the static directory to the assets directory by including
both of these entries in your site configuration:

  [[module.mounts]]
  source = 'assets'
  target = 'assets'

  [[module.mounts]]
  source = 'static'
  target = 'assets'

@context {string} Inner The content between the opening and closing shortcode tags.
@context {string} InnerDeindent The content between the opening and closing shortcode tags with indentation removed.
@context {string} Name The file name of the shortcode template, excluding the extension.
@context {int} Ordinal The zero-based ordinal of the shortcode on the page, or within its parent shortcode.
@context {page} Page A reference to the page containing the shortcode.
@context {map} Params The parameters specified in the opening shortcode tag.
@context {hugolib.ShortcodeWithPage} Parent The context of the parent shortcode.
@context {text.Position} Position The position of the shortcode within the page content.

@method {any} Get Returns the parameter value for the given key (for named parameters) or position (for positional parameters).
@mathod {bool} IsNamedParams Returns true if the shortcode is called with named instead of positional parameters.
@method {maps.Scratch) Scratch Returns a writable Scratch to store and manipulate data.

@param {string} Params.path The path to the CSV file.
@param {string} [Params.caption] The table caption.
@param {string} [Params.class] The class attribute of the table element.
@param {string} [Params.delimiter=","] The delimiting character between the fields in each row.
@param {bool} [Params.hasHeaderRow=true] Set to true if the CSV file has a header row.
@param {string} [Params.id] The id attribute of the table element.

@returns {template.html}

@example {{< csv-to-table path="data.csv" >}}
@example {{< csv-to-table path="data.csv" hasHeaderRow=false >}}
@example {{< csv-to-table path="data.csv" caption="Annual rainfall by region" >}}
*/}}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.114.0" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %s shortcode requires Hugo v%s or later." .Name $minHugoVersion }}
{{- end }}

{{- /* Get context. */}}
{{- $name := .Name }}
{{- $ordinal := .Ordinal }}
{{- $position := .Position }}

{{- /* Get parameters. */}}
{{- $caption := or (.Get "caption") "" }}
{{- $class := or (.Get "class") "" }}
{{- $delimiter := or (.Get "delimiter") "," }}
{{- $id := or (.Get "id") (printf "h-sc-%s-%d" $name $ordinal) }}
{{- $path := .Get "path" }}
{{- if not $path }}
  {{- errorf "The %q shortcode requires a path parameter. See %s" $name $position }}
{{- end }}
{{- $hasHeaderRow := true }}
{{- if isset .Params "hasHeaderRow" }}
  {{- if in (slice false "false") (.Get "hasHeaderRow") }}
    {{- $hasHeaderRow = false }}
  {{- end }}
{{- end }}

{{- /* Define attributes map. */}}
{{- $attrs := dict "class" $class "id" $id }}

{{- /* Get resource. */}}
{{- $r := "" }}
{{- $u := urls.Parse $path }}
{{- if $u.IsAbs }}
  {{- with resources.GetRemote $u.String }}
    {{- with .Err }}
      {{- errorf "%s" . }}
    {{- else }}
      {{- /* Path is a remote resource. */}}
      {{- $r = . }}
    {{- end }}
  {{- else }}
    {{- errorf "The %q shortcode was unable to get %s. See %s" $.Name $u.String $.Position }}
  {{- end }}
{{- else }}
  {{- with .Page.Resources.Get (strings.TrimPrefix "./" $u.Path) }}
    {{- /* Path is a page resource. */}}
    {{- $r = . }}
  {{- else }}
    {{- with (and (ne .Page.BundleType "leaf") (.Page.CurrentSection.Resources.Get (strings.TrimPrefix "./" $u.Path))) }}
      {{- /* Path is a section resource, and current page is not a leaf bundle. */}}
      {{- $r = . }}
    {{- else }}
      {{- with resources.Get $u.Path }}
        {{- /* Path is a global resource. */}}
        {{- $r = . }}
      {{- else }}
        {{- errorf "The %q shortcode was unable to get %s. See %s" $.Name $u.String $u.Path }}
      {{- end }}
    {{- end }}
  {{- end }}
{{- end }}

{{- /* Unmarshal resource. */}}
{{- $data := unmarshal (dict "delimiter" $delimiter) $r }}

{{- /* Render.*/}}
<table
  {{- range $k, $v := $attrs }}
    {{- if $v }}
      {{- printf " %s=%q" $k $v | safeHTMLAttr }}
    {{- end }}
  {{- end -}}
>
{{- with $caption }}
  <caption>{{ . }}</caption>
{{- end }}
{{- $rows := slice }}
{{- if $hasHeaderRow }}
  {{- $headerRow := index $data 0 }}
  {{- $rows = after 1 $data }}
  <thead>
    <tr>
      {{- range $headerRow }}
        <th>{{ . }}</th>
      {{- end }}
    </tr>
  </thead>
{{- else }}
  {{- $rows = $data }}
{{- end }}
<tbody>
 {{- range $i, $row := $rows }}
    <tr>
      {{- range $row }}
        <td>{{ . }}</td>
      {{- end }}
    </tr>
  {{- end }}
  </tbody>
</table>

Access the data

Hugo unmarshals CSV data to an array of arrays. To query or iterate over the data, use a partial to transform CSV data into a usable structure—an array of maps. The CSV file must have a header row.

Arguments

page
(page) A page reference, typically the current page.
path
(string) The path to the CSV file: a remote resource, a page resource, or a global resource in the assets directory.
delimiter
(string) Optional. The delimiting character between the fields in each row. The default value is a comma.

Example

{{ $options := dict "page" . "path" "pets.csv" }}
{{ $data := partial "csv-to-data.html" $options }}

The resulting maps include a key/value pair indicating the original position in the CSV file.

[
  {
    "age": "3",
    "breed": "Collie",
    "name": "Spot",
    "row": 1,
    "type": "dog"
  },
  {
    "age": "5",
    "breed": "Boxer",
    "name": "Rover",
    "row": 2,
    "type": "dog"
  },
  {
    "age": "7",
    "breed": "Malicious",
    "name": "Felix",
    "row": 3,
    "type": "cat"
  }
]

Now you can query and iterate over the data.

{{ range where $data "type" "dog" }}
  {{ .name }}
{{ end }}

Source code

layouts/partials/csv-to-data.html
{{- /* Last modified: 2023-12-10T09:45:41-08:00 */}}

{{- /*
Copyright 2023 Veriphor LLC

Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}

{{- /*
Returns a slice of maps from a CSV file.

This partial resolves internal paths by looking for a matching:

  1. Page resource (a file in the current page bundle)
  2. Section resource (a file in the current section)
  3. Global resource (a file in the assets directory)

It skips the section resource lookup if the current page is a leaf bundle, and
captures external files as resources.

You must place global resources in the assets directory. If you have placed
your resources in the static directory, and you are unable or unwilling to move
them, you must mount the static directory to the assets directory by including
both of these entries in your site configuration:

  [[module.mounts]]
  source = 'assets'
  target = 'assets'

  [[module.mounts]]
  source = 'static'
  target = 'assets'

The CSV file must have a header row.

@context {page} page A reference to the page with the page resource, or the current page.
@context {string} path The path to the CSV file.
@context {string} [delimiter=","] The delimiting character between the fields in each row.

@returns {template.html}

@example {{ $data := partial "csv-to-map.html" $options (dict "page" . "path" "pets.csv") }}
*/}}

{{- /* Initialize. */}}
{{- $partialName := "csv-to-data" }}

{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.114.0" }}
{{- if lt hugo.Version $minHugoVersion }}
  {{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}

{{- /* Get context. */}}
{{- $page := .page }}
{{- $path := .path }}
{{- $delimiter := or .delimiter "," }}

{{- /* Validate path. */}}
{{- if not $path }}
  {{- errorf "The %q partial requires a path parameter" $partialName }}
{{- end }}

{{- /* Get resource. */}}
{{- $r := "" }}
{{- $u := urls.Parse $path }}
{{- if $u.IsAbs }}
  {{- with resources.GetRemote $u.String }}
    {{- with .Err }}
      {{- errorf "%s" . }}
    {{- else }}
      {{- /* Path is a remote resource. */}}
      {{- $r = . }}
    {{- end }}
  {{- else }}
    {{- errorf "The %q partial was unable to find %q" $partialName $path }}
  {{- end }}
{{- else }}
  {{- with $page.Resources.Get (strings.TrimPrefix "./" $u.Path) }}
    {{- $r = . }}
  {{- else }}
    {{- with (and (ne $page.BundleType "leaf") ($page.CurrentSection.Resources.Get (strings.TrimPrefix "./" $u.Path))) }}
      {{- /* Path is a section resource, and current page is not a leaf bundle. */}}
      {{- $r = . }}
    {{- else }}
      {{- with resources.Get $u.Path }}
        {{- /* Path is a global resource. */}}
        {{- $r = . }}
      {{- else }}
        {{- errorf "xThe %q partial was unable to find %q" $partialName $path }}
      {{- end }}
    {{- end }}
  {{- end }}
{{- end }}

{{- /* Unmarshal resource. */}}
{{- $data := unmarshal (dict "delimiter" $delimiter) $r }}
{{- $headerRow := index $data 0 }}
{{- $s := slice }}
{{- range $k, $v := $data }}
  {{- if $k }}
    {{- $m := dict "row" $k }}
    {{- range $k, $v := . }}
      {{- $m = merge $m (dict (index $headerRow $k) $v) }}
    {{- end }}
    {{- $s = $s | append $m }}
  {{- end }}
{{- end }}
{{- $data = $s }}

{{- /* Return data. */}}
{{- return $data }}
Last modified: