Hugo is a program to create static HTML files from Markdown files. Its quicker and easier to create pages and content than using Wordpress, and also means you don’t have to keep Wordpress up-to-date, or worry about a website getting hacked. However, it does mean you have to convert a flat-file HTML to a Hugo template. Here is a guide written by Claude to assist.
Converting chrisjlocke.co.uk from flat HTML to Hugo
This walks through turning the flat HTML site (index.html, three post pages,
three category pages, css/style.css) into a proper Hugo site. Each step
explains why, not just what, so you can apply the same thinking to future
changes.
The core idea in Hugo: content and presentation are separate.
- Content (post text, dates, categories) lives in small text files with a bit of metadata on top — called front matter.
- Presentation (the HTML structure, the header/footer, the styling) lives in templates that Hugo fills in with that content.
Right now your flat HTML has both mixed together in every file — the header
and footer are pasted into index.html and all six other pages. Hugo’s job
is to let you write that header/footer once, and write each post as just its
text.
1. Install Hugo and create the site skeleton
If you haven’t already:
winget install Hugo.Hugo.Extended
Then create a new site (do this somewhere separate from the flat HTML folder, so you can copy pieces across deliberately rather than dumping everything in):
hugo new site chrisjlocke-hugo
cd chrisjlocke-hugo
This gives you a skeleton:
chrisjlocke-hugo/
archetypes/
content/
layouts/
static/
themes/
hugo.toml
You’re building this without a theme — just like the CS Computer Services site, you’ll write your own layouts. That’s the right call here too: the site is simple enough that a theme would add more complexity than it saves, and you already know the exact HTML you want.
2. Move the CSS across
Anything in static/ gets copied to the final site root as-is, with no
processing. That’s exactly what you want for a plain CSS file.
mkdir -p static/css
cp /path/to/flat-html/css/style.css static/css/style.css
When Hugo builds the site, static/css/style.css ends up at
/css/style.css on the live site — same URL you’re already using, so no
CSS link paths need to change.
2a. Add the favicon
Same mechanism as the CSS file — the favicon files just need to live
somewhere under static/ so Hugo copies them across untouched.
mkdir -p static/favicon
cp /path/to/flat-html/favicon/* static/favicon/
That’s the file-copying half done. The other half is telling every page’s
<head> to actually reference them, which in Hugo means editing exactly
one file rather than eight — see step 5a below, where the <link> tags
go into baseof.html once and apply to every page automatically.
Files involved, and why there are several rather than just one .ico:
| File | Used by |
|---|---|
favicon.ico |
Older browsers, and as a fallback if nothing else matches |
favicon-16x16.png, favicon-32x32.png |
Modern browsers, tab icon at actual display size |
apple-touch-icon.png (180x180) |
iOS “Add to Home Screen” icon |
You don’t strictly need all of them — a single favicon.ico still works
almost everywhere — but the PNG variants render more sharply in browser
tabs since they’re not scaled from a lower-resolution source, and the
Apple touch icon is the only one that controls what shows up if the site’s
ever added to an iPhone home screen.
3. Set up config.toml
Open hugo.toml (Hugo’s main config file — TOML is just another
config-file format, similar in spirit to what SSIS or .config files do)
and set:
baseURL = "https://chrisjlocke.co.uk/"
languageCode = "en-gb"
title = "chrisjlocke.co.uk"
[taxonomies]
category = "categories"
That [taxonomies] block is the important bit. It tells Hugo: “treat
categories as a first-class grouping.” Once that’s declared, Hugo
automatically builds a page for every category and a listing page showing
all posts in it — you get category-development.html-style pages for
free, without writing them by hand. This replaces the three
category-*.html files you currently maintain manually.
4. Turn posts into content files
In Hugo, each post is a Markdown file with front matter at the top. Front
matter is the bit between --- lines — it’s metadata Hugo reads, separate
from the post body.
Create the posts folder:
mkdir -p content/posts
Take your post-ioniq-battery.html and strip it down. Everything that was
repeated boilerplate (the <head>, the header nav, the footer) goes away —
that’s now the template’s job, not the content’s. What’s left is just the
front matter and the actual words:
---
title: "Sorting out the Ioniq's 12V battery"
date: 2026-08-19
categories: ["ev"]
featured: true
---
Like most EVs, the Ioniq Electric still runs a small 12V lead-acid battery
alongside the main traction pack, quietly keeping the low-voltage systems
alive. Mine finally gave up, so a replacement was overdue.
<!--more-->
The swap itself was straightforward, but reconnecting it threw up a couple
of quirks I wasn't expecting. Regenerative braking reset back to its default
level, rather than remembering the setting I'd left it on. Not a big deal,
just a case of dialling it back in from the paddle shifters.
More oddly, the welcome light stopped working after the reconnect. A bit of
digging suggests this is a fairly common side effect of disconnecting the
12V supply, and it's usually just a case of letting the car's various
modules re-initialise properly over a few drive cycles.
Worth keeping an eye on the 12V battery health generally. It's a small,
cheap part, but a flat one can strand a car that's otherwise got 200 miles
of range sat in the main battery.
The <!--more--> line is the important addition. It marks exactly where the
homepage teaser stops — everything above it becomes .Summary (used by the
{{ .Summary }} call in the card templates), everything below it only
appears on the full post page. See section 4a below for why this is better
than leaving it out.
Save that as content/posts/ioniq-battery.md.
Do the same for the other two, each with its own <!--more--> placed after
whichever paragraph you want the teaser to stop at:
content/posts/hugo-migration.md—categories: ["development"],featured: truecontent/posts/learning-lab.md—categories: ["electronics"],featured: true
4a. Where does the teaser actually stop?
Worth being precise about this, since it’s easy to assume “teaser” means “first paragraph” or “first two paragraphs” — it doesn’t, by default.
Without a <!--more--> marker, Hugo’s .Summary takes the first 70
words of the plain text (configurable via summaryLength in
hugo.toml), regardless of where paragraphs happen to break. For the Ioniq
post above, the first paragraph is only about 45 words, so a 70-word
auto-summary would run on into the second paragraph and stop mid-sentence —
not at a clean break.
Adding <!--more--> overrides that entirely: whatever’s above the marker
is the summary, word count irrelevant. This is why every post above
includes one — it’s the only way to guarantee the homepage card teaser
stops exactly where you intend, rather than wherever a word-counter happens
to land.
Notice: no HTML tags anywhere. Hugo converts Markdown paragraphs into <p>
tags automatically. This is the main productivity win over flat HTML — a new
post is just prose and four lines of front matter, not a full page copy-paste.
The featured: true field is the one we talked about — it’s just a custom
piece of front matter. Hugo doesn’t know what it means until a template
checks for it (next section). Any field name works; Hugo stores whatever you
put in front matter and templates decide what to do with it.
5. Build the layouts
This is the part that replaces all that repeated header/footer HTML. Hugo
layouts live in layouts/.
5a. The base template — header, footer, page shell
Create layouts/_default/baseof.html. This is the skeleton every page
shares — think of it as the one place your header and footer live now,
instead of seven places.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} — {{ .Site.Title }}{{ end }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="/favicon/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<a href="/" class="logo">chrisjlocke</a>
<nav class="site-nav">
<a href="/">Blog</a>
<a href="/contact/">Contact</a>
</nav>
</div>
</header>
<main{{ if .IsHome }} class="main--wide"{{ end }}>
{{ block "main" . }}{{ end }}
</main>
<footer class="site-footer">
<a href="/">Blog index</a>
</footer>
</body>
</html>
Two things worth understanding here, since they’ll come up constantly:
{{ }}is Hugo template syntax — think of it like a placeholder that gets replaced at build time, similar in spirit to string interpolation in VB.NET, or how SSIS expressions get evaluated at package run time.{{ block "main" . }}{{ end }}is a hole that other templates fill in. Every page type (home page, single post, category list) defines its own"main"block with different content, but they all use this same surrounding header/footer.- The nav bar deliberately stays just a static “Blog” link, not a loop over
categories. Looping over
.Site.Taxonomies.categoriesseems tempting since it auto-updates, but it also means the header grows a new link every time you add a category — fine at 3, unusable at 20. The full category list already lives on the home page (thecategories-linediv in step 5b) and on each post’s category tag, so nothing is lost by keeping the header simple. - The four favicon
<link>tags live here too, once, rather than in every page’s<head>— this is the same “edit one file instead of eight” benefit that applies to the header and footer.
5b. The home page — only featured posts, two-column grid, thumbnails
Create layouts/index.html (Hugo treats this filename specially — it’s
always the home page):
{{ define "main" }}
<h1 class="page-title">Latest posts</h1>
<div class="categories-line">
Categories:
{{ range .Site.Taxonomies.categories.ByCount }}
<a href="{{ .Page.RelPermalink }}">{{ .Page.Title }}</a>
{{ end }}
</div>
<ul class="post-list post-list--grid">
{{ range where (where .Site.RegularPages "Type" "posts") "Params.featured" true }}
<li class="post-card{{ if .Params.image }} post-card--with-thumb{{ end }}">
{{ if .Params.image }}
<img class="post-thumb" src="{{ .Params.image }}" alt="">
{{ end }}
<div>
<div class="post-meta">
{{ range .Params.categories }}
<a class="category-tag" href="/categories/{{ . }}/">{{ . | title }}</a>
{{ end }}
{{ .Date.Format "2 January 2006" }}
</div>
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
<p class="post-excerpt">{{ .Summary }}</p>
</div>
</li>
{{ end }}
</ul>
{{ end }}
Two things changed from a plain single-column list:
post-list--gridon the<ul>switches the homepage to a two-column layout — see the CSS in step 5f. Category and single-post pages keep the plainpost-listclass, so they stay single-column; only the homepage gets the grid treatment, since that’s the page where showing more posts per screen actually helps.{{ if .Params.image }}wraps the thumbnail so posts without animage:field in their front matter render fine without a broken image or an empty gap — the card just falls back to text-only.
The filtering logic itself is unchanged from before — read it inside-out:
.Site.RegularPages is every content page on the site, the first where
narrows to just posts, the second narrows to only posts flagged
featured: true. Posts with no featured field, or featured: false, are
excluded automatically.
5c. The single post template
Create layouts/posts/single.html — this renders one post:
{{ define "main" }}
<article>
{{ if .Params.image }}
<img class="post-banner" src="{{ .Params.image }}" alt="">
{{ end }}
<div class="post-header">
<div class="post-meta">
{{ range .Params.categories }}
<a class="category-tag" href="/categories/{{ . }}/">{{ . | title }}</a>
{{ end }}
{{ .Date.Format "2 January 2006" }}
</div>
<h1>{{ .Title }}</h1>
</div>
<div class="post-body">
{{ .Content }}
</div>
<a class="back-link" href="/">← Back to blog index</a>
</article>
{{ end }}
{{ .Content }} is the entire Markdown body, already converted to HTML by
Hugo. This one template renders all three (and every future) post — that’s
the other big win over flat HTML, where each post was a full copy-pasted
page.
The same image: front matter field used for the homepage thumbnail is
reused here, just rendered full width instead of cropped small — one image
per post, two different crops via CSS rather than two separate files to
maintain. Front matter for a post with an image now looks like:
---
title: "Sorting out the Ioniq's 12V battery"
date: 2026-08-19
categories: ["ev"]
featured: true
image: "/images/ioniq-battery.jpg"
---
Drop the actual image file into static/images/ so the path above resolves
— same mechanism as the CSS file in step 2.
5d. The category listing template
Create layouts/categories/list.html — Hugo uses this automatically for
every taxonomy term page, because of the [taxonomies] config from step 3:
{{ define "main" }}
<h1 class="page-title">Category: {{ .Title | title }}</h1>
<ul class="post-list">
{{ range .Pages.ByDate.Reverse }}
<li class="post-card">
<div class="post-meta"> {{ .Date.Format "2 January 2006" }}</div>
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
<p class="post-excerpt">{{ .Summary }}</p>
</li>
{{ end }}
</ul>
{{ end }}
No featured filter here — deliberately. .Pages is just “every post
tagged with this category,” full stop, which is exactly the behaviour you
wanted: featured or not, it shows up once you’re inside the category.
This single file replaces category-development.html,
category-electronics.html, and category-ev.html — Hugo generates all
three pages (and any future category) from this one template plus whatever’s
in each post’s categories: front matter.
5e. The posts section listing (optional, silences a warning)
Hugo automatically creates a listing page for every content folder — so
content/posts/ gets its own page at /posts/, whether you use it or not.
Without a template for it, hugo prints a harmless warning:
WARN found no layout file for "html" for kind "section"
Nothing links to /posts/ on this site (the home page and category pages
cover everything), so this is safe to ignore. If you’d rather silence it
properly, add layouts/posts/list.html:
{{ define "main" }}
<h1 class="page-title">All posts</h1>
<ul class="post-list">
{{ range .Pages.ByDate.Reverse }}
<li class="post-card">
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
<p class="post-excerpt">{{ .Summary }}</p>
</li>
{{ end }}
</ul>
{{ end }}
5f. CSS additions — grid, thumbnails, banner
Three small additions to static/css/style.css cover the homepage grid and
both image sizes. Add alongside the existing .post-list and .post-card
rules:
/* Two-column layout, homepage only. Category and single-post pages keep
the plain single-column .post-list above untouched. */
.post-list--grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 700px) {
.post-list--grid {
grid-template-columns: 1fr;
}
}
/* Homepage gets a bit more width so the two columns have room to breathe */
main.main--wide {
max-width: 1000px;
}
/* Card thumbnail (homepage/category cards) */
.post-card--with-thumb {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.post-thumb {
width: 100px;
height: 74px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
/* Full-width banner on the single post page */
.post-banner {
width: 100%;
height: auto;
border-radius: 6px;
margin-bottom: 1.5rem;
}
The @media (max-width: 700px) rule is doing the responsive work — below
700px viewport width (phones, most portrait tablets), the grid collapses
back to a single column automatically. Nothing in the templates needs to
change for mobile; it’s handled entirely by CSS reacting to screen size,
the same way min-height: 100vh on the footer needed no per-device logic
either.
5g. Contact page (Formspree)
Unlike posts and categories, the contact page is a single one-off page —
no taxonomy, no listing, just one URL. Hugo’s default content type covers
this without needing a posts-style folder.
Create the content file:
mkdir -p content/contact
content/contact/_index.md:
---
title: "Contact"
layout: "contact"
---
That layout: "contact" line tells Hugo to look for a template called
contact rather than the generic default — which is what lets you write a
form directly in HTML rather than trying to force a contact form through
Markdown.
Create layouts/_default/contact.html:
{{ define "main" }}
<h1 class="page-title">{{ .Title }}</h1>
<!--
NOTE: replace YOUR_FORM_ID below with your actual Formspree endpoint.
Sign up at https://formspree.io, create a form, and it gives you a URL
like https://formspree.io/f/abcd1234 — that whole URL goes in the
action attribute in place of the placeholder.
-->
<form class="contact-form" action="https://formspree.io/f/YOUR_FORM_ID" method="POST">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
<button type="submit">Send message</button>
</form>
{{ end }}
Nothing in this form needs a Hugo template variable — Formspree just needs
a plain HTML <form> posting to its endpoint, and it emails you the
submission. No backend code, no server to run, which fits a static site
like this one where there’s nowhere for form-handling logic to live.
Add the matching CSS to static/css/style.css:
.contact-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 480px;
}
.contact-form label {
font-size: 0.9rem;
font-weight: 600;
margin-top: 0.75rem;
}
.contact-form input,
.contact-form textarea {
font: inherit;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--card-bg);
color: var(--text-dark);
}
.contact-form input:focus,
.contact-form textarea:focus {
outline: none;
border-color: var(--royal-blue);
}
.contact-form textarea {
resize: vertical;
}
.contact-form button {
margin-top: 1.25rem;
align-self: flex-start;
background: var(--royal-blue);
color: #fff;
border: none;
padding: 0.65rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.contact-form button:hover {
background: var(--royal-blue-dark);
}
Once the real Formspree ID is in place, the page needs no further wiring — Formspree handles spam filtering, email delivery, and a basic thank-you redirect on its side (configurable from their dashboard once the form exists there).
6. Preview it
From the chrisjlocke-hugo folder:
hugo server -D
-D includes draft content too, useful while you’re still moving posts
across. Hugo starts a local server (usually http://localhost:1313) and
rebuilds live as you edit files — similar in usefulness to how you’d use
dotnet watch to see WinForms/console changes without restarting manually,
except this also refreshes the browser for you.
Check:
- Home page shows only the 3 featured posts
- Clicking a category link shows all posts in that category
- Clicking a post shows the full text
- Footer stays pinned to the bottom (this was pure CSS, so it should carry
over unchanged — worth double-checking
min-height: 100vhflex layout still applies since Hugo doesn’t touch your CSS at all)
7. Build for real
hugo
This outputs static files into public/ — that folder is the entire
finished site, ready to upload to your hosting (123-Reg, in
chrisjlocke.co.uk’s case) exactly like the flat HTML was, just generated
instead of hand-written.
What you gained versus the flat HTML
| Flat HTML | Hugo |
|---|---|
| New post = copy a whole page, edit header/nav in it too | New post = one .md file, 4 lines of front matter |
| New category = write a new page by hand | New category = just used in front matter, page appears automatically |
| Header/footer change = edit in every file | Header/footer change = edit baseof.html once |
| “Only some posts on home page” = manually decide what to paste in | featured: true flag, filtered by one template line |
What’s genuinely worth comparing to the CS Computer Services build
That site was more complex because it had multiple distinct page types
(services, about, contact) rather than one repeating type (blog post) plus
a home page. The taxonomy mechanism here (categories) is the same
underlying Hugo feature as anything you used there for content grouping —
so if that site used YAML front matter for its own page data, you’re already
familiar with the format; this is just applying it to a narrower, more
repetitive case.
8. Deploying: WinSCP script + batch file
hugo builds the finished site into a public/ folder. Getting that folder
onto the live server at chrisjlocke.co.uk (hosted via 123-Reg) is a separate
step — this uses WinSCP’s own scripting language, driven by a small batch
file, so publishing becomes a double-click rather than a manual FTP session.
8a. One-time setup — save the site in WinSCP
Open WinSCP normally, fill in the connection details for the hosting
(host, port, protocol — FTP or FTPS, username, password), then click
Save instead of Login. Give it a memorable site name, e.g. chrisjlocke.
This stores the connection (password encrypted by WinSCP) so the deploy
script below never needs a plaintext password.
While you’re in there, note the exact remote folder that is the site’s web
root — often /public_html or /httpdocs depending on how 123-Reg
structures the account. The script needs this exact path.
8b. The WinSCP script — deploy.txt
Place this in the Hugo project root, next to hugo.toml:
# WinSCP script — syncs the Hugo "public" output folder to the live site.
# Adjust the site name and remote path to match your setup.
option batch abort
option confirm off
# Opens the saved site called "chrisjlocke" (set up once in the WinSCP GUI,
# via Login screen > fill in details > Save, rather than putting a password here)
open chrisjlocke
# Mirror local "public" folder to the remote web root.
# -delete removes remote files that no longer exist locally (keeps the live
# site an exact match of what Hugo just built — useful once you trust it,
# but do a dry run first, see 8d below).
# -criteria=either uploads a file if either its size or timestamp differs.
synchronize remote -delete -criteria=either "public" "/public_html"
close
exit
8c. The batch file — deploy.bat
Also in the Hugo project root:
@echo off
REM Rebuilds the Hugo site, then uploads the public folder via WinSCP.
REM Run this from the root of the Hugo project (where hugo.toml lives).
echo Building site with Hugo...
hugo
if errorlevel 1 (
echo Hugo build failed - aborting deploy.
pause
exit /b 1
)
echo Build OK. Uploading via WinSCP...
REM Adjust this path if WinSCP is installed somewhere else
"C:\Program Files (x86)\WinSCP\winscp.com" /script=deploy.txt /log=deploy.log
if errorlevel 1 (
echo WinSCP reported an error - check deploy.log
pause
exit /b 1
)
echo Deploy complete.
pause
Check the WinSCP path matches your install — 64-bit WinSCP is usually under
C:\Program Files\WinSCP\winscp.com rather than the (x86) folder.
8d. Dry run before trusting -delete
-delete removes anything on the server that isn’t present locally, which
is what you want for a clean mirror, but it’s worth confirming the remote
path is correct before it can delete the wrong thing. Open WinSCP normally,
connect to the saved site, and use the GUI’s own Synchronize dialog once
to preview what it would change. Once that matches expectations, the
scripted version does the same thing unattended.
8e. Day-to-day use
Once both files are in place: write a post, run hugo server -D to check it
locally, then double-click deploy.bat. It rebuilds public/ and pushes it
to the live server in one go. deploy.log lands next to the batch file
after each run, worth a glance the first few times to see exactly what got
uploaded or deleted.
9. Markdown cheat sheet
The bare minimum needed to write posts. Everything below goes in the post
body, below the front matter’s closing ---.
| What you want | Type this | Result |
|---|---|---|
| Heading | # Heading 1 |
Large heading |
| Subheading | ## Heading 2 |
Medium heading |
| Bold | **bold text** |
bold text |
| Italic | *italic text* |
italic text |
| Bold + italic | ***both*** |
both |
| Link | [link text](https://example.com) |
a clickable link reading “link text” |
| Image |  |
an embedded image |
| Bullet list | - item (one per line) |
a bulleted list |
| Numbered list | 1. item (one per line) |
a numbered list |
| Blockquote | > quoted text |
an indented quote block |
| Inline code | `code` |
code in a monospace font |
| Code block | three backticks, code, three backticks | a fenced, syntax-highlighted block |
| Horizontal rule | --- on its own line |
a divider line |
| Teaser cutoff | <!--more--> on its own line |
marks where the homepage summary stops |
The link syntax is the one that trips people up most — square brackets around the visible text, immediately followed by round brackets around the URL, no space between them:
[Formspree](https://formspree.io)
renders as a link reading “Formspree” that goes to formspree.io. Get the
brackets the wrong way round, or leave a space between ] and (, and it
just prints as plain text with brackets rather than becoming a link.
Headings: don’t use # (a top-level # Heading 1) inside a post body —
that’s reserved for the post title, which the template already renders from
front matter ({{ .Title }} in single.html). Start post subheadings at
## to avoid two “biggest” headings competing on the same page.
Do tables work?
Yes — no configuration needed. Hugo’s default Markdown renderer (Goldmark) supports GitHub-style tables out of the box:
| Category | Post count |
|---|---|
| EV | 1 |
| Development | 1 |
| Electronics | 1 |
renders as an actual <table> with borders picked up from whatever generic
table styling exists in style.css — none currently does, so a plain table
will render with browser-default styling (no borders, tight spacing) until
CSS is added for table, th, and td if you want it to match the rest of
the site’s look.