Converting flat HTML page to Hugo

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.mdcategories: ["development"], featured: true
  • content/posts/learning-lab.mdcategories: ["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/.

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.categories seems 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 (the categories-line div 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.

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 }}
          &nbsp;{{ .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--grid on the <ul> switches the homepage to a two-column layout — see the CSS in step 5f. Category and single-post pages keep the plain post-list class, 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 an image: 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 }}
      &nbsp;{{ .Date.Format "2 January 2006" }}
    </div>
    <h1>{{ .Title }}</h1>
  </div>

  <div class="post-body">
    {{ .Content }}
  </div>

  <a class="back-link" href="/">&larr; 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">&nbsp;{{ .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: 100vh flex 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 ![alt text](/images/photo.jpg) 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.

← Back to blog index