DE

Hugo in Neovim

With the right tweaks, Neovim becomes a powerful tool for working with Hugo. In this guide, we’ll set up syntax highlighting for Hugo templates, ensure proper code block indentation, and integrate an auto-formatter that seamlessly handles HTML, Markdown, and Hugo-specific template files.

To streamline your workflow, we’ll integrate Hugo’s command-line tools directly into Neovim. By defining custom commands for the command-line mode, you can manage Hugo projects without ever leaving the editor.

Working with Templates

The Go standard library of comprises two packages with which Text and (secure) HTML code can be generated from templates. Hugo uses the same syntax for its templates.

With the exception of shortcodes, templates in Hugo are written as HTML files. The code they contain is therefore interpreted as HTML and highlighted accordingly. When new lines are added, the code is automatically indented to match the existing HTML structure. However, template actions are not HTML constructs and are therefore not taken into account in syntax highlighting.

The image shows a terminal window in which Neovim is open. The visible code contains standard HTML and template actions from Hugo. At the bottom right, the Neovim status bar shows that an HTML file is open. The HTML code is highlighted in red, orange and green. The background is dark, but not perfectly black. Components of the expressions, which are each enclosed by two curly brackets, are not highlighted in color. They are grayish white.
Template actions without syntax highlighting.

Syntax Highlighting and Correct Indentation

The plugin phelipetls/vim-hugo defines a custom file type for HTML with Hugo template actions. For these htmlhugo files it provides specific highlight and indent rules. After installation, the above listing looks like this:

The image shows a terminal window in which Neovim is open. The visible code contains standard HTML and template actions from Hugo. At the bottom right, the Neovim status bar shows that an htmlhugo file is open. The HTML code is highlighted in red, purple, blue and green. The background is dark, but not perfectly black. Components of the expressions, which are each enclosed by two curly brackets, are also highlighted in color. They are purple (control structures), blue (functions), green (strings) and white (normal expressions).
Template actions with syntax highlighting.

In order to benefit from improved indentation behavior and syntax highlighting in Markdown files, the corresponding Treesitter modules for the individual languages must be installed.

:TSInstall markdown
:TSInstall markdown_inline
:TSInstall yaml
:TSInstall go

Even if a file of the type markdown has been opened, Treesitter recognizes that the frontmatter has a different format. To verify we can use Treesitter Playground and run TSNodeUnderCursor when the cursor is on the frontmatter. Note that, if toml or json is used, the corresponding parser modules must of course be installed.

Autoformatting

With Prettier we can automatically format Markdown and frontmatter as well as HTML and template actions. The tool itself and additional modules can be installed system-wide or as project dependency with npm:

npm install --save-dev --save-exact prettier
npm install --save-dev prettier-plugin-toml

To use the tools, we add a .prettierrc with the following configuration to the main directory of our Hugo project:

{
  "$schema": "http://json.schemastore.org/prettierrc",
  "bracketSameLine": true,
  "bracketSpacing": true,
  "endOfLine": "lf",
  "goTemplateBracketSpacing": true,
  "overrides": [
    {
      "files": [
        "*.html",
        "*.gotmpl"
      ],
      "options": {
        "parser": "go-template",
        "bracketSameLine": true
      }
    }
  ],
  "plugins": ["prettier-plugin-go-template"],
  "singleQuote": true,
  "tabWidth": 2,
  "useTabs": false
}

If toml is used, prettier-plugin-go-template should also be installed. In this case, we add the plugin in the configuration mentioned above:

{
  "plugins": ["prettier-plugin-toml", "prettier-plugin-go-template"],
}

We could manually run Prettier from the command-line, but a more convenient solution is provided by the awesome stevearc/conform.nvim plugin, which enables standardized handling of installed autoformatters. The configuration could look like this:

require("conform").setup({
  notify_on_error = true,
  -- format_on_save = {
  --   timeout_ms = 500,
  --   lsp_fallback = true,
  -- },
  formatters_by_ft = {
    html = { "prettier" },
    htmlhugo = { "prettier" },
    markdown = { "prettier" },
  },
})

-- Define mapping to trigger autoformatting
vim.api.nvim_set_keymap('n', '<leader>cf', '<cmd>lua require("conform").format()<CR>', { noremap = true, silent = true })

Note that the new htmlhugo format is added. If we are in a markdown or template file, the plugin provides access to Prettier, which we can execute with <leader>cf. If you want, you can auto-format the code each time you save the file (simply uncomment the relevant lines). This will also take care of the proper formatting of the frontmatter.

Prettier describes himself as an “opinionated code formatter”. In Markdown, underscores are used instead of asterisks to emphasize things, which may not be perfectly conventional. As far as I can see, there is currently no configuration option to override this behavior.

Hugo Commands in Command-Line Mode

Like all shell commands, Hugo commands can be executed in Neovim’s command-line mode by preceding them with !. For example, to start the server you could type :! hugo server and press Enter. However, Neovim does not offer the autocompletion that you may be used to from your real shell.

To make working on a Hugo project more convenient, we can write our own small plugin. The goal is to create a custom :Hugo command that autocompletes the most frequently used subcommands. The following requirements will be implemented in the following subsections:

  • The command :Hugo in the Neovim command line should work like the command hugo in the terminal.
  • The command should only be available in a Hugo project.
  • After entering :Hugo , the autocomplete should suggest subcommands of the command-line tool.
  • After entering :Hugo new , the autocomplete should insert content as only reasonable suggestion.
  • After entering :Hugo new content , two cases must be distinguished:
    • :Hugo new content <TAB> should suggest files below the content/ directory.
    • :Hugo new content -k <TAB> and Hugo new content --kind <TAB> should suggest files and subdirectories from the archetype/ directory. After that, :Hugo new content -k/--kind <completed path> <TAB> should in turn suggest files below the content/ directory.

New User Command for Hugo Projects

The :Hugo command should work like hugo in the terminal and it should only be available if the current working directory is a Hugo project. A reasonable criterion for this is the presence of a Hugo configuration file. So if hugo.toml, hugo.yaml or hugo.json can be found in the working directory, a Hugo project has been opened.

The query can be implemented in Lua as follows:

local function is_hugo_project()
  local config_files = { "hugo.toml", "hugo.yaml", "hugo.json" }
  for _, file in ipairs(config_files) do
    if vim.fn.glob(file) ~= "" then
      return true
    end
  end
  return false
end

The function iterates over the file names in the config_files table and returns true if there is a file with the same name in the working directory. The further details of the implementation are less relevant in the given context.

Our own command essentially acts as a simple alias for !hugo. Zero or more arguments can be passed to the command. We write another function for this:

local function run_hugo_command(args)
  local cmd = "hugo " .. table.concat(args, " ")
  vim.cmd("!" .. cmd)
end

To connect this function to the :Hugo command, we can use the built-in vim.api.nvim_create_user_command() function.

if is_hugo_project() then
  vim.api.nvim_create_user_command('Hugo', function(opts)
    run_hugo_command(opts.fargs)
  end, {
    nargs = '*',
    complete = hugo_complete_arglead,
  })
end

The nargs line states that any number of arguments may be passed. With complete we can define which autocompletions should be triggered after :Hugo and other parameters have been entered. The following subsections deal with the function that is defined as value for complete.

A Function that provides Suggestions for Completion

Some completion modes are an integral part of Neovim. For example, -complete=file would result in files and directories being suggested when we press the autocompletion key after entering :Hugo. However, we would like to render the suggestions more differentiated, making it depend on the preceding subcommand. To do this, we can define a custom completion scheme.

For this purpose, we write a function that is set as the value for completion. The function takes three arguments whose values are determined at the moment when the completion key is pressed (like <TAB>): ArgLead, CmdLine and CursorPos. The first two are of particular interest to us:

  • ArgLead contains the string to be completed. If you type :Hugo se<TAB>, "se" would be the value of ArgLead when the function is called. If you type :Hugo new <TAB>, ArgLead would be the empty word.
  • CmdLine contains the entire content of the command line (without :). If we type :Hugo new <TAB>, "Hugo new" would be the value of CmdLine.

The signature of our completion function may look like this:

local function hugo_complete_arglead(lead, cmd_line, _)

To analyze the state of the command line, we split the content into parts, separating components at whitespace boundaries:

  cmd_line = cmd_line:gsub("%s+", " ") -- Multiple spaces shouldn't matter
  local parts = vim.split(cmd_line, " ")

If we press :Hugo new <TAB>, cmd_line has the value Hugo new and we get the table {"Hugo", "new", ""}. If, on the other hand, we press :Hugo new<TAB>, cmd_line has the value Hugo new and we get the table {"Hugo", "new"}. The difference is important because the user will expect a completion for the empty word, i.e. the list of all possible completions.

Suggestions after the first Word: Hugo Subcommands

If :Hugo has been entered, the next thing we want is a subcommand. We should therefore consider which subcommands to include in the autocomplete. The following preselection seems appropriate:

local hugo_subcommands = {
  "server",
  "new",
  "help",
  "version",
  "config",
}

If only Hugo and a space have been entered so far, the parts table contains two elements. If no additional space (after real characters) was entered, the second element could change. For example, Hugo ne<TAB> results in the table {"Hugo", "ne"}.

We would now like to give suggestions from the hugo_subcommands list. Since no initial letters are repeated, the list of suggestions could actually contain at most one element. However, the following query is more general than would be strictly necessary and returns the table of all suggestions that still “fit” when the function is called.

if #parts <= 2 then
  return vim.tbl_filter(function(sub)
    return sub:match("^" .. vim.pesc(lead or ""))
  end, basic_subcommands)
end

vim.tbl_filter is a filter function, as the name suggests. It iterates over all elements in the basic_subcommands table (the second argument) and returns a table of all elements that satisfy the condition specified by the first argument.

The expression may seem complicated, as the condition is denoted as an anonymous function. However, the details are not crucial for our purposes. The idea is simply to go through the list of all basic_subcommands and return all suggestions that can complete the second word of our previous input (including the empty word). If :Hugo n<TAB> was entered, the filter returns {"new"}. For :Hugo no<TAB> we get {}, i.e. no matching results.

Suggestions after the second Word: Create Content

By far the most important subcommand is hugo new, which in turn has further subcommands: hugo new content, hugo new site and hugo new theme. It is unlikely that a user would want to start a new website from an existing project. Since themes are initiated only rarely, an auto-completion for hugo new theme does not seem to make much sense, either.

The main purpose of this subcommand is to create new content. Originally, hugo new <PATH> was used. Although this usage is still possible, hugo new content is now preferred. Therefore, :Hugo new <TAB> should directly suggest :Hugo new content.

The implementation for this case is comparatively simple. If the second word is new, the list of completions only contains {"content"}:

if parts[2] == "new" then
  if #parts == 3 then
    return {"content"}
  end
...
end

Suggestions after the third Word: Archetypes and Content

Next, we ask what should be suggested after content (as the third word). There are two cases to be distinguished here:

  • :Hugo new content <TAB> should suggest files below the content/ directory.
  • :Hugo new content -k <TAB> or Hugo new content --kind <TAB> should suggest files and subdirectories from archetype/. :Hugo new content -k/--kind <completed path> <TAB> should then suggest files below the content/ directory.

In general terms, the logic appears relatively simple:

  if parts[2] == "new" then
    ...
    if parts[3] == "content" then
      if parts[#parts-1] == "-k" or parts[#parts-1] == "--kind" then
        return get_archetypes()
      end
      
      return get_content_completions(lead)
    end
  end

The first part offers archetypes (files and directories under archetype/) and the second part gives us content (files and directories under content/). Again, the details of these two functions don’t matter too much. Here are their implementations (without further commentary on their workings):

-- Function to get archetypes from the project
local function get_archetypes()
  local kinds = {}
  -- Check both project and theme archetypes
  local archetype_paths = {
    "archetypes/",
    "themes/*/archetypes/"
  }
  
  for _, path in ipairs(archetype_paths) do
    local files = vim.fn.glob(path .. "*.md", false, true)
    for _, file in ipairs(files) do
      local kind = vim.fn.fnamemodify(file, ":t:r")
      table.insert(kinds, kind)
    end
  end
  return kinds
end

-- Function to get content directory completions
local function get_content_completions(lead)
  -- If lead starts with 'content/', use it directly; otherwise, prepend it
  local search_path = lead:match("^content/") and lead or "content/" .. lead
  -- Get all files and directories under content/
  local matches = vim.fn.glob("content/**/*", false, true)
  
  -- Filter and format matches
  local completions = {}
  for _, match in ipairs(matches) do
    -- Remove 'content/' prefix for display
    local display = match:gsub("^content/", "")
    -- Only include matches that start with our search path
    if match:match("^" .. vim.pesc(search_path)) then
      -- If it's a directory, add a trailing slash
      if vim.fn.isdirectory(match) == 1 then
        display = display .. "/"
      end
      table.insert(completions, display)
    end
  end
  
  return completions
end

Bringing it all together

The entire plugin is best stored in after/plugins/hugo.lua, since the scripts in this directory are automatically loaded and executed when Neovim is started.

local function is_hugo_project()
  local config_files = { "hugo.toml", "hugo.yaml", "hugo.json" }
  for _, file in ipairs(config_files) do
    if vim.fn.glob(file) ~= "" then
      return true
    end
  end
  return false
end

local function get_archetypes()
  local kinds = {}
  local archetype_paths = {
    "archetypes/",
    "themes/*/archetypes/"
  }
  
  for _, path in ipairs(archetype_paths) do
    local files = vim.fn.glob(path .. "*.md", false, true)
    for _, file in ipairs(files) do
      local kind = vim.fn.fnamemodify(file, ":t:r")
      table.insert(kinds, kind)
    end
  end
  return kinds
end

local function get_content_completions(lead)
  local search_path = lead:match("^content/") and lead or "content/" .. lead
  local matches = vim.fn.glob("content/**/*", false, true)
  
  local completions = {}
  for _, match in ipairs(matches) do
    local display = match:gsub("^content/", "")
    if match:match("^" .. vim.pesc(search_path)) then
      if vim.fn.isdirectory(match) == 1 then
        display = display .. "/"
      end
      table.insert(completions, display)
    end
  end
  
  return completions
end

local function run_hugo_command(args)
  local cmd = "hugo " .. table.concat(args, " ")
  vim.cmd("!" .. cmd)
end

local function hugo_complete_arglead(lead, cmd_line, _)
  cmd_line = cmd_line:gsub("%s+", " ")
  
  local parts = vim.split(cmd_line, " ")
  
  local basic_subcommands = {
    "server",
    "new",
    "help",
    "version",
    "config",
  }
  
  if #parts <= 2 then
    return vim.tbl_filter(function(sub)
      return sub:match("^" .. vim.pesc(lead or ""))
    end, basic_subcommands)
  end
  
  if parts[2] == "new" then
    if #parts == 3 then
      return {"content"}
    end
    
    if parts[3] == "content" then
      -- If the previous part is -k or --kind
      if parts[#parts-1] == "-k" or parts[#parts-1] == "--kind" then
        return get_archetypes()
      end
      
      return get_content_completions(lead)
    end
  end
  
  return {}
end

if is_hugo_project() then
  vim.api.nvim_create_user_command('Hugo', function(opts)
    run_hugo_command(opts.fargs)
  end, {
    nargs = '*',
    complete = hugo_complete_arglead,
  })
end

Article from October 20, 2024.