17.4 Reading Time and Word Count
Right, so you want to show off how long your magnum opus on artisanal cheese is, or maybe you just want to guilt-trip your readers into actually finishing that 5000-word blog post. Either way, you’re going to want to calculate reading time and word count. It’s one of those features that seems trivial until you actually think about it, and then you realize there are a few delightful little traps waiting for you.
The core concept is embarrassingly simple: you get the number of words and divide it by an average words-per-minute reading speed. The internet’s default WPM is roughly 225, a number pulled from a study you and I have never read, but it’s become the standard. It’s fine. We’ll roll with it.
First, you need the content. And this is Pitfall Number One: where are you getting it from? If you’re in a template, you probably have access to the page’s content in a variable like .Content. But .Content is the rendered HTML from your Markdown. This is crucial.
The Great Word Count Heist
Let’s say you try to be clever and do this:
{{ .Content | countwords }}
Seems logical, right? Go’s template countwords function will indeed give you a number. But it’s a lie. That function counts words in a string, but .Content is full of HTML tags. So your beautiful paragraph <p>Here is a short post.</p> isn’t 4 words; it’s more like 5 (“p”, “Here”, “is”, “a”, “short”, “post”, “p” – though the actual parsing is smarter, it’s still polluted). You’re counting the scaffolding along with the furniture.
The correct source is almost always the raw, unrendered Markdown, which, thankfully, Hugo stores for you in .Plain. So step one is to always, always use .Plain.
{{ .Plain | countwords }}
Now you’re counting actual words. Let’s use that to build our calculations.
Implementing the Basic Calculation
Here’s how you’d typically do it in a Hugo template. We’ll calculate the words, then the reading time, and make sure we always round up because no one says “oh, that’ll take you 4.3 minutes.”
{{ $wordCount := .Plain | countwords }}
{{ $readingTime := div $wordCount 225.0 }}
{{ $readingTime = math.Ceil $readingTime }}
<p>This post is {{ $wordCount }} words long, and will take about {{ $readingTime }} minutes to read.</p>
Wait, hold on. Did you spot the potential disaster? div $wordCount 225.0. We’re using 225.0 to force floating-point division. If you use an integer (225), div performs integer division, which means 449 / 225 would be 1 instead of ~2.0. It’s an easy mistake that makes every short post seem like it takes 1 minute. Always use a float for the divisor.
A more robust version, tucked into a if statement to avoid a divide-by-zero panic on empty pages, looks like this:
{{ $wordCount := .Plain | countwords }}
{{ if gt $wordCount 0 }}
{{ $readingTime := div (float $wordCount) 225.0 }}
{{ $readingTime = math.Ceil $readingTime }}
<p>📖 {{ $readingTime }} min read ({{ $wordCount }} words)</p>
{{ end }}
Customizing the Reading Speed (Because You’re Not Average)
Maybe your audience is full of speed readers. Or maybe you write about quantum mechanics and your readers need a bit more time. Hard-coding 225 is, frankly, a bit authoritarian. Let’s fix that.
The best place for this is in your site’s configuration (hugo.yaml or config.toml). Define a parameter.
params:
readingSpeed: 200
Then, in your template, you use site-wide parameters. This is much cleaner.
{{ $words := .Plain | countwords }}
{{ $speed := .Site.Params.readingSpeed | default 225 }}
{{ if gt $words 0 }}
{{ $readingTime := div (float $words) (float $speed) }}
{{ $readingTime = math.Ceil $readingTime }}
<span>{{ $readingTime }} min read ({{ $words }} words)</span>
{{ end }}
Notice how we’re now casting both the word count and the speed to floats. This is defensive coding. It ensures that even if someone puts readingSpeed: 200 (an integer) in their config, the division will still be floating-point. We also use the default function as a fallback in case the parameter isn’t set.
The Edge Case You Didn’t Know You Needed
Here’s a fun one. What about a page with no .Plain content? Like a page that only uses a custom layout and data files? Or what if you put this code in a list template? .Plain on a list page will be empty.
Your template will output nothing because of our if gt $wordCount 0 guard. This is correct behavior. A list of pages shouldn’t have a reading time. But it’s worth remembering that this code is intended for use on single page templates (single.html) and not others. Context matters.
So there you have it. It seems simple, but the difference between .Content and .Plain is the difference between looking like you know what you’re doing and actually knowing what you’re doing. And always, always use floats for your division. Your readers might not notice, but you’ll know you did it right.