I’ve mentioned a few times that I use Visual Studio Code and Hugo for my blogging. I’ve also discussed some of the the ways I make it faster and easier to create my posts. In today’s post, I want to explore that topic further.
VS Code provides a very extensible editor experience. It allows you to add new commands and new UI elements. It even allows you to customize your workflows. In short, you have a lot of ways to customize the experience.
Some time ago, I noticed that there were four things that I did for each post that took excessive amounts of time. Being a DevOps practitioner, this seemed like a perfect opportunity to automate some processes. Taking inventory of the pain points, I identified a few areas that I could improve:
- Creating a new post
- Testing the site
- Validating the metadata
- Selecting tags and categories
With those in mind, I looked for ways to improve my process. Let’s start by looking at the first two items.
Creating new posts
The first item I tackled was creating new posts. Whether it’s a new blog article or a speaking engagement, there’s a lot of front matter YAML that I use for customizing how everything is presented. I also like to organize my articles by year so that I can find them more easily. For these, I took advantage of Hugo archetypes. These are essentially Go templates that Hugo can use to create new content. I created a custom archetype for my blog posts and a separate one for my speaking engagements. This way, I can keep the front matter separate and only include the fields that I need for each type of content. I can also include a default image for the blog posts that will be displayed if I haven’t created a custom banner.
If I put the template in the archetypes
folder, I can call hugo new blog/{NAME}
and it will create a new post with the correct title and front matter. I can then open the post in VS Code and start writing. My blog posts are created with a target publish date in mind. By using a convention of yyyy-mm-dd title
for the name of the post, I can create a folder with that name containing an index.md
. I can also use some light scripting in the template to automatically populate some of the details.
To make this work, I created a subdirectory in archetypes
called blog
. Within that, I put two files: index.md
(the template) and images/banner.jpg
(a default banner). The template looks like this:
1---{{ $data := split .Name " " }}{{ $time := time (print (index $data 0) "T05:00:00") "America/New_York" }}{{ $docTitle := delimit (after 1 $data) " " }}{{ $uniqueId := .File.UniqueID }}
2# yaml-language-server: $schema=/schema/blog.json
3title: {{ $docTitle | title }}
4categories: [General]
5tags: [General]
6postId: {{ substr $uniqueId 0 8 }}-{{ substr $uniqueId 8 4 }}-{{ substr $uniqueId 12 4 }}-{{ substr $uniqueId 16 4 }}-{{ substr $uniqueId 20 }}
7draft: true
8
9publishDate: {{ $time.Format "2006-01-02T15:04:05-07:00" }}
10date: {{ $time.Format "2006-01-02" }}
11month: {{ $time.Format "2006/01" }}
12year: {{ $time.Format "2006" }}
13
14slug: {{ urlize (lower $docTitle) }}
15banner: images/banner.jpg
16---
17Content goes here ...
Once a post is created, I can open it in VS Code and start writing. I can also customize the front matter as needed, changing the title, categories, tags, and other details.
Understanding the archetype
If you’re not interested in understanding how some of this works with Hugo, feel free to skip to the next section. The Hugo template is a bit complex, so I want to deconstruct that just a bit. My template relies on knowing a few things about the process and how Hugo handles archetypes:
- My command line uses the format
hugo new blog/{YEAR}/{DATE} {NAME}
. As an example, this blog post usedhugo new blog/2024/2024-10-01 Improved Blogging With Visual Studio Code Tasks
. Hugo uses the first segment,blog
, to identify the archetype to use. It then uses the template to populate the directoryblog/{YEAR}/{DATE} {NAME}
. In my case, creatingindex.md
andimages/banner.jpg
. - The template receives a value,
.Name
, which is just the last part of the path. In my case,{DATE YEAR}
(or2024-10-01 Improved Blogging With Visual Studio Code Tasks
). - Each file gets assigned a unique 32-character identifier such as
33893bb54801e282c95eaa65997071ea
that is accessible using.File.UniqueID
.
The template creates a variable, $data
, that splits the provided .Name
at the first space character. That creates an array with a date in the first element(index $data 0
). I can use
time
to convert it into a date/time data type, and
format
to choose how it is formatted as a string. This value is used to set the metadata used for publishing the post and for organizing the posts by date on my site.
The remaining segments (after 1 $data
) are joined back together to capture the title of the post as $doctitle
. I use the Hugo
title
function to properly case it. I then use
urlize
to sanitize the title and create a default “slug”. A slug is the HTTP path to the post, such as improve-blogging-with-visual-studio-code
(this post). These are all defaults, so I can customize and tweak it as needed.
All of my posts also have a unique GUID assigned to them. This was originally used to handle updating a Wordpress site with changes to existing posts and with the original RSS feeds. I use the UniqueID
property that Hugo assigns to the file to create the GUID. I then use the substr
function to take the first 20 characters of the GUID. This is the same format that I use for the file name, so I can easily find the post in my content folder.
This process has a limitation. It creates the file, but it doesn’t open it for me. For that, I need to start integrating with VS Code.
Integrating with Code
To improve the workflow, I wanted to integrate the experience with my Code environment. That would allow me to create the file and open it in one step. The easiest way to do that is with a
custom task. Tasks are configure by creating a file, .vscode/tasks.json
, in the root of the project. Tasks in that file are available under Task: Run Task
in the command pallette using Ctrl+Shift+P
or Cmd+Shift+P
(⇧⌘P). Tasks can
The tasks.json
file looks like this:
1{
2 "version": "2.0.0",
3 "tasks": [
4 {
5 "label": "Create Post",
6 "type": "shell",
7 "command": "hugo new 'blog/${input:title}' && code -r '${workspaceFolder}/content/blog/${input:title}/index.md'",
8 "problemMatcher": [],
9 "presentation": {
10 "echo": true,
11 "reveal": "always",
12 "focus": false,
13 "panel": "dedicated",
14 "showReuseMessage": true,
15 "clear": false
16 }
17 }
18 ],
19 "inputs": [
20 {
21 "id": "title",
22 "type": "promptString",
23 "description": "Post title",
24 "default": ""
25 }
26 ]
27}
This task uses the shell
type to invoke the Hugo command. This allows me to take advantage of the shell to do two distinct steps in a single command (by separating them with a &&
, which executes the second command if the first succeeds). While I could use dependsOn
to chain asks together, in this case the two components are always executed together and never individually. It makes sense in my case to combine them. I configure problemMatcher: []
to prevent VS Code from trying to parse the output of the Hugo command. I’ll come back to that one.
At the bottom of the file, I define an input called title
. It prompts me to provide the input for that task. The task can then reference ${input:title}
and automatically prompt me for the title of the post:
The task calls the Hugo command to create a new post, passing the input. It then uses the code
command to open the generated _index.md
file in the current VS Code window. The -r
flag tells VS Code to reuse the current window.
The presentation
section of the task is used to control how the task is displayed:
reveal: always
- Always bring the terminal panel where this is executing to the front and make it visible
echo: true
- Show the full command in the terminal panel
focus: false
- Don’t give the panel input focus. I want the focus to be on the editor.
showReuseMessage: true
- Displays the message indicating the terminal will be reused and I can press any key to close it. This allows me to review the output and close the terminal when I’m ready.
panel: dedicated
- The task gets its own terminal panel that is reused if I run the task again.
clear: false
- Allows the terminal to act as a running log of the task executions. Set this to
true
to clear the terminal before each run.
With the task in place, I can now quickly create posts and have a portion of the content automatically generated.
Instant access with key bindings
Using the command pallette is fine, but sometimes you just want things to be a simple keystroke. I can create an entry in my personal keybindings.json
(or through the Keyboard Shortcuts):
1[
2 {
3 "key": "ctrl+shift+n",
4 "command": "workbench.action.tasks.runTask",
5 "args": "Create Post",
6 "when": "resourcePath =~ /^\/workspaces\/website/"
7 }
8]
I’m binding Ctrl+N
to the workbench.action.tasks.runTask
command. The args
allow me to specify which Task I want to run. Because keybindings.json
is a user-level file (for
good reasons), it makes it a bit more challenging to have project-level configurations. I use the
when clause to limit the binding to a known path in my dev container. This way, I can use the same key binding for different tasks in different projects.
Testing the site
The next item I wanted to improve was testing the site. Hugo has a built-in development server that I can run with hugo serve
. This will build the site and start a server on http://localhost:1313/
. I can then open a browser to that address to see the site. The -D
flag tells Hugo to build the site in draft mode. This will include any posts that are marked as drafts. This lets me preview the post while I am creating in (with automatic reloading). To integrate that in, I’ll create another task:
1{
2 "label": "Serve Drafts",
3 "type": "shell",
4 "command": "hugo server -D -F --poll 700ms --enableGitInfo --port 1313"
5}
This lets me launch the site and automatically monitor changes while I’m working. If I wanted to monitor for issues, I could set the isBackground
flag and define problem matchers. VS Code would execute the task in the background and report back any Problems. This is particularly useful for continuous background linting. If you’re interested in this, review the
documentation for background tasks.
Validating the metadata
You may have noticed that my site defines a schema for the metadata. This lets me validate the content and ensures that the various fields have proper lengths to support Bing and Google searches. Normally, I can use
RedHat’s YAML extension to validate my YAML. Unfortunately, the extension that natively support Markdown in VS Code is not yet able to pass YAML front matter to the language server. To solve this, I created a custom task in PowerShell to assist me. The script used [Test-Json]
(https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/test-json?view=powershell-7.4) and
ConvertFrom-Yaml
(documentarian) to validate the schema for the posts.
1{
2 "label": "Validate Blog Metadata",
3 "type": "process",
4 "command": "pwsh",
5 "args": ["-command", "cd content/blog; ../../build/check-frontmatter.ps1" ],
6 "problemMatcher": [
7 {
8 "owner": "Markdown",
9 "fileLocation": "absolute",
10 "pattern": {
11 "regexp": "^(?<file>.+);(?<message>.+);(?<line>1);(?<column>1)$",
12 "file": 1,
13 "message": 2,
14 "line": 3,
15 "column": 4
16 }
17 }]
18}
By outputting the errors in a structured format, I could use the problem matcher to show the errors in the Problems pane and in the appropriate editor windows. That made it quick and easy to find and fix issues. For those interested, the code used Get-Content
to read each line of the markdown files. If the first line was ---
, it continued to read the lines until the next ---
. It used a regex to find the schema.
The basic code I used for testing the file looked like this:
1$schemaPath = (Join-Path $_.Directory.FullName $schema) | Resolve-Path -ErrorAction SilentlyContinue
2$finalSchemaPath = $schemaPath ?? "/workspaces/website/schema/blog.json"
3$converted = ConvertFrom-Yaml $yaml -ErrorAction Stop
4$json = ConvertTo-Json -Depth 5 $converted
5$err = $null
6$valid = Test-Json -Json $json -SchemaFile $finalSchemaPath -ErrorAction SilentlyContinue -ErrorVariable err
7[PSCustomObject]@{
8 File = $_
9 Directory = $_.Directory.Name
10 IsInvalidSchemaPath = $schemaPath -eq $null
11 Slug = $converted.slug
12 IsValid = $valid
13 SchemaError = $err.Exception.Message
14}
It allowed me to know when a file had a bad schema path and the specific errors (if any) for each file by directory, file name, or slug. To create the error messages for the problem matcher, I just used the SchemaError
and generate the error messages with some parsing and ConvertTo-Csv
. It wasn’t perfect, but it was good enough to get started. I like to time box any solutions and iterate on them later, so this was a fair compromise.
Just a few more tasks…
Over the years, I’ve added tasks for pain points as they would arise. I have tasks that validate all of the internal links between my posts. That came in handy as I migrated to Hugo! I don’t validate external links at the moment since there are a ton of those (and over time, many of them are likely to disappear). I also have a task that validates the file before publishing. I even have a process to validate the dates in the metadata and the folder remain aligned (since I may choose to postpone a post). There are even tasks for creating the speaking engagements, building and minifying the theme files, and validating the dev container.
In short – if it kept me from focusing on writing, I automated it. It’s the same approach I use for developing. I like to eliminate repetitive tasks that distract from core objectives. Many of these save only minutes per post. With 52 posts a year, each minute literally becomes a lost hour. My time is incredibly valuable to me.
Tasks are simple and very powerful, so they were a first choice for scripting any work that I needed to do frequently. With minimal code, you can quickly integrate the processes directly into VS Code. In my case, it allowed me to focus on being creative instead of the process behind providing the content. Hopefully you can see a few ways to use them yourself!