Skip to content

Commit f00a901

Browse files
author
csgoat0
committed
Adding reading time estimation to Article
1 parent 7b40e81 commit f00a901

File tree

7 files changed

+299
-1
lines changed

7 files changed

+299
-1
lines changed

administrator/cache/index.html

Lines changed: 0 additions & 1 deletion
This file was deleted.

origin

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
; Joomla! Project
2+
; Copyright (C) 2025 Your Name. All rights reserved.
3+
; License: GNU/GPL v2 or later
4+
5+
; Plugin metadata (shown in Extensions Manager)
6+
PLG_CONTENT_TIMEESTIMATION="Content - Time Estimation"
7+
PLG_CONTENT_TIMEESTIMATION_DESCRIPTION="Displays an estimated reading time before each article, based on a configurable words-per-minute reading speed."
8+
9+
; Admin param labels
10+
PLG_CONTENT_TIMEESTIMATION_WPM_LABEL="Words Per Minute"
11+
PLG_CONTENT_TIMEESTIMATION_WPM_DESC="Average reading speed in words per minute. Typical adult: 200-250 wpm. Lower for technical content."
12+
13+
; Front-end badge strings
14+
PLG_CONTENT_TIMEESTIMATION_MINUTES="%d min read"
15+
PLG_CONTENT_TIMEESTIMATION_WORD_COUNT="%d words"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
; Joomla! Project
2+
; Copyright (C) 2026 Joomla! Project. All rights reserved.
3+
; License: GNU/GPL v2 or later
4+
5+
; Plugin metadata (shown in Extensions Manager)
6+
PLG_CONTENT_TIMEESTIMATION="Content - Time Estimation"
7+
PLG_CONTENT_TIMEESTIMATION_DESCRIPTION="Displays an estimated reading time before each article, based on a configurable words-per-minute reading speed."
8+
9+
; Admin param labels
10+
PLG_CONTENT_TIMEESTIMATION_WPM_LABEL="Words Per Minute"
11+
PLG_CONTENT_TIMEESTIMATION_WPM_DESC="Average reading speed in words per minute. Typical adult: 200-250 wpm. Lower for technical content."
12+
13+
; Front-end badge strings
14+
PLG_CONTENT_TIMEESTIMATION_MINUTES="%d min read"
15+
PLG_CONTENT_TIMEESTIMATION_WORD_COUNT="%d words"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/**
4+
* @package Joomla.Plugin
5+
* @subpackage Content.Timeestimation
6+
*
7+
* @copyright (C) 2023 Open Source Matters, Inc. <https://www.joomla.org>
8+
* @license GNU General Public License version 2 or later; see LICENSE.txt
9+
*/
10+
11+
// Prevent direct access
12+
defined('_JEXEC') or die;
13+
14+
use Joomla\CMS\Extension\PluginInterface;
15+
use Joomla\CMS\Plugin\PluginHelper;
16+
use Joomla\DI\Container;
17+
use Joomla\DI\ServiceProviderInterface;
18+
use Joomla\Event\DispatcherInterface;
19+
use Joomla\CMS\Factory;
20+
use Joomla\Plugin\Content\Timeestimation\Extension\Timeestimation;
21+
22+
23+
return new class() implements ServiceProviderInterface
24+
{
25+
public function register(Container $container)
26+
{
27+
$container->set(
28+
PluginInterface::class,
29+
function (Container $container) {
30+
$config = (array) PluginHelper::getPlugin('content', 'timeestimation');
31+
$subject = $container->get(DispatcherInterface::class);
32+
$app = Factory::getApplication();
33+
34+
$plugin = new Timeestimation($subject, $config);
35+
$plugin->setApplication($app);
36+
37+
return $plugin;
38+
}
39+
);
40+
}
41+
};
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/**
4+
* @package Joomla.Plugin
5+
* @subpackage Content.Timeestimation
6+
*
7+
* @copyright (C) 2026 Joomla! Project. All rights reserved.
8+
* @license GNU General Public License version 2 or later; see LICENSE.txt
9+
*/
10+
11+
namespace Joomla\Plugin\Content\Timeestimation\Extension;
12+
13+
use Joomla\CMS\Event\Content\AfterTitleEvent;
14+
use Joomla\CMS\Event\Content\ContentPrepareEvent;
15+
use Joomla\CMS\Plugin\CMSPlugin;
16+
use Joomla\Event\SubscriberInterface;
17+
use Joomla\CMS\Event\Model\PrepareFormEvent;
18+
use Joomla\Event\Event;
19+
20+
// phpcs:disable PSR1.Files.SideEffects
21+
\defined('_JEXEC') or die;
22+
// phpcs:enable PSR1.Files.SideEffects
23+
24+
/**
25+
* Time Estimation Plugin
26+
*
27+
* Uses TWO events:
28+
*
29+
* 1. onContentPrepare – runs first, counts words while $article->text is
30+
* still raw HTML, stores the result on the article object.
31+
*
32+
* 2. onContentAfterTitle – runs later, reads the pre-computed values and returns
33+
* the badge HTML as the event result. Joomla collects
34+
* that string into $this->item->event->afterDisplayTitle,
35+
* which default.php echoes directly below the title:
36+
*
37+
* <?php echo $this->item->event->afterDisplayTitle; ?>
38+
*
39+
*/
40+
class Timeestimation extends CMSPlugin implements SubscriberInterface
41+
{
42+
/**
43+
* Auto-load the plugin language file.
44+
*
45+
* @var boolean
46+
*/
47+
48+
49+
/**
50+
* Fallback WPM when the admin param is absent or invalid.
51+
*
52+
* @var integer
53+
*/
54+
private const DEFAULT_WPM = 50;
55+
56+
/**
57+
* Map Joomla events → listener methods.
58+
*
59+
* @return array
60+
*/
61+
public static function getSubscribedEvents(): array
62+
{
63+
return [
64+
// Step 1: count words while the raw text is available.
65+
'onContentPrepare' => 'prepare',
66+
67+
// Step 2: return the badge into the afterDisplayTitle template slot.
68+
'onContentAfterTitle' => 'displayBadge',
69+
];
70+
}
71+
72+
// -------------------------------------------------------------------------
73+
// Step 1 — onContentPrepare
74+
// -------------------------------------------------------------------------
75+
76+
/**
77+
* Count words and store reading-time data on the article object.
78+
*
79+
* We do the heavy work here because $article->text is fully available
80+
* during onContentPrepare. By the time onContentAfterTitle fires, Joomla
81+
* may have already processed the text further.
82+
*
83+
* @param ContentPrepareEvent $event
84+
*
85+
* @return void
86+
*/
87+
public function prepare(ContentPrepareEvent $event): void
88+
{
89+
$context = $event->getContext();
90+
$article = $event->getItem();
91+
92+
if (!\in_array($context, ['com_content.article', 'com_content.category', 'com_content.featured'])) {
93+
return;
94+
}
95+
96+
if (empty($article->text)) {
97+
return;
98+
}
99+
100+
$wpm = (int) $this->params->get('words_per_minute', self::DEFAULT_WPM);
101+
102+
if ($wpm <= 0) {
103+
$wpm = self::DEFAULT_WPM;
104+
}
105+
106+
// Store on the article so displayBadge() can read it without re-counting.
107+
$article->readingTimeWords = $this->countWords($article->text);
108+
$article->readingTimeMinutes = max(1, (int) ceil($article->readingTimeWords / $wpm));
109+
}
110+
111+
// -------------------------------------------------------------------------
112+
// Step 2 — onContentAfterTitle
113+
// -------------------------------------------------------------------------
114+
115+
/**
116+
* Return the badge HTML into the afterDisplayTitle template slot.
117+
*
118+
* Joomla accumulates every plugin's return value for this event and stores
119+
* the combined string in $this->item->event->afterDisplayTitle.
120+
* default.php then echoes it with:
121+
*
122+
* <?php echo $this->item->event->afterDisplayTitle; ?>
123+
*
124+
*
125+
* @param AfterTitleEvent $event
126+
*
127+
* @return void
128+
*/
129+
public function displayBadge(AfterTitleEvent $event): void
130+
{
131+
$context = $event->getContext();
132+
$article = $event->getItem();
133+
134+
// Only show on single article view — not in category/blog list cards.
135+
if ($context !== 'com_content.article') {
136+
return;
137+
}
138+
139+
// Safety check: prepare() may have been skipped (e.g. empty article).
140+
if (!isset($article->readingTimeMinutes)) {
141+
return;
142+
}
143+
144+
// Return the badge into the event result.
145+
// Joomla reads this and writes it into $article->event->afterDisplayTitle.
146+
$event->addResult($this->buildBadge($article->readingTimeMinutes, $article->readingTimeWords));
147+
}
148+
149+
// -------------------------------------------------------------------------
150+
// Helpers
151+
// -------------------------------------------------------------------------
152+
153+
/**
154+
* Count visible words in an HTML string.
155+
*
156+
* @param string $html
157+
*
158+
* @return integer
159+
*/
160+
private function countWords(string $html): int
161+
{
162+
$text = strip_tags($html);
163+
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
164+
$text = preg_replace('/\s+/', ' ', trim($text));
165+
166+
return $text === '' ? 0 : str_word_count($text);
167+
}
168+
169+
/**
170+
* Build the reading-time HTML badge.
171+
*
172+
* @param integer $minutes
173+
* @param integer $wordCount
174+
*
175+
* @return string
176+
*/
177+
private function buildBadge(int $minutes, int $wordCount): string
178+
{
179+
$minLabel = $minutes === 1 ? 'min read' : 'mins read';
180+
181+
return '<div class="reading-time-badge">'
182+
. '<span>&#128337; <strong>' . $minutes . '</strong> ' . $minLabel . '</span>'
183+
. '<span> (' . $wordCount . ' words)</span>'
184+
. '</div>';
185+
}
186+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<extension type="plugin" group="content" method="upgrade">
3+
4+
<name>PLG_CONTENT_TIMEESTIMATION</name>
5+
<author>Joomla! Project</author>
6+
<creationDate>2026-3</creationDate>
7+
<copyright>(C) 2026 Open Source Matters, Inc.</copyright>
8+
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
9+
<authorEmail>admin@joomla.org</authorEmail>
10+
<authorUrl>www.joomla.org</authorUrl>
11+
<version>1.0.0</version>
12+
<description>PLG_CONTENT_TIMEESTIMATION_DESCRIPTION</description>
13+
<namespace path="src">Joomla\Plugin\Content\Timeestimation</namespace>
14+
15+
<files>
16+
<folder plugin="timeestimation">services</folder>
17+
<folder>src</folder>
18+
<folder>language</folder>
19+
</files>
20+
21+
<languages>
22+
<language tag="en-GB">language/en-GB/plg_content_timeestimation.ini</language>
23+
<language tag="en-GB">language/en-GB/plg_content_timeestimation.sys.ini</language>
24+
</languages>
25+
26+
<config>
27+
<fields name="params">
28+
<fieldset name="basic">
29+
<field
30+
name="words_per_minute"
31+
type="text"
32+
label="PLG_CONTENT_TIMEESTIMATION_WPM_LABEL"
33+
description="PLG_CONTENT_TIMEESTIMATION_WPM_DESC"
34+
default="200"
35+
filter="integer"
36+
/>
37+
38+
</fieldset>
39+
</fields>
40+
</config>
41+
42+
</extension>

0 commit comments

Comments
 (0)