Skip to content

Commit f3dada9

Browse files
authored
Merge pull request #371 from davep/copilot/add-read-time-wpm-configuration
2 parents 843d140 + f7dfe65 commit f3dada9

9 files changed

Lines changed: 146 additions & 3 deletions

File tree

ChangeLog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
- Added a table of longest posting streaks to the stats page, showing the
88
top 10 consecutive-day posting streaks of two or more days.
99
([#369](https://github.com/davep/blogmore/pull/369))
10+
- Added a `read_time_wpm` configuration option that lets users override the
11+
words-per-minute value used when calculating estimated reading time.
12+
([#371](https://github.com/davep/blogmore/pull/371))
1013

1114
## v2.10.0
1215

blogmore.yaml.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,13 @@ posts_per_feed: 20
6666
# with_stats: false
6767

6868
# Optional: Show estimated reading time on posts (default: false)
69-
# Displays the approximate time to read each post based on 200 words per minute.
69+
# Displays the approximate time to read each post based on the configured WPM.
7070
# with_read_time: false
7171

72+
# Optional: Words per minute used when calculating estimated reading time (default: 200)
73+
# Must be a positive integer. Configuration file only.
74+
# read_time_wpm: 200
75+
7276
# Optional: Show "Generated with BlogMore" footer line (default: true)
7377
# Set to false to hide the footer attribution line.
7478
# with_advert: true

docs/configuration.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ with_stats: true
320320

321321
#### `with_read_time`
322322

323-
Show estimated reading time on each post. When enabled, BlogMore calculates the approximate time to read each post (based on 200 words per minute) and displays it next to the post date on all post listings and individual post pages.
323+
Show estimated reading time on each post. When enabled, BlogMore calculates the approximate time to read each post (based on the configured words-per-minute rate) and displays it next to the post date on all post listings and individual post pages.
324324

325325
**Type:** Boolean
326326
**Default:** `false`
@@ -329,6 +329,17 @@ Show estimated reading time on each post. When enabled, BlogMore calculates the
329329
with_read_time: true
330330
```
331331

332+
#### `read_time_wpm`
333+
334+
Words per minute used when calculating estimated reading time. Adjust this value to match the expected reading speed of your audience. Must be a positive integer. This is a **configuration file only** option — it cannot be set on the command line.
335+
336+
**Type:** Integer
337+
**Default:** `200`
338+
339+
```yaml
340+
read_time_wpm: 250
341+
```
342+
332343
#### `post_path`
333344

334345
Format string that controls the output path (and therefore the URL) of every blog post. This is a **configuration file only** option — it cannot be set on the command line.

src/blogmore/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"head",
6565
"light_mode_code_style",
6666
"dark_mode_code_style",
67+
"read_time_wpm",
6768
}
6869
)
6970

@@ -528,4 +529,24 @@ def parse_site_config_from_dict(
528529
else:
529530
kwargs["dark_mode_code_style"] = raw_dark_style
530531

532+
# --- read_time_wpm -------------------------------------------------------
533+
if "read_time_wpm" in config:
534+
raw_wpm = config["read_time_wpm"]
535+
if not isinstance(raw_wpm, int) or isinstance(raw_wpm, bool):
536+
errors.append(
537+
"read_time_wpm in the configuration file must be an integer; "
538+
"using the default"
539+
)
540+
kwargs["read_time_wpm"] = 200
541+
elif raw_wpm <= 0:
542+
errors.append(
543+
"read_time_wpm in the configuration file must be a positive "
544+
"integer; using the default"
545+
)
546+
kwargs["read_time_wpm"] = 200
547+
else:
548+
kwargs["read_time_wpm"] = raw_wpm
549+
else:
550+
kwargs["read_time_wpm"] = 200
551+
531552
return kwargs, errors

src/blogmore/generator.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,11 @@ def generate(self) -> None:
569569
)
570570
print(f"Found {len(posts)} posts")
571571

572+
# Apply configured reading-speed to every post so that reading_time
573+
# reflects the user's read_time_wpm setting.
574+
for post in posts:
575+
post.words_per_minute = self.site_config.read_time_wpm
576+
572577
# Apply default author to posts that don't have one
573578
if self.site_config.default_author:
574579
for post in posts:

src/blogmore/parser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ class Post:
134134
draft: bool = False
135135
metadata: dict[str, Any] | None = None
136136
url_path: str | None = field(default=None, repr=False, compare=False)
137+
words_per_minute: int = field(default=200, repr=False, compare=False)
137138

138139
@property
139140
def slug(self) -> str:
@@ -206,7 +207,7 @@ def reading_time(self) -> int:
206207
Returns:
207208
Estimated reading time in minutes (minimum 1 minute)
208209
"""
209-
return calculate_reading_time(self.content)
210+
return calculate_reading_time(self.content, self.words_per_minute)
210211

211212
@property
212213
def modified_date(self) -> dt.datetime | None:

src/blogmore/site_config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,17 @@ class SiteConfig:
117117
with_read_time: bool = False
118118
"""Whether to show estimated reading time on posts."""
119119

120+
read_time_wpm: int = 200
121+
"""Words per minute used when calculating estimated reading time.
122+
123+
Controls the reading speed assumption used by the reading-time estimator.
124+
Must be a positive integer. The default value of 200 WPM reflects a
125+
widely-cited average adult reading speed.
126+
127+
This is a **configuration file only** option — it cannot be set on the
128+
command line. Defaults to ``200``.
129+
"""
130+
120131
include_drafts: bool = False
121132
"""Whether to include draft posts in generation."""
122133

tests/test_config.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,3 +1419,65 @@ def test_extra_stylesheets_cli_override_wins_over_config(
14191419

14201420
assert errors == []
14211421
assert kwargs["extra_stylesheets"] == ["cli.css"]
1422+
1423+
def test_read_time_wpm_valid_value(self, tmp_path: Path) -> None:
1424+
"""A valid positive integer read_time_wpm is accepted."""
1425+
from blogmore.config import parse_site_config_from_dict
1426+
1427+
kwargs, errors = parse_site_config_from_dict({"read_time_wpm": 250}, tmp_path)
1428+
1429+
assert errors == []
1430+
assert kwargs["read_time_wpm"] == 250
1431+
1432+
def test_read_time_wpm_absent_resets_to_default(self, tmp_path: Path) -> None:
1433+
"""When read_time_wpm is absent from config, it resets to the default (200)."""
1434+
from blogmore.config import parse_site_config_from_dict
1435+
1436+
kwargs, errors = parse_site_config_from_dict({}, tmp_path)
1437+
1438+
assert errors == []
1439+
assert kwargs["read_time_wpm"] == 200
1440+
1441+
def test_read_time_wpm_zero_produces_error(self, tmp_path: Path) -> None:
1442+
"""A read_time_wpm of zero produces an error and uses the default."""
1443+
from blogmore.config import parse_site_config_from_dict
1444+
1445+
kwargs, errors = parse_site_config_from_dict({"read_time_wpm": 0}, tmp_path)
1446+
1447+
assert len(errors) == 1
1448+
assert "read_time_wpm" in errors[0]
1449+
assert kwargs["read_time_wpm"] == 200
1450+
1451+
def test_read_time_wpm_negative_produces_error(self, tmp_path: Path) -> None:
1452+
"""A negative read_time_wpm produces an error and uses the default."""
1453+
from blogmore.config import parse_site_config_from_dict
1454+
1455+
kwargs, errors = parse_site_config_from_dict({"read_time_wpm": -50}, tmp_path)
1456+
1457+
assert len(errors) == 1
1458+
assert "read_time_wpm" in errors[0]
1459+
assert kwargs["read_time_wpm"] == 200
1460+
1461+
def test_read_time_wpm_non_integer_produces_error(self, tmp_path: Path) -> None:
1462+
"""A non-integer read_time_wpm produces an error and uses the default."""
1463+
from blogmore.config import parse_site_config_from_dict
1464+
1465+
kwargs, errors = parse_site_config_from_dict(
1466+
{"read_time_wpm": "fast"}, tmp_path
1467+
)
1468+
1469+
assert len(errors) == 1
1470+
assert "read_time_wpm" in errors[0]
1471+
assert kwargs["read_time_wpm"] == 200
1472+
1473+
def test_read_time_wpm_bool_produces_error(self, tmp_path: Path) -> None:
1474+
"""A boolean read_time_wpm produces an error (booleans are subclasses of int)."""
1475+
from blogmore.config import parse_site_config_from_dict
1476+
1477+
kwargs, errors = parse_site_config_from_dict(
1478+
{"read_time_wpm": True}, tmp_path
1479+
)
1480+
1481+
assert len(errors) == 1
1482+
assert "read_time_wpm" in errors[0]
1483+
assert kwargs["read_time_wpm"] == 200

tests/test_parser.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,31 @@ def hello():
437437
# Reading time should be calculated from the actual text content
438438
assert post.reading_time >= 1
439439

440+
def test_reading_time_uses_words_per_minute_field(self) -> None:
441+
"""Test that reading_time uses the words_per_minute field."""
442+
# 200 words at 100 WPM = 2 minutes; at 200 WPM (default) = 1 minute
443+
content = " ".join(["word"] * 200)
444+
post = Post(
445+
path=Path("test.md"),
446+
title="Test",
447+
content=content,
448+
html_content=f"<p>{content}</p>",
449+
words_per_minute=100,
450+
)
451+
assert post.reading_time == 2
452+
453+
def test_reading_time_default_words_per_minute(self) -> None:
454+
"""Test that reading_time defaults to 200 WPM."""
455+
# 200 words at 200 WPM (default) = 1 minute
456+
content = " ".join(["word"] * 200)
457+
post = Post(
458+
path=Path("test.md"),
459+
title="Test",
460+
content=content,
461+
html_content=f"<p>{content}</p>",
462+
)
463+
assert post.reading_time == 1
464+
440465
def test_modified_date_none_when_no_metadata(self) -> None:
441466
"""Test that modified_date returns None when metadata is None."""
442467
post = Post(

0 commit comments

Comments
 (0)