Not Today

I wasn’t planning to do any development today. I’m visiting family, but of course I brought my laptop - it’s like my keys at this point. A key to the digital world, if you want to be poetic about it.

I wanted to show my sibling what AI tools I’ve been using. Started with Claude Code (naturally), but also installed Ollama and LM Studio to demonstrate what’s possible with local models, especially with RooCode.

While waiting for family dinner, I figured I’d review some old blog posts. There are a few I’m not proud of anymore, things I disagree with now. Sure, they’ll still be on Archive.org, but this is more of a “clean my head” activity than anything else.

The Problem

So how do I review posts? The repo is cloned locally, I could view them in VSCode with the markdown preview. But that’s not great - images don’t load properly, formatting is off, Hugo shortcodes don’t render.

I started the Hugo dev server locally to see the posts as they actually look. But something was missing. I could see the post, but to edit it I’d need to find the file in VSCode, navigate to the right year folder, find the exact markdown file…

Wait. Doesn’t Hugo have some “edit on GitHub” feature? How does that work?

“Hey Claude, what if we could…”

And that’s where this coding session began.

What I Wanted

Simple: a button on each blog post that opens the markdown file directly in VSCode. Not GitHub, not a web editor - my local VSCode instance, right to the exact file.

This should only work in development mode. In production, on the live site, I don’t want this button showing up. It’s a local-only convenience feature.

The Solution

VSCode URI Scheme

VSCode supports vscode://file//absolute/path/to/file links. If VSCode is running and you click this link in a browser, it opens that file directly in your editor.

Perfect.

Hugo Environment Configs

Hugo has a nice config directory structure:

config/
├── _default/
│   └── config.toml      # Base config
├── development/
│   └── config.toml      # Dev overrides
└── production/
    └── config.toml      # Prod overrides

The _default config contains everything shared between environments. Then development and production configs override specific settings.

For the edit button, I added this to config/development/config.toml:

[params.editPost]
  enabled = true
  URL = "vscode://file"
  workingDir = "/path/to/repo"
  Text = "Edit in VSCode"

And in config/production/config.toml:

[params.editPost]
  enabled = false

Custom Partial

Hugo’s PaperMod theme already has an edit_post.html partial, but it’s designed for GitHub links. I created my own at layouts/partials/edit_post.html:

{{- if and .File site.Params.editPost.enabled -}}
{{- $filePath := .File.Path }}
{{- $workingDir := site.Params.editPost.workingDir }}
{{- $absolutePath := path.Join $workingDir "content" $filePath }}
{{- $editURL := printf "%s//%s" site.Params.editPost.URL $absolutePath | safeURL }}
{{- $editText := site.Params.editPost.Text | default "Edit in VSCode" }}

{{- if or .Params.author site.Params.author (.Param "ShowReadingTime") (not .Date.IsZero) .IsTranslated }} | {{- end -}}
<span class="edit-post-vscode">
    <a href="{{ $editURL }}" rel="noopener noreferrer edit" title="{{ $editText }}">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 1em; height: 1em; vertical-align: middle;">
            <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
            <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
        </svg>
        {{ $editText }}
    </a>
</span>
{{- end }}

Key points:

  • Only renders if site.Params.editPost.enabled is true
  • Gets the file path from Hugo’s .File.Path
  • Constructs the full vscode://file// URL
  • Uses safeURL to tell Hugo this is a safe custom protocol
  • Adds a little pencil icon for visual clarity

Makefile Shortcuts

Because I’m definitely going to forget the Hugo command flags, I added Makefile targets:

.PHONY: dev run

# Start Hugo development server
dev:
	hugo server -D --environment development

# Start Hugo production server (for local testing)
run:
	hugo server --environment production

Now I just run make dev and everything works.

Claude: The hardcoded path in the development config is intentional - it’s a local-only setting. If someone else clones the repo, they’d update their own development config with their path. It never goes to production anyway.

The Process

The whole thing took maybe 30 minutes, and most of that was figuring out the config structure and debugging the URL format. Hugo initially sanitized the vscode:// protocol with #ZgotmplZ, which is Hugo’s way of saying “this looks suspicious.” The safeURL pipe fixed that.

We started with a single config file, then realized the clean approach was the config directory structure. Moving from:

config.toml
config.development.toml  # doesn't merge properly

To:

config/_default/config.toml
config/development/config.toml
config/production/config.toml

Much cleaner. Hugo merges these automatically based on the --environment flag.

Does It Work with Slides and Projects?

Good question. (Yes, this was a rhetorical question for the post, but let’s answer it anyway.)

The partial is called from the PaperMod theme’s _default/single.html layout, which is used for all single-page content types. So yes, it should work for slides and projects too, assuming they use the same base layout and have a .File object.

I haven’t tested it yet on those content types, but the code doesn’t discriminate. If Hugo knows the file path, the button appears.

What I Learned

  1. VSCode URI scheme is powerful - vscode://file// is a simple way to integrate local editing into web workflows
  2. Hugo config directory structure is underrated - Much cleaner than single-file configs with conditionals
  3. Environment-based features are useful - Development-only conveniences don’t need to be production-ready
  4. Hugo’s safeURL is necessary - Custom protocols get sanitized by default
  5. Even casual activities turn into code - I just wanted to review old posts, and ended up building a feature

Why This Matters

This is a tiny quality-of-life improvement. But those add up. When I’m reviewing content, every little bit of friction matters. Open browser, see post, spot a typo, click button, fix typo, refresh browser. Done.

No context switching, no hunting for files, no mental overhead. Just smooth workflow.

And honestly? That’s what good tooling feels like - invisible until you need it, obvious once it’s there.


Now I can finally review those old posts. With one click to edit. While waiting for dinner. On a family visit.

Maybe I should go put the laptop away.