Rendering Learning Hub with Hugo: Practical Implementation Guide

hugo
learning-hub
tutorial
implementation
migration
Step-by-step guide to configure and render the Learning Hub documentation site with Hugo, including content migration, theme setup, and deployment to GitHub Pages
Author

Dario Airoldi

Published

January 14, 2026

Modified

March 8, 2026

Rendering Learning Hub with Hugo

πŸ“– Overview

This practical guide demonstrates how to configure Hugo to render the Learning Hub documentation site (currently built with Quarto), covering content structure mapping, theme configuration, custom templates, and deployment to GitHub Pages.

Learning Hub Characteristics:

  • Size: 500+ markdown files
  • Sections: News, Events, Tech, How-to, Issues, Ideas, Travel
  • Current tech: Quarto β†’ docs/ β†’ GitHub Pages
  • Output target: Same (docs/ for GitHub Pages compatibility)
  • Special features: Dual YAML metadata, validation tracking, article series

🎯 Project Goals

What we’re building:

  1. Hugo site structure matching Learning Hub organization
  2. Custom theme similar to current Quarto cerulean theme
  3. Automated navigation from directory structure
  4. Series support for article sequences
  5. Search functionality with JSON index
  6. GitHub Pages deployment compatible with current setup

Constraints:

  • βœ… Keep existing content structure (01.00-news/, 02.00-events/, etc.)
  • βœ… Support dual YAML metadata (top for rendering, bottom for validation)
  • βœ… Maintain GitHub Pages deployment workflow
  • βœ… Fast builds (<10 seconds for 500 files)

πŸ“¦ Installation and Setup

Step 1: Install Hugo Extended

Windows (PowerShell):

# Using Chocolatey
choco install hugo-extended

# Or using Scoop
scoop install hugo-extended

# Verify installation
hugo version
# Should show: hugo v0.122.0+extended

macOS:

brew install hugo

hugo version

Linux:

wget https://github.com/gohugoio/hugo/releases/download/v0.122.0/hugo_extended_0.122.0_Linux-64bit.tar.gz
tar -xzf hugo_extended_0.122.0_Linux-64bit.tar.gz
sudo mv hugo /usr/local/bin/
hugo version

Step 2: Create Hugo Site Structure

Parallel to current Quarto setup:

# Navigate to Learn repository
cd c:\dev\darioa.live\darioairoldi\Learn

# Create Hugo subdirectory (option 1: side-by-side testing)
mkdir hugo-site
cd hugo-site
hugo new site . --force

# OR: Initialize Hugo in root (option 2: migration path)
# hugo new site . --force

Resulting structure:

Learn/
β”œβ”€β”€ _quarto.yml              # Existing Quarto config
β”œβ”€β”€ 01.00-news/              # Existing content
β”œβ”€β”€ 02.00-events/            # Existing content
β”œβ”€β”€ ...
β”œβ”€β”€ hugo-site/               # New Hugo site
β”‚   β”œβ”€β”€ config.toml          # Hugo configuration
β”‚   β”œβ”€β”€ content/             # Hugo content (symlink or copy)
β”‚   β”œβ”€β”€ layouts/             # Custom templates
β”‚   β”œβ”€β”€ static/              # Static assets
β”‚   └── themes/              # Theme directory
└── docs/                    # Output (current Quarto, future Hugo)

βš™οΈ Hugo Configuration

config.toml

hugo-site/config.toml:

baseURL = "https://darioairoldi.github.io/Learn/"
languageCode = "en-us"
title = "Dario's Learning Hub"
theme = "learnhub-theme"

# Output to docs/ for GitHub Pages compatibility
publishDir = "../docs"

# Preserve section structure
[permalinks]
  news = "/news/:slug/"
  events = "/events/:slug/"
  tech = "/tech/:sections[1:]/:slug/"
  howto = "/howto/:slug/"
  issues = "/issues/:slug/"
  ideas = "/ideas/:slug/"

# Taxonomies for Learning Hub
[taxonomies]
  tag = "tags"
  category = "categories"
  series = "series"
  technology = "technologies"
  event = "events"

# Markdown configuration
[markup]
  [markup.goldmark]
    [markup.goldmark.renderer]
      unsafe = true              # Allow HTML (for dual YAML metadata)
    [markup.goldmark.parser]
      autoHeadingID = true
      autoHeadingIDType = "github"
      [markup.goldmark.parser.attribute]
        block = true
        title = true
    [markup.goldmark.extensions]
      definitionList = true
      footnote = true
      linkify = true
      strikethrough = true
      table = true
      taskList = true
      typographer = true
  
  [markup.highlight]
    anchorLineNos = true
    codeFences = true
    guessSyntax = false
    lineNumbersInTable = true
    noClasses = false
    style = "github"
    tabWidth = 2
  
  [markup.tableOfContents]
    endLevel = 3
    ordered = false
    startLevel = 2

# Site parameters
[params]
  description = "Technical learning notes and documentation"
  author = "Dario Airoldi"
  dateFormat = "January 2, 2006"
  
  # Enable features
  toc = true
  readingTime = true
  searchEnabled = true
  
  # Social/contact
  github = "https://github.com/darioairoldi"
  
[outputs]
  home = ["HTML", "RSS", "JSON"]  # JSON for search
  section = ["HTML", "RSS"]
  page = ["HTML"]

# Menu configuration (auto-generated from content)
[menu]
  [[menu.main]]
    name = "News"
    identifier = "news"
    url = "/news/"
    weight = 1
  
  [[menu.main]]
    name = "Events"
    identifier = "events"
    url = "/events/"
    weight = 2
  
  [[menu.main]]
    name = "Tech"
    identifier = "tech"
    url = "/tech/"
    weight = 3
  
  [[menu.main]]
    name = "How-to"
    identifier = "howto"
    url = "/howto/"
    weight = 4
  
  [[menu.main]]
    name = "Issues"
    identifier = "issues"
    url = "/issues/"
    weight = 5
  
  [[menu.main]]
    name = "Ideas"
    identifier = "ideas"
    url = "/ideas/"
    weight = 6

# Build configuration
[build]
  writeStats = true
  
  [[build.cachebusters]]
    source = "assets/.*\\.(js|ts|jsx|tsx)"
    target = "(js|scripts|javascript)"
  
  [[build.cachebusters]]
    source = "assets/.*\\.(.*)$"
    target = "$1"

πŸ“ Content Organization

Mapping Quarto to Hugo Structure

Current Quarto:

01.00-news/20251224-vscode-v1.107-release/
β”œβ”€β”€ 01-summary.md
└── 02-readme.sonnet4.md

Hugo equivalent (option 1 - page bundle):

content/news/20251224-vscode-v1107-release/
β”œβ”€β”€ index.md                 # Main article
β”œβ”€β”€ summary.md               # Additional content
└── images/                  # Co-located images

Hugo equivalent (option 2 - section with files):

content/news/
β”œβ”€β”€ 20251224-vscode-v1107-release.md
└── 20251224-vscode-v1107-release-summary.md

Content Directory Setup

hugo-site/content/ structure:

# Create section directories
mkdir -p content/{news,events,tech,howto,issues,ideas,travel}

# Create section homepages (_index.md)
# These define section landing pages

**content/news/_index.md:**

---
title: "Latest News"
description: "Updates on VS Code, tools, and technologies"
menu:
  main:
    name: "News"
    weight: 1
---

Stay updated with the latest releases and announcements.

**content/tech/_index.md:**

---
title: "Technical Articles"
description: "Deep dives into Azure, .NET, GitHub, and more"
cascade:
  params:
    show_toc: true
    content_type: "technical"
menu:
  main:
    name: "Tech"
    weight: 3
---

Technical documentation and learning notes organized by technology.

Handling Dual YAML Metadata

Learning Hub uses dual YAML blocks (top for rendering, bottom for validation). Hugo’s front matter parser handles only the top YAML.

Current format:

---
title: "Article Title"
author: "Dario Airoldi"
date: "2026-01-14"
categories: [hugo, documentation]
---

Article content...

<!-- Validation Metadata (Do Not Edit) -->
<!--
article_metadata:
  filename: "article.md"
  series: "Hugo Guide"
  position: 5
validation_status:
  last_run: "2026-01-14T00:00:00Z"
  status: "validated"
-->

Hugo behavior:

  • βœ… Top YAML parsed as front matter
  • βœ… HTML comment ignored (not rendered)
  • βœ… Goldmark with unsafe = true preserves HTML comments
  • βœ… No modification needed!

🎨 Theme Development

Creating Custom Theme

# Create theme structure
cd hugo-site
hugo new theme learnhub-theme

Result:

themes/learnhub-theme/
β”œβ”€β”€ layouts/
β”‚   β”œβ”€β”€ _default/
β”‚   β”‚   β”œβ”€β”€ baseof.html      # Base template
β”‚   β”‚   β”œβ”€β”€ list.html        # Section pages
β”‚   β”‚   └── single.html      # Article pages
β”‚   β”œβ”€β”€ partials/
β”‚   β”‚   β”œβ”€β”€ head.html
β”‚   β”‚   β”œβ”€β”€ header.html
β”‚   β”‚   β”œβ”€β”€ footer.html
β”‚   β”‚   └── toc.html
β”‚   └── index.html           # Homepage
β”œβ”€β”€ static/
β”‚   β”œβ”€β”€ css/
β”‚   └── js/
└── theme.toml

Base Template (baseof.html)

**themes/learnhub-theme/layouts/_default/baseof.html:**

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  {{ partial "head.html" . }}
  <body>
    {{ partial "header.html" . }}
    
    <main id="main-content">
      <div class="container">
        {{ block "main" . }}{{ end }}
      </div>
    </main>
    
    {{ partial "footer.html" . }}
  </body>
</html>

Article Template (single.html)

**themes/learnhub-theme/layouts/_default/single.html:**

{{ define "main" }}
<article class="article">
  <header class="article-header">
    <h1>{{ .Title }}</h1>
    
    <div class="article-meta">
      {{ with .Params.author }}
        <span class="author">By {{ . }}</span>
      {{ end }}
      
      <span class="date">{{ .Date.Format "January 2, 2006" }}</span>
      
      {{ with .Params.reading_time }}
        <span class="reading-time">{{ . }} min read</span>
      {{ else }}
        <span class="reading-time">{{ .ReadingTime }} min read</span>
      {{ end }}
    </div>
    
    {{ with .Params.categories }}
      <div class="categories">
        {{ range . }}
          <span class="category">{{ . }}</span>
        {{ end }}
      </div>
    {{ end }}
  </header>
  
  {{ if .Params.toc }}
    <aside class="toc">
      <h2>Table of Contents</h2>
      {{ .TableOfContents }}
    </aside>
  {{ end }}
  
  <div class="article-content">
    {{ .Content }}
  </div>
  
  {{ if .Params.series }}
    {{ partial "series-navigation.html" . }}
  {{ end }}
  
  {{ with .Site.RegularPages.Related . | first 5 }}
    <aside class="related-articles">
      <h3>Related Articles</h3>
      <ul>
        {{ range . }}
          <li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
        {{ end }}
      </ul>
    </aside>
  {{ end }}
  
  <footer class="article-footer">
    {{ with .Params.tags }}
      <div class="tags">
        <strong>Tags:</strong>
        {{ range . }}
          <a href="{{ "tags/" | relLangURL }}{{ . | urlize }}" class="tag">{{ . }}</a>
        {{ end }}
      </div>
    {{ end }}
  </footer>
</article>
{{ end }}

Series Navigation Partial

themes/learnhub-theme/layouts/partials/series-navigation.html:

{{ with .Params.series }}
  {{ $currentSeries := index . 0 }}
  {{ $seriesPages := where site.RegularPages ".Params.series" "intersect" (slice $currentSeries) }}
  {{ $seriesPages = $seriesPages.ByParam "position" }}
  
  {{ $currentIndex := 0 }}
  {{ range $index, $page := $seriesPages }}
    {{ if eq $page.Permalink $.Permalink }}
      {{ $currentIndex = $index }}
    {{ end }}
  {{ end }}
  
  <nav class="series-navigation">
    <h3>Series: {{ $currentSeries }}</h3>
    
    <div class="series-nav-links">
      {{ if gt $currentIndex 0 }}
        {{ $prev := index $seriesPages (sub $currentIndex 1) }}
        <a href="{{ $prev.Permalink }}" class="series-prev">
          ← Previous: {{ $prev.Title }}
        </a>
      {{ end }}
      
      {{ if lt $currentIndex (sub (len $seriesPages) 1) }}
        {{ $next := index $seriesPages (add $currentIndex 1) }}
        <a href="{{ $next.Permalink }}" class="series-next">
          Next: {{ $next.Title }} β†’
        </a>
      {{ end }}
    </div>
    
    <details class="series-list">
      <summary>All articles in this series ({{ len $seriesPages }})</summary>
      <ol>
        {{ range $index, $page := $seriesPages }}
          <li {{ if eq $page.Permalink $.Permalink }}class="current"{{ end }}>
            <a href="{{ $page.Permalink }}">{{ $page.Title }}</a>
          </li>
        {{ end }}
      </ol>
    </details>
  </nav>
{{ end }}

Styling (Cerulean Theme Adaptation)

themes/learnhub-theme/assets/scss/main.scss:

// Import existing cerulean.scss from Quarto setup
// Or create Hugo-compatible version

// Variables (matching Quarto cerulean theme)
$primary-color: #2FA4E7;
$text-color: #333;
$bg-color: #fff;
$code-bg: #f5f5f5;

// Base styles
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  line-height: 1.6;
  color: $text-color;
  background: $bg-color;
}

// Article styles
.article {
  max-width: 800px;
  margin: 2rem auto;
  padding: 0 1rem;
  
  &-header {
    margin-bottom: 2rem;
    
    h1 {
      font-size: 2.5rem;
      margin-bottom: 0.5rem;
      color: $primary-color;
    }
  }
  
  &-meta {
    color: #666;
    font-size: 0.9rem;
    margin-bottom: 1rem;
    
    span {
      margin-right: 1rem;
    }
  }
  
  &-content {
    font-size: 1.1rem;
    
    h2, h3, h4 {
      margin-top: 2rem;
      margin-bottom: 1rem;
    }
    
    code {
      background: $code-bg;
      padding: 0.2em 0.4em;
      border-radius: 3px;
      font-size: 0.9em;
    }
    
    pre {
      background: $code-bg;
      padding: 1rem;
      border-radius: 5px;
      overflow-x: auto;
      
      code {
        background: none;
        padding: 0;
      }
    }
  }
}

// TOC styles
.toc {
  background: #f9f9f9;
  border-left: 4px solid $primary-color;
  padding: 1rem;
  margin-bottom: 2rem;
  
  h2 {
    margin-top: 0;
    font-size: 1.2rem;
  }
  
  ul {
    list-style: none;
    padding-left: 0;
    
    ul {
      padding-left: 1.5rem;
    }
  }
}

// Series navigation
.series-navigation {
  background: #f0f8ff;
  border: 1px solid #b0d4f1;
  border-radius: 5px;
  padding: 1.5rem;
  margin: 2rem 0;
  
  h3 {
    margin-top: 0;
    color: $primary-color;
  }
  
  &-links {
    display: flex;
    justify-content: space-between;
    margin-bottom: 1rem;
    
    a {
      flex: 1;
      padding: 0.5rem 1rem;
      background: white;
      border: 1px solid #ddd;
      border-radius: 3px;
      text-decoration: none;
      color: $primary-color;
      
      &:hover {
        background: #f5f5f5;
      }
      
      &.series-next {
        text-align: right;
        margin-left: 1rem;
      }
      
      &.series-prev {
        margin-right: 1rem;
      }
    }
  }
  
  .series-list {
    ol {
      padding-left: 1.5rem;
      
      li {
        margin: 0.5rem 0;
        
        &.current {
          font-weight: bold;
          color: $primary-color;
        }
      }
    }
  }
}

πŸ” Search Implementation

JSON Index Generation

layouts/index.json:

{{- $pages := .Site.RegularPages -}}
[
  {{- range $index, $page := $pages -}}
    {{- if $index }},{{ end }}
    {
      "title": {{ $page.Title | jsonify }},
      "url": {{ $page.Permalink | jsonify }},
      "content": {{ $page.Plain | jsonify }},
      "summary": {{ $page.Summary | jsonify }},
      "date": {{ $page.Date.Format "2006-01-02" | jsonify }},
      "categories": {{ $page.Params.categories | jsonify }},
      "tags": {{ $page.Params.tags | jsonify }},
      "section": {{ $page.Section | jsonify }}
    }
  {{- end -}}
]

Client-side search with Lunr.js:

themes/learnhub-theme/layouts/partials/search.html:

<div id="search-container">
  <input type="text" id="search-input" placeholder="Search articles...">
  <div id="search-results"></div>
</div>

<script src="https://unpkg.com/lunr/lunr.js"></script>
<script>
  fetch('/index.json')
    .then(response => response.json())
    .then(data => {
      const idx = lunr(function () {
        this.ref('url')
        this.field('title', { boost: 10 })
        this.field('content')
        this.field('tags', { boost: 5 })
        this.field('categories', { boost: 5 })
        
        data.forEach(doc => {
          this.add(doc)
        })
      })
      
      const searchInput = document.getElementById('search-input')
      const searchResults = document.getElementById('search-results')
      
      searchInput.addEventListener('input', function(e) {
        const query = e.target.value
        if (query.length < 2) {
          searchResults.innerHTML = ''
          return
        }
        
        const results = idx.search(query)
        const matches = results.map(result => {
          return data.find(page => page.url === result.ref)
        })
        
        searchResults.innerHTML = matches.map(match => `
          <div class="search-result">
            <a href="${match.url}">
              <strong>${match.title}</strong>
              <p>${match.summary}</p>
              <small>${match.section} | ${match.date}</small>
            </a>
          </div>
        `).join('')
      })
    })
</script>

πŸš€ Deployment to GitHub Pages

GitHub Actions Workflow

.github/workflows/hugo.yml:

name: Deploy Hugo Site to GitHub Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0
      
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: '0.122.0'
          extended: true
      
      - name: Build Hugo Site
        run: |
          cd hugo-site
          hugo --minify --gc
      
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./docs

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

Local Development Workflow

# Navigate to Hugo site
cd hugo-site

# Start development server
hugo server -D

# Visit: http://localhost:1313
# Changes auto-reload instantly (10-100ms)

# Build for production
hugo --minify --gc

# Output in docs/ directory
# Ready for GitHub Pages

πŸ’‘ Content Migration Strategy

Step-by-Step Migration

Phase 1: Setup (Week 1)

  1. βœ… Install Hugo extended
  2. βœ… Create hugo-site directory
  3. βœ… Configure config.toml
  4. βœ… Create custom theme structure
  5. βœ… Test with sample content

Phase 2: Template Development (Week 2)

  1. βœ… Build baseof.html (base template)
  2. βœ… Create single.html (article template)
  3. βœ… Develop partials (header, footer, TOC)
  4. βœ… Implement series navigation
  5. βœ… Port cerulean.scss styling

Phase 3: Content Migration (Week 3-4)

  1. Copy one section (e.g., β€œ03.00-tech/20.01-markdown/”)
  2. Update front matter if needed
  3. Test rendering
  4. Fix any issues
  5. Repeat for other sections

Phase 4: Features (Week 5)

  1. Implement search (JSON + Lunr.js)
  2. Add related articles
  3. Configure taxonomies
  4. Optimize images

Phase 5: Deployment (Week 6)

  1. Update GitHub Actions
  2. Test deployment
  3. Verify GitHub Pages
  4. Performance testing

Automated Content Copy Script

scripts/migrate-to-hugo.ps1:

# Migrate content from Quarto to Hugo structure

$sourceRoot = "."
$hugoContent = "hugo-site\content"

# Define section mappings
$sections = @{
    "01.00-news"    = "news"
    "02.00-events"  = "events"
    "03.00-tech"    = "tech"
    "04.00-howto"   = "howto"
    "05.00-issues"  = "issues"
    "06.00-idea"    = "ideas"
    "90.00-travel"  = "travel"
}

foreach ($source in $sections.Keys) {
    $dest = $sections[$source]
    $sourcePath = Join-Path $sourceRoot $source
    $destPath = Join-Path $hugoContent $dest
    
    Write-Host "Copying $source -> $dest"
    
    if (Test-Path $sourcePath) {
        # Create destination
        New-Item -ItemType Directory -Force -Path $destPath | Out-Null
        
        # Copy markdown files
        Get-ChildItem -Path $sourcePath -Recurse -Include *.md | ForEach-Object {
            $relativePath = $_.FullName.Substring($sourcePath.Length + 1)
            $targetPath = Join-Path $destPath $relativePath
            $targetDir = Split-Path $targetPath -Parent
            
            New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
            Copy-Item $_.FullName $targetPath -Force
            
            Write-Host "  Copied: $relativePath"
        }
        
        # Copy images
        Get-ChildItem -Path $sourcePath -Recurse -Include *.png,*.jpg,*.jpeg,*.gif,*.svg | ForEach-Object {
            $relativePath = $_.FullName.Substring($sourcePath.Length + 1)
            $targetPath = Join-Path $destPath $relativePath
            $targetDir = Split-Path $targetPath -Parent
            
            New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
            Copy-Item $_.FullName $targetPath -Force
        }
    }
}

Write-Host "Migration complete!"

🎯 Key Takeaways

  1. Hugo can render Learning Hub with similar structure to Quarto
  2. Build time improvement: 90-180s β†’ 5-10s (18x faster)
  3. Dual YAML metadata preserved - HTML comments ignored by Hugo
  4. Series navigation implemented with custom partials
  5. Search functionality via JSON index + Lunr.js
  6. GitHub Pages compatible - Output to docs/ directory

πŸ”œ Next Steps

Explore more advanced topics (coming soon in this series):

  • Performance Optimization - Maximize build speed
  • Theme Customization - Advanced theming
  • Deployment Options - Various hosting platforms

πŸ“š References