When writing blog posts in Hugo, I often want to open the file that I'm currently editing in my browser, so I can preview how the post looks. It'd be nice to do this automatically from a vim shortcut. Fortunately, Hugo can introspect on the pages it generates, which allows us to programatically find the URL of the current page we're editing.

Hugo's CLI tool has a command that outputs all the pages it generates: hugo list. The two fields this command outputs that we care about are path and permalink: path is the filesystem path of the content file (think /content/path/to/my/post.md) and permalink is the actual URL of the page that Hugo generates.

$ hugo list all
path,slug,title,date,expiryDate,publishDate,draft,permalink
content/blog/2020-07-10-Vim-Tip-Open-Hugo-Page-in-Browser/index.md,,Vim Tip: Open Hugo Page in Browser,2020-07-10T08:38:03Z,0001-01-01T00:00:00Z,2020-07-10T08:38:03Z,false,https://benjamincongdon.me/blog/2020/07/10/Vim-Tip-Open-Hugo-Page-in-Browser/
content/archive.md,,Blog Archive,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,false,https://benjamincongdon.me/blog/archive/
...

From the above, we see that the path of this post is content/blog/2020-07-10-Vim-Tip-Open-Hugo-Page-in-Browser/index.md, and its permalink is https://benjamincongdon.me/blog/2020/07/08/Vim-Tip-Open-Hugo-Page-in-Browser/.

Only, this isn't quite right for local development. When previewing posts locally, we want a localhost base URL – usually something starting with http://localhost:1313. Fortunately, we can set the HUGO_BASEURL environment variable to make the permalinks work locally:

$ HUGO_BASEURL="http://localhost:1313" hugo list all
path,slug,title,date,expiryDate,publishDate,draft,permalink
content/blog/2020-07-10-Vim-Tip-Open-Hugo-Page-in-Browser/index.md,,Vim Tip: Open Hugo Page in Browser,2020-07-10T08:38:03Z,0001-01-01T00:00:00Z,2020-07-10T08:38:03Z,false,https://localhost:1313/blog/2020/07/10/Vim-Tip-Open-Hugo-Page-in-Browser/
...

In order to open the permalink yielded by hugo list, we have to extract it from the list of results:

hugo list all | grep /path/to/page.md | head -n 1 | cut -d ',' -f 8

We use grep to filter the results to the page we want to preview, head to ensure we only ever get 1 result, and cut to extract the permalink field. Here's what that looks like translated into vimscript:

let l:url = systemlist("hugo list all | grep " . l:contentpath . " | head -n 1 | cut -d ',' -f 8")[0]

Next, we have to do some scripting in Vim so that the hugo CLI tool is correctly run from the Hugo base directory (i.e. the directory that contains your config.{toml,yaml,json}). If hugo isn't run from the directory containing your site's configuration, hugo list won't work correctly. In the code snippet below, HugoBaseDirectory() recurses up the directory hierarchy of the provided file until it finds something that contains a config file.

And here's the final code:

let g:hugo_site_config = [ 'config.toml', 'config.yaml', 'config.json' ]
" The local Hugo server URL
let g:hugo_base_url = "http://localhost:1313/"

function! HugoBaseDirectory(filepath)
    let l:mods = ':p:h'
    let l:dirname = 'dummy'
    while !empty(l:dirname)
        let l:path = fnamemodify(a:filepath, l:mods)
        let l:mods .= ':h'
        let l:dirname = fnamemodify(l:path, ':t')
        " Check if the parent of the content directory contains a config file.
        let l:parent = fnamemodify(l:path, ":h")
        if HugoConfigFile(l:parent) != ""
            return l:parent
        endif
    endwhile

    return ""
endfunction

function! HugoConfigFile(dir)
    " :p adds the final path separator if a:dir is a directory.
    let l:dirpath = fnamemodify(a:dir, ':p')
    for config in g:hugo_site_config
        let l:file = l:dirpath . config
        if filereadable(l:file)
            return l:file
        endif
    endfor
    return ""
endfunction

function! PreviewHugoPage(filepath)
    let l:fullpath = fnamemodify(a:filepath, ':p')
    " Get Hugo's base directory
    let l:basedir = HugoBaseDirectory(l:fullpath)
    if l:basedir == ""
        return ""
    endif
    " Get the path of the content file relative to Hugo's base directory
    let l:contentpath = substitute(l:fullpath, l:basedir . '/', '', '')
    " Huse `hugo list all` to find the URL of the content file
    let l:url = systemlist("cd " . l:basedir . " && HUGO_BASEURL='" . g:hugo_base_url . "' hugo list all | grep " . l:contentpath . " | head -n 1 | cut -d ',' -f 8")[0]
    exe system("open " . l:url)
endfunction

nmap <Leader>hp :call PreviewHugoPage(expand('%'))<cr>

Now, you can type ,hp in vim while editing a Hugo content file, and that file will automatically be opened in your browser for previewing!

The nice thing about making g:hugo_base_url configurable is that you could make a variant of this command that opens the deployed version of your content post – you'd just have to make a function that altered g:hugo_base_url to point to your deployed Hugo site.

Attribution

Some of the Hugo config file code is adapted from vim-hugo-helper