Alright, let’s get our hands dirty with the filesystem. This is where Hugo stops being just a static site generator and starts feeling like a proper programming language. The readFile, readDir, and fileExists functions are your direct line to the raw content of your project. They are incredibly powerful, but with great power comes the great responsibility of not accidentally shipping your TODO.md file to production.

The Workhorses: readFile and readDir

Think of these as your cat and ls commands, but baked right into your templates. Their primary job is to read files from your project’s root, not from the built site’s public directory. This is a crucial distinction. You’re working with your source material.

readFile slurps the entire content of a file into a string. It’s delightfully straightforward.

{{/* Grabs the raw content of a file */}}
{{ $readme := readFile "README.md" }}
{{/* Now you can manipulate that string */}}
{{ $readme | markdownify }}

readDir is its directory-listing cousin. It returns a slice of FileInfo objects, each telling you everything you’d want to know about a file or directory within the given path.

{{/* List all files in the assets directory */}}
{{ $files := readDir "assets" }}
<ul>
{{ range $files }}
  <li>{{ .Name }} (Size: {{ .Size }} bytes)</li>
{{ end }}
</ul>

Each file object from readDir gives you .Name, .Size (in bytes), .Mode, .IsDir, and .ModTime. It’s everything you need to build a custom file browser or a portfolio page that automatically lists the latest files in a folder.

The Bouncer: fileExists

This one is the gatekeeper. It’s a boolean function—true or false—that checks if a path exists in your project directory. You must use this before calling readFile on a path you’re not 100% sure about. Why? Because if readFile can’t find the file, it will throw a big, fat error and stop your build process dead in its tracks. It’s not forgiving.

The right way to do it is to use fileExists as a guard clause.

{{/* Don't be this person: {{ readFile "maybe-real.txt" }} */}}

{{/* Be this person: */}}
{{ if fileExists "content/extra-script.js" }}
  {{ $script := readFile "content/extra-script.js" }}
  <script>{{ $script | safeJS }}</script>
{{ else }}
  {{ warnf "The file 'content/extra-script.js' is missing. Did you forget to include it?" }}
{{ end }}

See what I did there? I also used warnf to log a helpful message to the console during build. This is a pro move. It turns a silent failure into a clear, actionable message without breaking the entire site build.

The Absolute Path Gotcha

Here’s the first thing that trips everyone up, and it’s a doozy: the path is always relative to your project’s root directory. You cannot use them to read files from outside your project. This is a security feature, thankfully, but it means you can’t do ../../etc/passwd (which is good) but you also can’t easily read a file from a totally different project (which is sometimes annoying).

The path is also absolute from that root. If your file is at assets/pdf/guide.pdf, you must reference it as assets/pdf/guide.pdf, not just guide.pdf.

Why You Can’t Use These in hugo.yaml

This is a common point of confusion. You cannot use these functions in your hugo.yaml configuration file. Why? Because the config file is parsed before the rest of your site is built. These functions are part of the template runtime—they are tools available to you during the process of building pages. The config file is just setting the stage; it’s not part of the play itself.

A Real-World Example: Building a CSS Bundle

Let’s say you’re a purist and you want to break your CSS into modules but ship one file. You can use readDir and readFile to automate this.

{{/* Grab all CSS files from a specific directory */}}
{{ $cssSlice := slice }}
{{ $cssFiles := readDir "assets/css" }}
{{ range where $cssFiles "Name" "*.css" }}
  {{ $cssSlice = $cssSlice | append (readFile (printf "assets/css/%s" .Name)) }}
{{ end }}

{{/* Now combine all CSS into one string and minify it */}}
{{ $combinedCSS := $cssSlice | delimit "\n" | minify }}
<style>{{ $combinedCSS | safeCSS }}</style>

This code reads every .css file in assets/css, combines them into one big string, minifies it, and spits it out in a <style> tag. It’s elegant, powerful, and automatically includes any new CSS files you add to the directory. That’s the kind of automation that makes you feel like a wizard. Just remember, for large sites, piping through Hugo’s Pipes (resources.Concat) is often more efficient, but this method is perfect for quick, simple projects.