Skip to main content
Carefully implemented forms

Reusable Gohugo Forms via Configuration

A while back (pre-COVID-19 era), I had the idea to create a GoHugo module that would allow me to create forms via configuration files. I also wanted to easily translate the form into different languages. I sketched out a solution but never got around to implementing it. I recently stumbled upon the scratch pad and decided to finally implement it. This article is a write-up of the process, and the first final result can be seen with the contact form on this site.

Some quick sidenotes:

  • I added settings required for Netlify’s form processing.
  • Many classes and controls are based on Bootstrap 5. It is easy to adapt the form to other CSS frameworks.
  • Currently, many attributes are printed as they are defined. No sanitation is done. So, the form is as safe as your configuration/translation files are.

I added the shortcode to my shortcodes module. That makes it easier to transport to other sites I am working on. At the time of writing this article, the files in use are the following:

Things might change because this is a “work in progress” with plenty of features I want to add. But for now, let’s dive into the details of the first incarnation: A contact form.

Configuration in config/_default/params.toml

1[dnb]
2  [dnb.forms]
3    [dnb.forms.contactform]
4      groups = true
5      groupstyle = 'grid'
6      id = 'contactform'
7      labelling = 'i18n'
1dnb:
2  forms:
3    contactform:
4      groups: true
5      groupstyle: grid
6      id: contactform
7      labelling: i18n
 1{
 2   "dnb": {
 3      "forms": {
 4         "contactform": {
 5            "groups": true,
 6            "groupstyle": "grid",
 7            "id": "contactform",
 8            "labelling": "i18n"
 9         }
10      }
11   }
12}

All config options I have implemented in my modules live in the dnb namespace. The dnb.forms section defines the configuration for the form shortcode. Lastly, the contactform part is the identifier for the form we are using. This could be any string and is used to separate the configurations for different forms.

The id is used as the id and name attribute for the form.

The labelling option defines how the labels and names for the form fields are generated. The default is i18n, meaning the labels are translated using the i18n function. The other option would be inline and directly use the field’s name and label attributes (not implemented yet).

The groups option defines whether the form fields are grouped. If groups is set to true, then the groupstyle option defines how the fields are grouped. The default is grid, meaning the fields are grouped in a grid layout. Another option would be fieldset, which would group the fields in fieldsets (not implemented yet).

Two options I left out are method (defaults to post) and action (defaults to "" (empty)) that set their form tag attribute counterparts.

1[dnb]
2  [dnb.forms]
3    [dnb.forms.contactform]
4      [dnb.forms.contactform.attributes]
5        class = 'mb-6'
6        data-netlify = 'true'
7        netlify-honeypot = '%random%'
1dnb:
2  forms:
3    contactform:
4      attributes:
5        class: mb-6
6        data-netlify: "true"
7        netlify-honeypot: '%random%'
 1{
 2   "dnb": {
 3      "forms": {
 4         "contactform": {
 5            "attributes": {
 6               "class": "mb-6",
 7               "data-netlify": "true",
 8               "netlify-honeypot": "%random%"
 9            }
10         }
11      }
12   }
13}

The attributes section defines the attributes for the form tag. Every option will be added as key="value".

As you can see, I am using Netlify’s form processing as the delivery method for my form. For that to work, you must add attributes to the form tag. The data-netlify attribute is used to enable Netlify’s form handling. The netlify-honeypot attribute is used to define the name of the honeypot field.

The class attribute defines the CSS classes for the form.

The keys and values in this section can be anything you want. The only important thing is that the values are strings enclosed in double quotation marks. If you wish to use a string containing quotation marks, you must use backslashes before them. I can’t think of any use case for this.

 1[dnb]
 2  [dnb.forms]
 3    [dnb.forms.contactform]
 4      [dnb.forms.contactform.fields]
 5        [[dnb.forms.contactform.fields.1]]
 6          label = 'shortcodes.form.name'
 7          name = 'shortcodes.form.fieldnames.name'
 8          required = true
 9          type = 'text'
10        [[dnb.forms.contactform.fields.1]]
11          html = '<div data-netlify-recaptcha="true"></div>'
12          type = 'special'
13        [[dnb.forms.contactform.fields.1]]
14          name = '%random%'
15          type = 'invisible'
16        [[dnb.forms.contactform.fields.2]]
17          label = 'shortcodes.form.message'
18          name = 'shortcodes.form.fieldnames.message'
19          required = true
20          type = 'textarea'
21          [dnb.forms.contactform.fields.2.options]
22            rows = 8
 1dnb:
 2  forms:
 3    contactform:
 4      fields:
 5        "1":
 6        - label: shortcodes.form.name
 7          name: shortcodes.form.fieldnames.name
 8          required: true
 9          type: text
10        - html: <div data-netlify-recaptcha="true"></div>
11          type: special
12        - name: '%random%'
13          type: invisible
14        "2":
15        - label: shortcodes.form.message
16          name: shortcodes.form.fieldnames.message
17          options:
18            rows: 8
19          required: true
20          type: textarea
 1{
 2   "dnb": {
 3      "forms": {
 4         "contactform": {
 5            "fields": {
 6               "1": [
 7                  {
 8                     "label": "shortcodes.form.name",
 9                     "name": "shortcodes.form.fieldnames.name",
10                     "required": true,
11                     "type": "text"
12                  },
13                  {
14                     "html": "\u003cdiv data-netlify-recaptcha=\"true\"\u003e\u003c/div\u003e",
15                     "type": "special"
16                  },
17                  {
18                     "name": "%random%",
19                     "type": "invisible"
20                  }
21               ],
22               "2": [
23                  {
24                     "label": "shortcodes.form.message",
25                     "name": "shortcodes.form.fieldnames.message",
26                     "options": {
27                        "rows": 8
28                     },
29                     "required": true,
30                     "type": "textarea"
31                  }
32               ]
33            }
34         }
35      }
36   }
37}

Now, the process of adding form fields. Because I only implemented the groups/grid mode (fields are part of a group and will be contained in a container that can be styled via CSS Grid), I will only explain how to configure this mode.

The fields section is an array of arrays. The outer array defines the groups, and the inner arrays represent the fields. The order of the fields is how they will be rendered. The group name can be anything.

Each field can have the following attributes:

  • name - the field’s name, used as id and name attribute. If labelling is set to i18n, the name will be translated using the i18n function. (As I am writing this, I don’t know if that makes much sense for the name field, but I will leave it in for now. Be careful to not use any special characters and spaces in the name.) The name MUST be unique in the whole form.
  • label - the label of the field. If labelling is set to i18n, the label will be translated using the i18n function.
  • type - the type of the field. The default is text. All HTML field types (like date, tel, etc.) are supported. In addition, the following fields are supported:
    • textarea - a textarea field. The rows option can define the number of rows. You can design the field via CSS.
    • invisible - an input field that is not visible. This can be used as a honeypot for antispam measures. Note that this field is not of the type hidden, but a regular text field. The name’s %random attribute is replaced with a random string.
    • special - a field defined via the html attribute. Note that this is added as is and is not escaped. So be careful with this one. Quotation marks need to be escaped with a backslash.
  • required - if set to true, the field will be marked as required. The default is false.
  • class - the CSS classes for the field. The default is form-control. This is only used for the text (HTML types) and textarea fields.
  • options - a section that can be used to define options for the field. Only the rows option for the textarea field is currently supported.
1[dnb]
2  [dnb.forms]
3    [dnb.forms.contactform]
4      [[dnb.forms.contactform.buttons]]
5        class = 'btn btn-primary'
6        label = 'shortcodes.form.submit'
7        name = 'shortcodes.form.fieldnames.submit'
8        type = 'submit'
1dnb:
2  forms:
3    contactform:
4      buttons:
5      - class: btn btn-primary
6        label: shortcodes.form.submit
7        name: shortcodes.form.fieldnames.submit
8        type: submit
 1{
 2   "dnb": {
 3      "forms": {
 4         "contactform": {
 5            "buttons": [
 6               {
 7                  "class": "btn btn-primary",
 8                  "label": "shortcodes.form.submit",
 9                  "name": "shortcodes.form.fieldnames.submit",
10                  "type": "submit"
11               }
12            ]
13         }
14      }
15   }
16}

The buttons section is an array of buttons. Each button can have the following attributes:

  • type - the type of the button. The default is submit. All HTML button types are supported.
  • name - the name of the button. If labelling is set to i18n, the name will be translated using the i18n function.
  • label - the label of the button. If labelling is set to i18n, the label will be translated using the i18n function.
  • class - the CSS classes for the button.

This needs much improvement. For instance, we currently do not have upload, select, options, checkbox, and radio fields. But this is a good start. I also think that with some use of the brain, the upload, checkbox, and radio fields can be used with the existing system.

Internationalization in i18n/en.toml

The labelling attribute in the form configuration preceding sets the way of labelling the form to i18n. All fields can be configured via i18n/en.toml.

 1[shortcodes]
 2  [shortcodes.contactform]
 3    [shortcodes.contactform.fieldnames]
 4      [shortcodes.contactform.fieldnames.email]
 5        other = 'Email'
 6      [shortcodes.contactform.fieldnames.message]
 7        other = 'Message'
 8      [shortcodes.contactform.fieldnames.name]
 9        other = 'Name'
10      [shortcodes.contactform.fieldnames.subject]
11        other = 'Subject'
12    [shortcodes.contactform.name]
13      description = 'Form label for the name field'
14      other = 'Your Name'
 1shortcodes:
 2  contactform:
 3    fieldnames:
 4      email:
 5        other: Email
 6      message:
 7        other: Message
 8      name:
 9        other: Name
10      subject:
11        other: Subject
12    name:
13      description: Form label for the name field
14      other: Your Name
 1{
 2   "shortcodes": {
 3      "contactform": {
 4         "fieldnames": {
 5            "email": {
 6               "other": "Email"
 7            },
 8            "message": {
 9               "other": "Message"
10            },
11            "name": {
12               "other": "Name"
13            },
14            "subject": {
15               "other": "Subject"
16            }
17         },
18         "name": {
19            "description": "Form label for the name field",
20            "other": "Your Name"
21         }
22      }
23   }
24}

For instance, in Netlify’s form processing, the field name is the key for the email sent to the recipient. So, having the field names in the i18n system makes sense. Because currently, there is no option other than the i18n method for labelling the form, so we will have to add an i18n configuration for all form elements.

Form generation in layouts/shortcodes/form.html

This layout file uses the configuration defined in params.toml to generate the HTML for the form. It is long, so let’s break down the essential parts. The first few lines are used to get the configuration from params.toml above and to set some defaults.

Then, the form itself is generated. As I wrote above, the only implemented groups method is grid, so lines 6-14 are building the containers and fields for the form, and lines 16-24 are creating the buttons section of the form. Both sections use inline partials, which I will explain below.

 1<form {{ $formAttributes | safe.HTMLAttr }} class="{{ $formConfig.classes | safeHTMLAttr }}">
 2  {{- $groups := $formConfig.groups | default "false" -}}
 3  {{- if eq $groups "false" -}}
 4    {{- /* @todo ungrouped forms */ -}}
 5  {{- else -}}
 6    <div class="row g-3">
 7      {{ range $formConfig.fields }}
 8        <div class="col col-sm-6">
 9          {{ range . }}
10            {{ partial "dnb-forms-inlinetemplate-formfield" . }}
11          {{ end }}
12        </div>
13      {{ end }}
14    </div>
15  {{- end -}}
16  <div class="row">
17    {{ range $formConfig.buttons -}}
18      <div class="col-12">
19        {{ range $formConfig.buttons }}
20          {{ partial "dnb-forms-inlinetemplate-button" . }}
21        {{ end }}
22      </div>
23    {{ end }}
24  </div>
25</form>

The actual execution of the form field and button creation is done by inline partials. I never used inline partials before, but for this use, that made much more sense than moving these actions into ranged partials.

I don’t really like inline partials too much because of the following sentence in the documentation:

… remember that template namespace is global, so you need to make sure that the names are unique to avoid conflicts.

This means it’s a gamble if you are not obsessively specific with your partial name (like the ones I used here). It also means that the partial is not confined to the file it is used in. I can’t wrap my head around this yet. Besides having everything nice and neatly in one file, I don’t see any advantage of inline partials.

Let’s keep using them for now so the code is within a single file.

 1{{ define "partials/dnb-forms-inlinetemplate-formfield" }}
 2  {{- $fieldRequired := .required | default "false" -}}
 3  {{- if eq $fieldRequired "true" -}}
 4    {{- $fieldRequired = "required" -}}
 5  {{- else -}}
 6    {{- $fieldRequired = "" -}}
 7  {{- end -}}
 8  {{- $fieldId := i18n .name | lower -}}
 9  {{- $fieldType := .type | default "text" -}}
10
11  {{- if eq "invisible" $fieldType -}}
12
13    {{- $random := partial "func/getRandomString.html" (dict "limit" 10) -}}
14    <label class="d-none invisible" for="{{- $random -}}">{{- $random -}}</label>
15    <input type="text" name="{{- $random -}}" id="{{- $random -}}" class="d-none invisible">
16
17  {{- else if eq "special" $fieldType -}}
18
19    {{- .html | safeHTML -}}
20
21  {{- else if eq "textarea" $fieldType -}}
22
23      <div class="mb-3">
24        <label class="form-label"
25                for="{{- $fieldId -}}">
26          {{ i18n .label }}
27        </label>
28        <textarea class="form-control"
29                  id="{{- $fieldId -}}"
30                  name="{{- $fieldId -}}"
31                  rows="{{- .options.rows | default 5 -}}"
32                  {{ $fieldRequired | safe.HTMLAttr }}></textarea>
33      </div>
34
35  {{- else -}}
36
37    <div class="mb-3">
38      <label class="form-label"
39              for="{{ $fieldId }}">
40        {{ i18n .label }}
41      </label>
42      <input class="form-control"
43              name="{{- $fieldId -}}"
44              id="{{- $fieldId -}}"
45              type="{{- .type -}}"
46              {{ $fieldRequired | safe.HTMLAttr }}>
47    </div>
48
49  {{- end -}}
50{{ end }}

This partial checks the type of the field and renders the appropriate HTML. The textarea field is a bit more complex because it has an options section. Other fields will be added later. As I wrote above, the special field is rendered as is, so be careful with that one.

Then there is the button partial:

1{{- define "partials/dnb-forms-inlinetemplate-button" -}}
2  {{- $buttonAttributes := printf "class=\"%s\" type=\"%s\" value=\"%s\""
3      .class
4      (.type | default "submit")
5      (i18n .label) -}}
6  <div class="mb-3">
7    <input {{ $buttonAttributes | safeHTMLAttr  -}}>
8  </div>
9{{- end -}}

That one is pretty simple. It creates the button with the attributes defined in the configuration.

A note on the getRandomString function

The getRandomString function generates random strings for the honeypot field. It is defined in layouts/partials/func/getRandomString.html in my functions module. It is a reliable, reusable function in my arsenal that is used in a lot of places.

1{{ $seed := printf "%s%s" site.Title now.Unix }}
2{{ if isset . "seed" }}
3  {{ $seed = .seed }}
4{{ end }}
5{{ $limit := .limit | default 12 }}
6{{ $random := delimit (shuffle (split (md5 $seed) "" )) "" }}
7{{ return substr $random 0 $limit }}

This is an excellent way to quickly create a random string for any requirements.

1{{- $random := partialCached "func/getRandomString" (dict "limit" 8) -}}
2{{- $random := partialCached "func/getRandomString" . -}}
  • calling without parameters returns 12 characters
  • call with a limit parameter to select the amount of characters returned
  • call with a seed parameter will use that string instead of the site.Title to create the random string
  • call with partialCached and a unique seed to reuse the random string

The result

This all looks more complicated than it is. The result is a form that can be configured via params.toml and translated via i18n/en.toml. The form can be used in any content file by adding the shortcode:

1{{< form id="contactform" >}}

You can see it live on my contact page. Not that you ever would fill a contact form and send it, actually ;]

That’s all for now

Thank you for reading all this (or scrolling down to the end). The TLDR is that I have created a rudimentary shortcode that enables you to build a form by adding some configuration and, if you wish, some i18n setup without touching the code itself.

There are plenty of things that can be improved. There are plenty of things that can be added. For instance, the whole CSS class system could be put into an internal configuration to replace it with another CSS framework.

But for now, I am happy with the result. The next step will be to use the shortcode for other websites I am working on. I have, for instance, a booking form for holiday tours in mind that will test the limits and future requirements of this shortcode. I will post an update when I have implemented that.

Back to top
Back Forward