Skip to content

✨ Show a reading time next to each post date - #227

Merged
ker0x merged 1 commit into
mainfrom
feat/issue-000-blog-reading-time
Aug 21, 2026
Merged

✨ Show a reading time next to each post date#227
ker0x merged 1 commit into
mainfrom
feat/issue-000-blog-reading-time

Conversation

@ker0x

@ker0x ker0x commented Aug 21, 2026

Copy link
Copy Markdown
Member

Blog posts advertised only a publication date, so readers had no signal about how long a post is before committing to it. This adds a "6 min read" indicator next to the date, on the single-post header and on every post card (/blog, the category and tag archives, and the six-latest section on the homepage — they all share templates/component/Blog/PostCard.html.twig).

Where the number comes from

BlogPostRepository::parse() already rendered each post's markdown in full when building the index — it read ParsedMarkdown->frontmatter and threw ->html away. It now hands that HTML to the factory, so the word count costs nothing extra and rides the existing index cache. No second pass over the filesystem, no new cache entry, no reading_time front matter field to keep in sync.

ReadingTimeCalculator (src/Blog/Domain/Service/) strips tags, decodes entities, splits on /\s+/u, then 200 wpm rounded up with a floor of one minute. preg_split rather than str_word_count — the latter is locale dependent and mangles the accented French catalogue.   is decoded before splitting, otherwise it glues its neighbours into a single word.

Code blocks are counted like prose. Reading a listing is not faster than reading a paragraph, and special casing <pre> adds a branch for no clear win — but it is a one-line change in the calculator if you disagree.

The cache key bump

index() gains a version segment: blog.index.v2.{locale}.{fingerprint}.

blog.cache is Redis backed and survives deploys, and the key was derived only from content mtimes. Without the bump, a deploy that left content/blog/ untouched would have returned entries serialized before BlogPost gained $readingTime, unserialized into the new shape with the typed property uninitialized — a fatal on every read of /blog. The constant carries a comment saying it needs bumping whenever BlogPost changes shape.

Also in the payload

timeRequired joins the BlogPosting JSON-LD as an ISO 8601 duration (PT2M), next to datePublished.

Verification

51 unit / 90 integration / 26 functional tests green, PHPStan level 8 clean, PHP CS Fixer and Twig CS Fixer clean, Twig/YAML/Doctrine linters clean.

Checked against the one real post in content/blog/: /blog/why-this-blog-has-no-database renders 2 min read, /fr/blog/pourquoi-ce-blog-na-pas-de-base-de-donnees renders 2 min de lecture, both with "timeRequired":"PT2M" in the JSON-LD.

The test fixtures are one or two sentences each, so they all land on the one-minute floor — the arithmetic itself is pinned in ReadingTimeCalculatorTest, where the input is controlled.

Posts advertised only a publication date, so readers had no signal about
length before committing to one.

The reading time is derived from the rendered body rather than declared in
front matter, so there is nothing to keep in sync. BlogPostRepository::parse()
already rendered each post in full to read its front matter and threw the HTML
away; it now passes that HTML to the factory, so the word count is free and
rides the existing index cache.

The index cache key gains a version segment: the pool is Redis backed and
survives deploys, and the key was derived only from content mtimes, so a deploy
that left content/blog/ untouched would have returned BlogPost objects
unserialized into the new shape with $readingTime uninitialized.
@ker0x
ker0x merged commit 237d217 into main Aug 21, 2026
1 check passed
@ker0x
ker0x deleted the feat/issue-000-blog-reading-time branch August 21, 2026 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant