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.
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:
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 commandhugo
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 insertcontent
as only reasonable suggestion. - After entering
:Hugo new content
, two cases must be distinguished::Hugo new content <TAB>
should suggest files below thecontent/
directory.:Hugo new content -k <TAB>
andHugo new content --kind <TAB>
should suggest files and subdirectories from thearchetype/
directory. After that,:Hugo new content -k/--kind <completed path> <TAB>
should in turn suggest files below thecontent/
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 ofArgLead
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 ofCmdLine
.
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 thecontent/
directory.:Hugo new content -k <TAB>
orHugo new content --kind <TAB>
should suggest files and subdirectories fromarchetype/
.:Hugo new content -k/--kind <completed path> <TAB>
should then suggest files below thecontent/
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