Hugo in Neovim
Neovim lässt sich speziell für die Arbeit mit Hugo konfigurieren. Wir richten Syntax-Highlighting für Templates ein und sorgen dafür, dass Blöcke automatisch korrekt eingerückt werden. Zusätzlich fügen wir einen Autoformatter hinzu, der die Formatierung unterstützt.
Für eine einfachere Handhabung binden wir die Kommandozeilenwerkzeuge von Hugo direkt in den Editor ein. Dazu definieren wir eigene Befehle für den Kommandozeilenmodus (command-line mode).
Features für die Arbeit mit Templates
Die Standardbibliothek von Go umfasst zwei Paketen, mit denen Text und (sicherer) HTML-Code aus Vorlagen heraus erzeugt werden können. Hugo nutzt für seine Templates die gleiche Syntax.
Mit Ausnahme von Shortcodes werden Templates in Hugo als HTML-Dateien gespeichert. Der darin enthaltene Code wird daher als HTML interpretiert und entsprechend hervorgehoben. Beim Hinzufügen neuer Zeilen wird der Code automatisch passend zur bestehenden HTML-Struktur eingerückt. Template-Aktionen sind jedoch keine HTML-Konstrukte und bleiben bei der Syntaxhervorhebung unberücksichtigt.
Syntaxhervorhebung und korrektes Einrücken
Das Plugin
phelipetls/vim-hugo definiert einen Dateityp speziell für HTML mit Hugo-Template-Actions. Es bietet spezifische Highlight- und Indent-Regeln für diese htmlhugo
-Dateien. Nach der Installation sieht das obige Listing folgendermaßen aus:
Um auch in Markdown-Dateien von einem verbesserten Einrückverhalten und von Syntaxhervorhebung zu profitieren, müssen die entsprechenden Treesitter-Module für die einzelnen Sprachen installiert sein.
:TSInstall markdown
:TSInstall markdown_inline
:TSInstall yaml
:TSInstall go
Auch wenn allgemein eine Datei vom Typ markdown
geöffnet wurde, so erkennt Treesitter, dass die Frontmatter ein anderes Format hat. Wurde
Treesitter Playground installiert, so können wir uns davon überzeugen, wenn wir in der Frontmatter TSNodeUnderCursor
ausführen. Falls toml
oder json
verwendet wird, müssen natürlich die entsprechenden Parser-Module installiert sein.
Autoformatting
Mit Prettier können wir Markdown und Frontmatter sowie HTML und Template-Aktionen automatisch formatieren. Das Tool selbst und zusätzlich Module können mit npm systemweit oder projektabhängig installiert werden:
npm install --save-dev --save-exact prettier
npm install --save-dev prettier-plugin-toml
Um die Werkzeuge zu nutzen, fügen wir dem Hauptverzeichnis unseres Hugo-Projekts eine .prettierrc
mit der folgenden Konfiguration hinzu:
{
"$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
}
Wenn TOML verwendet wird, sollte zudem prettier-plugin-go-template
installiert werden. In diesem Fall fügen wir das Plugin in der oben genannten Konfiguration hinzu:
{
"plugins": ["prettier-plugin-toml", "prettier-plugin-go-template"],
}
Prettier ist ein Kommandozeilenwerkzeuge, das gezielt für bestimmte Dateien ausgeführt werden kann. Eine bequemere Möglichkeit bietet das stevearc/conform.nvim-Plugin, das eine einheitliche Handhabung installierter Autoformatter ermöglicht. Die Konfiguration könnte folgendermaßen aussehen:
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 })
Wenn wir uns in einer Markdown- oder Template-Datei befinden, stellt das Plugin Prettier als Autoformatter bereit, den wir mit <leader>cf
ausführen können. Wer möchte, kann den Code bei jedem speichern autoformatieren (dazu einfach die entsprechenden Zeilen einkommentieren). Auch die Frontmatter wird automatisch formatiert. Für die Formatierung von Template-Aktionen wird das neue htmlhugo
-Format hinzugefügt.
Prettier bezeichnet sich selbst als “meinungsstarken Code-Formatter” (“opinionated”). In Markdown werden Unterstriche anstelle von Sternchen verwendet, um Dinge hervorzuheben, was möglicherweise nicht perfekt konventionell ist. Soweit ich sehe gibt es derzeit keine Konfigurationsoption, um dieses Verhalten zu überschreiben.
Hugo-Befehle im Kommandozeilenmodus
Wie alle Shell-Befehle können auch Hugo-Befehle im Kommandozeilenmodus von Neovim ausgeführt werden, indem man !
voranstellt. Um den Server zu starten, könnten man beispielsweise :! hugo server
eintippen und mit Enter ausführen. Allerdings verfügt Neovim in diesem Modus nicht über die Autovervollständigung, die man möglicherweise aus einer echten Shell gewohnt ist.
Um die Arbeit an einem Hugo-Projekt komfortabler zu gestalten, können wir ein eigenes kleines Plugin schreiben. Ziel ist es, einen eigenen :Hugo
-Befehl zu erstellen, der die nützlichsten Unterbefehle autovervollständigt. Die folgenden Anforderungen sollen in den kommenden Unterabschnitten realisiert werden:
- Der Befehl
:Hugo
in der Neovim-Kommandozeile soll wie der Befehlhugo
im Terminal funktionieren. - Der Befehl soll nur in einem Hugo-Projekt verfügbar sein.
- Nach der Eingabe von
:Hugo
soll die Autovervollständigung Unterbefehle des Kommandozeilenwerkzeugs vorschlagen. - Nach der Eingabe von
:Hugo new
soll die Autovervollständigung direktcontent
einsetzen. - Nach der Eingabe von
:Hugo new content
sind zwei Fälle zu unterscheiden::Hugo new content <TAB>
sollte Dateien unterhalb vomcontent/
-Verzeichnis vorschlagen.:Hugo new content -k <TAB>
undHugo new content --kind <TAB>
sollten Dateien und Unterverzeichnisse aus demarchetype/
-Verzeichnis vorschlagen. Zudem sollte:Hugo new content -k/--kind <completed path> <TAB>
wiederum Dateien unterhalb vomcontent/
-Verzeichnis vorschlagen.
Neuer Benutzerbefehl für Hugo-Projekte
Der :Hugo
-Befehl soll wie hugo
im Terminal funktionieren und nur verfügbar sein, wenn das aktuelle Arbeitsverzeichnis ein Hugo-Projekt ist. Ein sinnvolles Kriterium dafür ist die Existenz einer Hugo-Konfigurationsdatei. Wenn sich also hugo.toml
, hugo.yaml
oder hugo.json
im Arbeitsverzeichnis befindet, wurde ein Hugo-Projekt geöffnet.
Die Abfrage lässt sich in Lua folgendermaßen implementieren:
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
Die Funktion iteriert über die Dateinamen in der config_files
-Tabelle und gibt true
zurück, wenn sich im Arbeitsverzeichnis eine gleichnamige Datei befindet. Die weiteren Details der Implementierung sind in diesem Kontext weniger relevant.
Unser eigener Befehl fungiert im Wesentlichen als einfacher Alias für !hugo
. Dem Befehl können null oder mehr Argumente übergeben werden. Dazu schreiben wir eine weitere Funktion:
local function run_hugo_command(args)
local cmd = "hugo " .. table.concat(args, " ")
vim.cmd("!" .. cmd)
end
Um diese Funktion mit dem :Hugo
-Befehl zu verknüpfen, können wir die eingebaute vim.api.nvim_create_user_command()
-Funktion nutzen.
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
Die nargs
-Zeile besagt, dass beliebig viele Argumente übergeben werden dürfen. Mit complete
können wir festlegen, welche Autovervollständigungen getriggert werden sollen, nachdem :Hugo
und weitere Parameter eingegeben wurden. Die folgenden Unterabschnitte behandeln die Funktion, die als Wert für complete
definiert wird.
Eine Funktion, die Vorschläge zur Vervollständigung gibt
Einige Vervollständigungsmodi sind
fester Bestandteil von Neovim. Beispielsweise hätte -complete=file
zur Folge, dass Dateien und Verzeichnisse vorgeschlagen werden, wenn wir nach der Eingabe von :Hugo
die Autocompletion-Taste drücken. Wir möchten jedoch die Vorschläge differenzierter gestalten, abhängig vom Unterbefehl. Dazu können wir ein
eigenes Vervollständigungsschema definieren.
Zu diesem Zweck schreiben wir eine Funktion, die als Wert für completion
gesetzt wird. Die Funktion nimmt drei Argumente entgegen, die beim Drücken der Completion-Taste (wie <TAB>
) automatisch mit Werten gefüllt werden: ArgLead
, CmdLine
und CursorPos
. Für uns sind insbesondere die ersten beiden von Interesse:
ArgLead
enthält den zu vervollständigenden String. Tippt man:Hugo se<TAB>
, so wäre"se"
der Wert vonArgLead
beim Aufruf der Funktion. Tippt man:Hugo new <TAB>
, so wäreArgLead
das leere Wort.CmdLine
enthält den gesamten Inhalt der Kommandozeile (ohne:
). Tippen wir:Hugo new <TAB>
, so wäre"Hugo new "
der Wert vonCmdLine
.
Die Signatur unserer Completion-Funktion könnte daher wie folgt aussehen:
local function hugo_complete_arglead(lead, cmd_line, _)
Um den Zustand der Kommandozeile zu analysieren, zerlegen wir den Inhalt in Teile, wobei wir an Whitespace-Grenzen trennen:
cmd_line = cmd_line:gsub("%s+", " ") -- Multiple spaces shouldn't matter
local parts = vim.split(cmd_line, " ")
Wenn wir :Hugo new <TAB>
drücken, hat cmd_line
den Wert Hugo new
und wir erhalten die Tabelle {"Hugo", "new", ""}
. Drücken wir hingegen :Hugo new<TAB>
, hat cmd_line
den Wert Hugo new
, und wir erhalten die Tabelle {"Hugo", "new"}
. Der Unterschied ist wichtig, da der Anwender insbesondere für das leere Wort eine Vervollständigung erwarten wird.
Vorschläge nach dem ersten Wort: Unterbefehle
Wenn :Hugo
eingegeben wurde, wollen wir als Nächstes einen Unterbefehl. Daher sollten wir uns überlegen, welche Unterbefehle in die Autovervollständigung aufgenommen werden. Die folgende Vorauswahl erscheint zweckmäßig:
local hugo_subcommands = {
"server",
"new",
"help",
"version",
"config",
}
Wenn bisher nur Hugo
und ein Leerzeichen eingegeben wurde, enthält die parts
-Tabelle zwei Elemente. Wenn kein weiteres Leerzeichen (nach echten Zeichen) eingegeben wurde, könnte sich das zweite Element ändern. So ergibt beispielsweise Hugo ne<TAB>
die Tabelle {"Hugo", "ne"}
.
Wir möchten nun Vorschläge aus der hugo_subcommands
-Liste geben. Da sich keine Anfangsbuchstaben wiederholen, kann die Liste der Vorschläge eigentlich höchstens ein Element enthalten. Die folgende Abfrage ist jedoch allgemein gehalten und gibt die Tabelle aller Vorschläge zurück, die beim Aufruf noch “passen”.
if #parts <= 2 then
return vim.tbl_filter(function(sub)
return sub:match("^" .. vim.pesc(lead or ""))
end, basic_subcommands)
end
vim.tbl_filter
ist, wie der Name bereits andeutet, eine Filter-Funktion. Sie iteriert über alle Elemente der basic_subcommands
-Tabelle (das zweite Argument) und gibt eine Tabelle aller Elemente zurück, die eine Bedingung erfüllen (das erste Argument).
Der Ausdruck mag kompliziert erscheinen, da die Bedingung in Form einer anonymen Funktion formuliert wird. Die Details sind für unsere Zwecke jedoch nicht entscheidend. Die Idee ist lediglich, durch die Liste aller basic_subcommands
zu gehen und alle Vorschläge zurückzugeben, die das zweite Wort unserer bisherigen Eingabe (einschließlich des leeren Wortes) vervollständigen können. Wenn :Hugo n<TAB>
eingegeben wurde, gibt der Filter {"new"}
zurück. Bei :Hugo no<TAB>
erhalten wir {}
(keine passenden Ergebnisse).
Vorschläge nach dem zweiten Wort: Inhalte erstellen
Der mit Abstand wichtigste Unterbefehl ist hugo new
, der wiederum weitere Unterbefehle hat: hugo new content
, hugo new site
und hugo new theme
. Es ist unwahrscheinlich, dass ein Anwender aus einem bestehendem Projekt heraus eine neue Webseite starten möchte. Da Themes zu selten initiiert werden, erscheint eine Autovervollständigung für hugo new theme
wenig sinnvoll.
Der Hauptzweck dieser Funktionalität besteht darin, aus Neovim heraus neue Inhalte zu erstellen. Ursprünglich wurde dazu hugo new <PATH>
verwendet. Auch wenn diese Verwendungsweise weiterhin möglich ist, wird hugo new content
empfohlen. Daher sollte :Hugo new <TAB>
direkt :Hugo new content
vorschlagen.
Die Implementierung für diesen Fall ist vergleichsweise einfach. Wenn das zweite Wort new
ist, enthält die Liste der Vervollständigungen lediglich {"content"}
:
if parts[2] == "new" then
if #parts == 3 then
return {"content"}
end
...
end
Vorschläge nach dem dritten Wort: Archetypen und Inhalte
Als Nächstes stellen wir die Frage, was nach content
(als drittes Wort) vorgeschlagen werden sollte. Hier sind zwei Fälle zu unterscheiden:
:Hugo new content <TAB>
sollte Dateien unterhalb vomcontent/
-Verzeichnis vorschlagen.:Hugo new content -k <TAB>
oderHugo new content --kind <TAB>
sollte Dateien und Unterverzeichnisse ausarchetype/
vorschlagen.:Hugo new content -k/--kind <completed path> <TAB>
sollte dann wiederum Dateien unterhalb vomcontent/
-Verzeichnis vorschlagen.
Prinzipiell ist die Logik relativ einfach:
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
Der erste Teil gibt uns Archetypen (Dateien und Verzeichnisse unter archetype/
) und der zweite Teil gibt uns Inhalte (Dateien und Verzeichnisse unter content/
). Die Details dieser beiden Funktionen spielen erneut keine große Rolle. Hier sind ihre Implementierungen:
-- 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
Die Komponenten zusammenführen
Das gesamte Plugin kann unter after/plugins/hugo.lua
gespeichert werden:
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
Die Skripte im Verzeichnis after/plugins/
werden beim Start von Neovim automatisch geladen und ausgeführt. Der neue Befehl steht nun zur Verfügung, wenn wir uns im Wurzelverzeichnis eines Hugo-Projekts befinden.