Intro

Wide horizontal logo of the Hugo static site generator
another thrilling meta article about Hugo!

When I decided to start this site, I thought WordPress was the obvious choice. I’ve used it before for a few projects but every time I revisit WordPress, I am quickly reminded that I just don’t enjoy working in it. There are so many options and plugins and themes that it becomes a bit overwhelming and I just wanted to start writing.

Django was certainly an option—I’ve built a number of decent web applications with it over the years and could certainly build a django-based blog but that was also a huge stack of technology to deal with before I could write the first hello world post.

Happily I stumbled upon the Hugo static site generator via a YouTube rabbit hole. It was markdown-based, fast, simple and scriptable and lends itself to some clever CLI workflows. Exactly what I was going for.

🧾 View Other Hugo Posts


First Impressions

“I changed my headlights the other day. I put in strobe lights instead! Now when I drive at night, it looks like everyone else is standing still …” – Steven Wright

Some impressions after 2 months of working with Hugo (especially compared to other CMS or manual web design approaches):

  • Markdown everywhere = good.
  • GoLang code, optimized for speed and flexible templating. Active development.
  • Easy to use inline HTML with a setting in config.toml
  • Just about everything can be configured and overridden if desired
  • PaperMod theme is clean but takes some tweaking to look “less academic.”
  • Git feels natural here; the site is a repo. Easy to jump between machines and keep working.
  • CLI + scripts give me more control than a CMS ever would.
  • Static site generation so the site is fast and lightweight while still featuring some impressive scripted bits

Editing Options

I’ve tried a bunch of different tools to write and edit posts:

  • Vim: unbeatable for quick edits, regex magic, and staying close to the shell.
  • VS Code: excellent overall, though it still lacks a truly reliable spellchecker.
  • Obsidian: great for note-taking and linking ideas, but with some quirks.
  • Typora: clean interface and live preview, though not much beyond Hugo’s built-in preview.

These days I bounce between VS Code (for structure) and Vim (for quick fixes), always wishing for a universal spellcheck to catch my numerous typos. Working in Markdown keeps me close to the writing while hiding the verbosity of HTML and CSS. When I need more control, Goldmark’s settings let me drop into raw HTML without friction.

Obsidian is very tempting to use but it’s vault structure is dirty with respect to git with lots of things you have to add to .gitignore with varying degrees of difficulty. I originally made a sync script to maintain a mirror between Obsidian and Hugo’s production directory in my codelab folder. That worked but I vastly prefer VS Code with its integrated Terminal and Git support as my main editor. The drawbacks with VS Code are a lack of spellchecking and a separate tabbed view for rendered markdown. Not a big deal.

My workflow is simple: one terminal tab runs hugo server -D, while two browser tabs track the local dev server and the public site. Make a change (usually in VSCode), and see it reflected instantly. Everything is tracked in git. When it’s time to publish, one script takes it live.


Publishing Workflow

After setup, the main challenge was getting updates live without friction. WordPress gave me gui menus, Hugo gives me a (Go) binary. Perfect excuse to write my own tooling. I was able to quickly identify a manual workflow and then build a nice, robust script to automate it. What a lovely experience.

What I ended up with was the following publish script that I run almost daily. It handles cleaning, building, syncing—everything I don’t want to think about (or mess up).

🔗 View publish-hugo on GitHub

Why not Git hooks or CI? Because I like the flexibility of running this script on any machine, without a network dependency.

The script automates the full Hugo publishing cycle with built-in safety checks. It starts by making sure any hugo server process is stopped, then moves into the correct project directory and performs a clean rebuild with hugo --cleanDestinationDir. Before anything goes live, it can snapshot your work in Git:

  • Show a concise diff if there are uncommitted changes.
  • Prompt you whether to commit and push.
  • Auto-build the commit message from changed .md files, leaving a traceable record of what was published.

From there, publish-hugo.sh (or publish-adminjitsu.sh) adds some quality-of-life touches:

  • Draft detection – warns you if you’re about to ship drafts (forgot to flip status).
  • Rsync prompt – asks before running the destructive rsync --delete.
  • Dry-run mode – lets you preview the sync without touching the server.

The rsync step itself is straightforward but effective, mirroring your public/ folder to the remote web root in one shot. And because it’s a Bash script, the UX has personality: colorful prompts, conversational feedback, and even a celebratory fortune if you have that installed.

In practice, this makes for a deploy process that’s safe, repeatable, and just a little bit fun.


Pre-Press and Sanity Checks

“In theory there is no difference between theory and practice. In practice there is.” – Benjamin Brewster

Publishing text isn’t just about writing—it’s also about cleaning and organizing. In my hugo directory I created a scratch/ folder and scratch/bin/ to hold helper scripts and test pages as I create them. It’s a good place to keep site tools and I have built a few decent ones already.


metaclean

Metaclean is a simple, robust tool for working with images in pre-press workflows. It can scan and strip metadata in a variety of ways, which makes it very scriptable.

Check out my 🔗 Metaclean post for the script.


tag-audit

Tag audit is a simple script that reports on category and tag usage in my posts. It helps me to keep my taxonomies straight and to spot typos and outliers and to refresh my memory without looking at each posts front matter. All in a nice, flexible report.

Check out my 🔗 Tag Audit post for the script.


asset demos

I find it useful to keep various demo pages in my scratch/bin folder. Hugo ignores arbitrary folders like this and scratch is my convention. I use it to store all sorts of helpers and notes that are strictly relating to the site as opposed to a more general tool.

Some examples of things that I find useful to keep around:

  • font-previews
  • BOILERPLATE.txt with useful snippets
  • POST-IDEAS.txt where I can jot down rough ideas that pop into my head
  • various little test scripts
  • a WIP bin where I can keep articles that I’m not ready to work on yet

One tool that is extremely useful is this dynamic javascript powered feather icon picker. Feather icons are SVG (vector based) icons that scale to any size and have a number of styling options. My picker is pretty cool despite being hard as heck to get just right. It allows you to easily browse, search, filter, adjust stroke width, scale, and copy the icon in a ready-to-go <i data-feather="home"></i> block that you can copy and paste, like here:

Check out 🔗 my feather-picker.html tool


Tips and Tricks

Some CLI tools and habits that keep me sane:

  • find, grep, sed, and awk for batch edits.
  • vim -p to open multiple posts side by side.
  • Running Hugo with --cleanDestinationDir so stale files don’t stick around.
  • Using shortcodes and custom CSS tweaks from my style sheet.

Problem solving

So far, the majority of issues I’ve encountered can be resolved by stopping the server, running hugo --cleanDestinationDir and starting the server again. The other is that I forgot to set draft to false in the frontmatter. Virtually every other problem will produce a big, descriptive error on the dev server giving you a chance to fix the problem and saving you from uploading (most) broken code to your webhost. Getting shortcodes to work can be a little tricky but bouncing the server helps with runtime issues.

Git is your friend when it gets confusing:

# 1. Show changes to a file vs. last commit
git diff HEAD -- path/to/file

# 2. Restore file from the last commit (discard current changes)
git restore path/to/file

# 3. Restore file to a specific commit (useful when you know the good hash)
git checkout <commit-hash> -- path/to/file

# 4. If you want to revert *all* changes since last commit (scorched earth)
git reset --hard HEAD

Style playground

I made this demo page public for fun and interest. Basically I have a style dojo page where I try out each markdown element and made it useful as a glyph board from which I can copy various colors, and feather, unicode, and emoji symbols. It’s great having a silly, but useful, testing ground where I can quickly try out CSS changes and see the results in one place. The original markdown also gives me a crib sheet from which I can copy useful snippets when I forget the syntax.

I kind of adore living documentation!

Check out the 🔗 Ninjas Style Dojo post for the shenanigans.



CSS for Left-side floating Table of Contents

I created z_toc-left.css in assets/css/extended containing the following to create and lock my floating toc to the left side of the content area:

/* --- FLOATING TOC ON LEFT (FIXED TO CONTENT COLUMN) --- */

#toc-sidebar {
    position: fixed;
    top: 120px;

    /* 1000px max content width → 500px half width */
    /* 320px TOC width + 48px gap */
    left: calc(50% - 500px - 368px);

    width: 320px;
    max-width: 380px;
    max-height: calc(100vh - 140px);
    overflow-y: auto;
    background: var(--entry);
    border: none;
    border-radius: 8px;
    padding: 1em 1.5em;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
    z-index: 1000;
}



@media (max-width: 1380px) {
    #toc-sidebar {
        position: static;
        width: auto;
        max-height: none;
        box-shadow: none;
        margin-bottom: 1rem;
        left: unset;
    }
}

I have been experimenting with settings as I test on different platforms. So far it works quite well on wide screens, collapsing to an inline menu on narrower windows (like mobile browsers). It’s not perfect but it’s easy to tweak. Worst case, I just git restore z_toc-left.css and I’m back to a clean setup


Update It was a bit of a challenge to get my TOC to indent and format headings correctly, so I thought I would share what I figured out. Here’s the CSS + JS combo I landed on to make heading levels show up clearly in the sidebar:

I added the following CSS to assets/css/extended/z_toc-left.css: (this handles the indentation, bullets, and subtle guide rails once each TOC item has a depth class)

📜 CSS: TOC Indent Styles
/* === BEGIN: Adminjitsu TOC Indent v1 === */
/* Make sure list markers/indent can actually show */
#toc-sidebar ol,
#toc-sidebar ul {
    list-style-position: outside !important;
}

#toc-sidebar li {
    list-style: disc;
}

/* H2 (##) */
#toc-sidebar li.toc-depth-2 {
    margin-left: 0;
    font-weight: 600;
}

/* H3 (###) */
#toc-sidebar li.toc-depth-3 {
    margin-left: 1rem;
    list-style-type: circle;
    font-size: 0.95em;
    opacity: 0.9;
}

/* H4 (####) */
#toc-sidebar li.toc-depth-4 {
    margin-left: 1.6rem;
    list-style-type: square;
    font-size: 0.9em;
    opacity: 0.8;
}

/* Optional: subtle guide rail for sublevels */
#toc-sidebar li.toc-depth-3,
#toc-sidebar li.toc-depth-4 {
    position: relative;
}

#toc-sidebar li.toc-depth-3::before,
#toc-sidebar li.toc-depth-4::before {
    content: "";
    position: absolute;
    left: -0.6rem;
    top: 0.25em;
    bottom: 0.25em;
    width: 1px;
    background: var(--tertiary);
}
/* === END: Adminjitsu TOC Indent v1 === */

And added the following script to layouts/partials/extend_footer.html: (the theme’s TOC doesn’t tag list items by heading level out of the box, so this script inspects the page headings, figures out their depth, and adds a class like toc-depth-2 or toc-depth-3. Without this, our CSS wouldn’t know which styles to apply.)

⚙️ JS: TOC Depth Tagger
// === BEGIN: Adminjitsu TOC Depth Tagger v1 ===
(function () {
  const TOC_CONTAINER = '#toc-sidebar'; // your left TOC wrapper

  function tagTOCDepth() {
    const toc = document.querySelector(TOC_CONTAINER);
    if (!toc) return;

    const links = toc.querySelectorAll('a[href^="#"]');
    if (!links.length) return;

    links.forEach((a) => {
      const id = a.getAttribute('href').slice(1);
      if (!id) return;
      const h = document.getElementById(id);
      if (!h) return;

      const depthMap = { H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 };
      const depth = depthMap[h.tagName] || 2;

      const li = a.closest('li') || a.parentElement;
      if (li) {
        li.classList.forEach((c) => {
          if (c.startsWith('toc-depth-')) li.classList.remove(c);
        });
        li.classList.add(`toc-depth-${depth}`);
      }
    });
  }

  document.addEventListener('DOMContentLoaded', tagTOCDepth);
  window.addEventListener('load', tagTOCDepth);

  const mo = new MutationObserver((muts) => {
    for (const m of muts) {
      if (m.type === 'childList') {
        tagTOCDepth();
        break;
      }
    }
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });
})();
// === END: Adminjitsu TOC Depth Tagger v1 ===


CSS to increase content area width on wide screens

I added the following (I’m using Paper Mod so check the documentation for your theme) to theme-vars-override.css. Customize the --main-width variable.

:root {
    --code-block-bg: #58585a;
    --code-text-color: #f8f8f2;
    --main-width: 1000px;
}


Responsive image use

I had to experiment a bit to find the right, responsive pattern for images. Without it they go full KAIJU mode and spill out of the content area on smaller screens rather than resizing like you probably want. You can see this in action in the following two examples. Either resize your browser down or view on a small screen and the first example behaves while the second tries to destroy Tokyo

Godzilla behind the scenes - responsive example
Responsive: scales with the column on small screens, never exceeds 800 px on wide screens.
Godzilla behind the scenes - non-responsive example
Broken on phones: uses only max-width:800px, so it overflows when the content area is narrower than 800 px.

The key is to use width:min(100%, 800px); or whatever maximum width you desire, in the style line like this:

<figure style="text-align:center; margin: 1em auto;">
  <img src="kaiju.jpg"
       alt="Godzilla behind the scenes"
       style="display:block; margin:0 auto; width:min(100%, 800px); height:auto;">
  <figcaption style="font-size:85%; color:#666; line-height:1.4; margin-top:0.4em;">
    Responsive kaiju — scales down neatly on phones, capped at 800px wide.
  </figcaption>
</figure>

An example of a responsive, plain img tag:

<p align="center">
  <img src="kaiju.jpg"
       alt="Godzilla behind the scenes"
       style="width:min(100%, 800px); height:auto; display:block; margin:0 auto;">
</p>

Plain markdown images ![](foo.jpg) in PaperMod (and most Hugo themes) sets the rendered html tag to max-width:100%; height:auto; in CSS so they are responsive out of the box. The downside of course is that you can’t tweak captions, widths or add styles.

Check out my 🔗 Espanso howto for a good way to keep track of boilerplate like proper html 5 figure statements and other long snippets.


Report Functions

Add the following to your startup files (e.g., .bashrc or .zshrc) and reload.

This reports on each article with the title and a word and line count. Good for a quick overview

article_stats(){
  local count=0
  while IFS= read -r -d '' f; do
    title=$(awk -F': ' '/^title:[[:space:]]*/{sub(/^title:[[:space:]]*/,""); gsub(/^"|"$/, "", $0); print; exit}' "$f")
    printf '>>> %s\n' "$f"
    [ -n "$title" ] && printf '   title: %s\n' "$title"
    awk 'BEGIN{w=0;l=0} {l++; w+=NF} END{printf "   words: %d   lines: %d\n", w, l}' "$f"
    count=$((count+1))
  done < <(find ./content/posts -type f -name index.md -print0)
  printf 'Total articles: %d\n' "$count"
}

This alias prints out each article with it’s front matter and makes for a handy overview of your site. I know this will scale badly so I’ll come up with a better function-based report to replace it eventually. It’s useful as is though.

alias article_fm='find . -type f -name "index.md" -print0 | \
  xargs -0 -n1 sh -c '\''echo ">>> $1"; awk "/^---/{if (inblock){inblock=0; print \"----------------\"; exit} else {inblock=1; next}} inblock" "$1"; echo'\'' sh'

HTML & CSS References

Hugo Resources


Conclusion

Hugo doesn’t feel like a CMS. It feels like a tool that gets out of the way once you wire it into your workflow. With a couple of scripts and sanity checks, publishing feels less like blogging and more like running make deploy && go get coffee.

🧾 View Other Hugo Posts

Have a favorite Hugo trick? Or just a good spellchecker for VS Code?
📬 feedback@adminjitsu.com — always happy to swap notes.