EN

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.

In dem Bild ist ein Terminal-Fenster zu sehen, in dem Neovim geöffnet ist. Der sichtbare Code beinhaltet Standard-HTML und Template-Actions von Hugo. Unten rechts wird in der Neovim-Statusbar angezeigt, dass eine HTML-Datei geöffnet ist. Der HTML-Code ist farblich hervorgehoben in rot, orange und grün. Der Hintergrund ist dunkel, aber nicht perfekt schwarz. Bestandteile der Ausdrücke, die von jeweils zwei geschweiften Klammern umschlossen werden, sind nicht farblich hervorgehoben. Sie sind gräulich weiß.
Darstellung von Template-Actions ohne Syntax-Highlighting.

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:

In dem Bild ist ein Terminal-Fenster zu sehen, in dem Neovim geöffnet ist. Der sichtbare Code beinhaltet Standard-HTML und Template-Actions von Hugo. Unten rechts wird in der Neovim-Statusbar angezeigt, dass eine htmlhugo-Datei geöffnet ist. Der HTML-Code ist farblich hervorgehoben in rot, lila, blau und grün. Der Hintergrund ist dunkel, aber nicht perfekt schwarz. Bestandteile der Ausdrücke, die von jeweils zwei geschweiften Klammern umschlossen werden, werden auch farblich hervorgehoben. Sie sind lila (Kontrollstrukturen), blau (Funktionen), grün (Strings) und weiß (normale Ausdrücke).
Darstellung von Template-Actions mit Syntax-Highlighting.

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 Befehl hugo 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 direkt content einsetzen.
  • Nach der Eingabe von :Hugo new content sind zwei Fälle zu unterscheiden:
    • :Hugo new content <TAB> sollte Dateien unterhalb vom content/-Verzeichnis vorschlagen.
    • :Hugo new content -k <TAB> und Hugo new content --kind <TAB> sollten Dateien und Unterverzeichnisse aus dem archetype/-Verzeichnis vorschlagen. Zudem sollte :Hugo new content -k/--kind <completed path> <TAB> wiederum Dateien unterhalb vom content/-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 von ArgLead beim Aufruf der Funktion. Tippt man :Hugo new <TAB>, so wäre ArgLead das leere Wort.
  • CmdLine enthält den gesamten Inhalt der Kommandozeile (ohne :). Tippen wir :Hugo new <TAB>, so wäre "Hugo new " der Wert von CmdLine.

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 vom content/-Verzeichnis vorschlagen.
  • :Hugo new content -k <TAB> oder Hugo new content --kind <TAB> sollte Dateien und Unterverzeichnisse aus archetype/ vorschlagen. :Hugo new content -k/--kind <completed path> <TAB> sollte dann wiederum Dateien unterhalb vom content/-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.

Artikel vom 20. Oktober 2024.