A Table Shortcode for Hugo
I write a lot of notes in Markdown files and use Hugo to display them in a browser. It makes for easy reading and jumping among them. Several months ago, I needed to create some tables where some cells spanned mulitple rows or columns. That’s not possible with Markdown tables. Creating tables in HTML with appropriate classes seemed tedious. I thought it might be less so by defining tables in TOML and use a shortcode to translate them into HTML.
I might be wrong. It turns out that it’s very hard to define a table in TOML. It’s even harder to define one where a cell can span two or more rows or columns. Once you have that, you still have to write a lot of CSS to get it to display properly.
What I have is a way to turn a TOML table stored in /data/complete-example.toml
into this:
Header R2C1 | Header R2C2 | Header R2C3 |
---|---|---|
Header R3C1 | Header R3C2 | Header R3C3 |
Header R4C1 | Header R4C2 | Header R4C3 |
a | b | c |
d | e | f |
Header R1C1 | Header R1C2 | Header R1C3 |
foot1 | foot2 | foot3 |
It’s not a complete solution, but it’s enough given the effort to get this far.In the future, if I need fancy tables, I think I’ll just embed HTML into the markdown.
The contents of /data/complete-example.toml
is:
# Table caption
caption = "All the Features in the create_table Shortcode"
[header]
hd1 = ["Header R1C1", "Header R1C2", "Header R1C3"]
[[head]]
hd1 = ["Header R2C1", "Header R2C2", "Header R2C3"]
[[head]]
hd1 = ["Header R3C1", "Header R3C2", "Header R3C3"]
hd2 = ["Header R4C1", "Header R4C2", "Header R4C3"]
[[body]]
row = ["a", "b", "c"]
[[body]]
row = ["d", "e", "f"]
# Don't embed colgroup in another array. It becomes an embedded map.
# [[head.colgroup]]
# span = 1
# [[head.colgroup]]
# span = 2
[[foot]]
row = ["foot1", "foot2", "foot3"]
# This map is at the top-level of the table and can be used to create an HTML
# colgroup for the table. One entry for each <col> element.
[[colgroup]]
span = 1
[[colgroup]]
span = 2
class = "odd"
[[colgroup]]
span = 1
class = "even"
To use the shortcode on this file, include {{< create_table src="complete-example" >}}
in a markdown file.
The create_table Shortcode
This shortcode starts with realizing Hugo’s built-in .Site.Data
variable isn’t a path to a configured data
directory. It’s a map of all files in the data folder, its subfolders, the files contained in those subfolders, and the contents of all those files. On one hand, that’s rather insane. On the other, it gets all that data into memory so Hugo can operate on it more quickly than if files had to be individually loaded and parsed.
Let’s capture that in a variable for use later:
<!--
.Site.Data isn't a path. It's a map of all the files in the data folder,
its subfolders and their files, and the contents of all those files.
-->
{{- $data := .Site.Data -}}
Next, get the data that’s in the given file. The file is given in the src
attribute:
{{- $src := .Get "src" -}}
{{- $pathComponents := split $src "/" -}}
Here’s the code to pull out the data just for that file:
<!--
Use the components of the src path to rummage through the map-of-maps in
$data to get the src table's contents.
-->
{{- range $component := $pathComponents -}}
{{- if ne $data nil -}}
{{- $data = index $data $component -}}
{{- end -}}
{{- end -}}
From here, I’ll give a quick top-down view of the shortcode. It defines a table as an HTML <table>
element with a class of the same name. The contents of the table is generated from a buildTable
template and our data:
<table class="table">
{{- template "buildTable" $data -}}
</table>
buildTable Template
The buildTable
template attempts to determine if the TOML data has a head and body. It also captures the data for a caption, any column groups, the head, body, and foot of the table. It starts with initializing some variables.
<!-- Table data is always a map -->
{{- $table := . -}}
{{- $haveHead := false -}}
{{- $haveBody := false -}}
{{- $captionValue := "" -}}
{{- $colGroupValue := "" -}}
{{- $bodyValue := "" -}}
{{- $headValue := "" -}}
{{- $footValue := "" -}}
The next step is to collect all the keys in the map. The keys are expected to be the components of an HTML table.
- body
- caption
- colgroup
- head
- foot
<!-- collect all the keys in the map -->
{{- range $key, $val := $table -}}
{{- if eq $key "body" -}}
{{- if $haveBody -}}
{{- $haveBody = $haveBody | append $val -}}
{{- else -}}
{{- $haveBody = true -}}
{{- $bodyValue = $val}}
{{- end -}}
{{- else if eq $key "caption" -}}
{{- $captionValue = $val -}}
{{- else if eq $key "colgroup" -}}
{{- $colGroupValue = $val -}}
{{- else if eq $key "head" -}}
{{- $haveHead = true -}}
{{- $headValue = $val -}}
{{- else if ne $key "foot" -}}
{{- if $haveBody -}}
{{- $bodyValue = $bodyValue | append $val -}}
{{- else -}}
{{- $haveBody = true -}}
{{- $bodyValue = $val}}
{{- end -}}
{{- else -}}<!-- key is foot -->
{{- $footValue = $val -}}
{{- end -}}
{{- end -}}
Now we can fill in all those variables.
{{- if ne $captionValue "" -}}
<caption>{{- $captionValue -}}</caption>
{{- end -}}
{{- if ne $colGroupValue "" -}}
<colgroup>
{{- template "createColgroup" $colGroupValue -}}
</colgroup>
{{- end -}}
{{- if ne $headValue "" -}}
<thead>
{{- template "createRowsHead" $headValue -}}
</thead>
{{- end -}}
{{- if ne $bodyValue "" -}}
<tbody>
{{- template "createRows" $bodyValue -}}
</tbody>
{{- end -}}
{{- if ne $footValue "" -}}
<tfoot>
{{- template "createRows" $footValue -}}
</tfoot>
{{- end -}}
There are several templates used by that chunk of code. They are small, so we can go through them quickly.
createColGroup Template
createColGroup
creates column groups with the <col>
element:
{{- define "createColgroup" -}}
{{- $colgroup := . -}}
{{- range $idx, $col := $colgroup}}
<col{{range $attr, $val := $col}} {{$attr}}="{{$val}}" {{end}}>
{{- end -}}
{{- end -}}
createRowsHead and processMapHead Templates
These two templates are mutually recursive. createRowsHead
creates rows in the <head>
section of the table. It processes each element of the row, creating a new <th>
element as it goes.
<!-- Use only within thead elements. This will generate
<tr><th>...</th><th>...</th>...</tr> row sequences
-->
{{- define "createRowsHead" -}}
{{- $rows := . -}}
{{- range $idx, $row := $rows -}}
{{- if and (not (reflect.IsMap $row)) (not (reflect.IsSlice $row)) -}}
<!-- row value is a scalar -->
<th>{{- $row -}}</th>
{{- else if reflect.IsMap $row -}}
<!-- row value is a map -->
{{- template "processMapHead" $row -}}
{{- else if reflect.IsSlice $row}}
<!-- row value is a slice -->
{{- template "createRowsHead" $row -}}
{{- else -}}
<!-- row value type is unknown -->
{{- $idx -}} (head-row-unknown): {{- $row -}}
{{- end -}}
{{- end -}}
{{- end -}}
If a new row needs to be generated, createRowsHead
calls processMapHead
which generates a new <tr>
element. Likewise, processMapHead
will call back to createRowsHead
in the case that a column has more than one value.
{{- define "processMapHead" -}}
{{- $row := . -}}
{{- range $k, $v := $row -}}
{{- if and (not (reflect.IsMap $v)) (not (reflect.IsSlice $v)) -}}
<td>{{- $k -}} (map->scalar): ({{$v}})</td>
{{- else if reflect.IsMap $v -}}
<tr class="head">{{- $k -}} (map-map): {{- template "processMapHead" $v -}}</tr>
{{- else if reflect.IsSlice $v}}
<tr class="head">{{- template "createRowsHead" $v -}}</tr>
{{- else -}}
<td>{{$k}} (map-unknown): {{- $v -}}</td>
{{- end -}}
{{- end -}}
{{- end -}}
createRows and processMap Templates
These two templates are mutually recursive. createRows
creates the column (<td>
) elements for each row in <body>
or <foot>
element. Note that createRows
is a recursive template, so it can handle some weirdly designed tables.
<!-- Use within <table>, <tbody>, or <tfoot> elements, but not within <thead>;
this will generate <tr><td>...</td><td>...</td>...</tr> row sequences
-->
{{- define "createRows" -}}
{{- $rows := . -}}
{{- range $idx, $row := $rows -}}
{{- if and (not (reflect.IsMap $row)) (not (reflect.IsSlice $row)) -}}
<!-- row value is a scalar -->
<td>{{- $row -}}</td>
{{- else if reflect.IsMap $row -}}
<!-- row value is a map -->
{{- template "processMap" $row -}}
{{- else if reflect.IsSlice $row}}
<!-- row value is a slice -->
{{- template "createRows" $row -}}
{{- else -}}
<!-- row value type is unknown -->
{{- $idx -}} (rows-unknown): {{- $row -}}
{{- end -}}
{{- end -}}
{{- end -}}
processMap
either creates columns in a row, or generates a new row and calls itself recursively. If a column has more than one value, it calls back to createRows
to process them.
{{- define "processMap" -}}
{{- $row := . -}}
{{- range $k, $v := $row -}}
{{- if and (not (reflect.IsMap $v)) (not (reflect.IsSlice $v)) -}}
<td>{{- $k -}} (map->scalar): ({{$v}})</td>
{{- else if reflect.IsMap $v -}}
<tr class="body">{{- $k -}} (map-map): {{- template "processMap" $v -}}</tr>
{{- else if reflect.IsSlice $v}}
<tr class="body">{{- template "createRows" $v -}}</tr>
{{- else -}}
<td>{{$k}} (map-unknown): {{- $v -}}</td>
{{- end -}}
{{- end -}}
{{- end -}}
The Complete create_table Shortcode
Here’s the whole thing w/o comments. It’s not that useful, but it was a journey and an education to get here.
<!--
.Site.Data isn't a path. It's a map of all the files in the data folder,
its subfolders and their files, and the contents of all those files.
-->
{{- $data := .Site.Data -}}
{{- $src := .Get "src" -}}
{{- $pathComponents := split $src "/" -}}
<!--
Use the components of the src path to rummage through the map-of-maps in
$data to get the src table's contents.
-->
{{- range $component := $pathComponents -}}
{{- if ne $data nil -}}
{{- $data = index $data $component -}}
{{- end -}}
{{- end -}}
{{- define "processMapHead" -}}
{{- $row := . -}}
{{- range $k, $v := $row -}}
{{- if and (not (reflect.IsMap $v)) (not (reflect.IsSlice $v)) -}}
<td>{{- $k -}} (map->scalar): ({{$v}})</td>
{{- else if reflect.IsMap $v -}}
<tr class="head">{{- $k -}} (map-map): {{- template "processMapHead" $v -}}</tr>
{{- else if reflect.IsSlice $v}}
<tr class="head">{{- template "createRowsHead" $v -}}</tr>
{{- else -}}
<td>{{$k}} (map-unknown): {{- $v -}}</td>
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "processMap" -}}
{{- $row := . -}}
{{- range $k, $v := $row -}}
{{- if and (not (reflect.IsMap $v)) (not (reflect.IsSlice $v)) -}}
<td>{{- $k -}} (map->scalar): ({{$v}})</td>
{{- else if reflect.IsMap $v -}}
<tr class="body">{{- $k -}} (map-map): {{- template "processMap" $v -}}</tr>
{{- else if reflect.IsSlice $v}}
<tr class="body">{{- template "createRows" $v -}}</tr>
{{- else -}}
<td>{{$k}} (map-unknown): {{- $v -}}</td>
{{- end -}}
{{- end -}}
{{- end -}}
<!-- Use only within thead elements. This will generate
<tr><th>...</th><th>...</th>...</tr> row sequences
-->
{{- define "createRowsHead" -}}
{{- $rows := . -}}
{{- range $idx, $row := $rows -}}
{{- if and (not (reflect.IsMap $row)) (not (reflect.IsSlice $row)) -}}
<!-- row value is a scalar -->
<th>{{- $row -}}</th>
{{- else if reflect.IsMap $row -}}
<!-- row value is a map -->
{{- template "processMapHead" $row -}}
{{- else if reflect.IsSlice $row}}
<!-- row value is a slice -->
{{- template "createRowsHead" $row -}}
{{- else -}}
<!-- row value type is unknown -->
{{- $idx -}} (head-row-unknown): {{- $row -}}
{{- end -}}
{{- end -}}
{{- end -}}
<!-- Use within <table>, <tbody>, or <tfoot> elements, but not within <thead>;
this will generate <tr><td>...</td><td>...</td>...</tr> row sequences
-->
{{- define "createRows" -}}
{{- $rows := . -}}
{{- range $idx, $row := $rows -}}
{{- if and (not (reflect.IsMap $row)) (not (reflect.IsSlice $row)) -}}
<!-- row value is a scalar -->
<td>{{- $row -}}</td>
{{- else if reflect.IsMap $row -}}
<!-- row value is a map -->
{{- template "processMap" $row -}}
{{- else if reflect.IsSlice $row}}
<!-- row value is a slice -->
{{- template "createRows" $row -}}
{{- else -}}
<!-- row value type is unknown -->
{{- $idx -}} (rows-unknown): {{- $row -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "createColgroup" -}}
{{- $colgroup := . -}}
{{- range $idx, $col := $colgroup}}
<col{{range $attr, $val := $col}} {{$attr}}="{{$val}}" {{end}}>
{{- end -}}
{{- end -}}
{{- define "buildTable" -}}
<!-- Table data is always a map -->
{{- $table := . -}}
{{- $haveHead := false -}}
{{- $haveBody := false -}}
{{- $captionValue := "" -}}
{{- $colGroupValue := "" -}}
{{- $bodyValue := "" -}}
{{- $headValue := "" -}}
{{- $footValue := "" -}}
<!-- collect all the keys in the map -->
{{- range $key, $val := $table -}}
{{- if eq $key "body" -}}
{{- if $haveBody -}}
{{- $haveBody = $haveBody | append $val -}}
{{- else -}}
{{- $haveBody = true -}}
{{- $bodyValue = $val}}
{{- end -}}
{{- else if eq $key "caption" -}}
{{- $captionValue = $val -}}
{{- else if eq $key "colgroup" -}}
{{- $colGroupValue = $val -}}
{{- else if eq $key "head" -}}
{{- $haveHead = true -}}
{{- $headValue = $val -}}
{{- else if ne $key "foot" -}}
{{- if $haveBody -}}
{{- $bodyValue = $bodyValue | append $val -}}
{{- else -}}
{{- $haveBody = true -}}
{{- $bodyValue = $val}}
{{- end -}}
{{- else -}}<!-- key is foot -->
{{- $footValue = $val -}}
{{- end -}}
{{- end -}}
{{- if ne $captionValue "" -}}
<caption>{{- $captionValue -}}</caption>
{{- end -}}
{{- if ne $colGroupValue "" -}}
<colgroup>
{{- template "createColgroup" $colGroupValue -}}
</colgroup>
{{- end -}}
{{- if ne $headValue "" -}}
<thead>
{{- template "createRowsHead" $headValue -}}
</thead>
{{- end -}}
{{- if ne $bodyValue "" -}}
<tbody>
{{- template "createRows" $bodyValue -}}
</tbody>
{{- end -}}
{{- if ne $footValue "" -}}
<tfoot>
{{- template "createRows" $footValue -}}
</tfoot>
{{- end -}}
{{- end -}}
<table class="table">
{{- template "buildTable" $data -}}
</table>