Chinese Alligator

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:

All the Features in the Create-Table Shortcode
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>