Compare commits

..
16 Commits
Author SHA1 Message Date
Kevin DecherfandGitHub a8b08d8cb2 Merge pull request #114 from Kdecherf/fix/array-filter
Fix "Undefined array key 0" error
2026-06-21 22:46:10 +02:00
Kevin Decherf 7db754debe Fix "Undefined array key 0" error
If a matching element isn't the first child (e.g. preceded by a whitespace
text node), it may sit at a key other than 0 after some cleanup, leading to
a "Undefined array key 0" error.

This fix is now covered by 6 tests.

Fixes #96

Signed-off-by: Kevin Decherf <kevin@kdecherf.com>
2026-06-21 17:06:38 +02:00
Jérémy BenoistandGitHub 18bfe842f8 Merge pull request #121 from j0k3r/dependabot/composer/friendsofphp/php-cs-fixer-3.95.8
Update friendsofphp/php-cs-fixer requirement from 3.94.2 to 3.95.8
2026-06-19 10:18:22 +02:00
dependabot[bot]andGitHub 63a0db9109 Update friendsofphp/php-cs-fixer requirement from 3.94.2 to 3.95.8
Updates the requirements on [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) to permit the latest version.
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.94.2...v3.95.8)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.8
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 08:08:54 +00:00
Jan Tojnar 63d6ef1a47 .github: Set up Dependabot
This will make GitHub check dependencies in `composer.json` once a week
and open PRs trying to bump each of them whenever a new version is released.
If the CI fails, as is likely the case with PHP-CS-Fixer, we will need
to fix is manually before the PR can be merged.

https://docs.github.com/en/code-security/tutorials/secure-your-dependencies/dependabot-quickstart
2026-06-19 10:05:13 +02:00
Jan Tojnar 116b6c839a JSLikeHTMLElement: Add types to methods
PHP-CS-Fixer 3.95.0 added `void_return` rule to `@Symfony:risky` ruleset.

https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases/tag/v3.95.0
https://cs.symfony.com/doc/rules/function_notation/void_return.html

`void` type declaration is available since PHP 7.1:
https://www.php.net/manual/en/language.types.void.php

Magic methods can have type annotations but they must be compatible with the default ones
(not enforced prior to PHP 8.0):
https://php.watch/versions/8.0/magic-method-signatures

Let’s just type all the methods.

This could technically be a BC break if someone is extending
the `JSLikeHTMLElement` class, overriding the methods that previously
did not have return type hint without adding it, and calling
`registerNodeClass` on the public `dom` property but I think it is
pretty unlikely – at least there seem to be no public instances on GitHub.
2026-06-19 10:00:39 +02:00
Jan Tojnar a35f00ebb3 php-cs-fixer: Prevent removing strict_types declaration
PHP-CS-Fixer 3.95.0 started removing the declarations as a part of
`@Symfony:risky` ruleset.

We currently only use it in `rector.php` but it works there so no point
in following Symfony code style on that.

We will probably want to enable `strict_types` globally eventually,
but let’s at least stop removing them for now so that we can bump
PHP-CS-Fixer.

https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases/tag/v3.95.0
https://cs.symfony.com/doc/rules/strict/declare_strict_types.html
2026-06-19 09:51:35 +02:00
Jan Tojnar 0f9f971556 tests: Use class constants instead of strings
Fixed using `composer rector` and then `composer fix`:

https://getrector.com/rule-detail/string-class-name-to-class-constant-rector
2026-06-19 09:50:01 +02:00
Jan Tojnar fd1eaf61a7 composer: Stop using lock file
Since we are a library that supports wide range of PHP versions,
we cannot lock into a single set of versions of run-time dependencies.

https://getcomposer.org/doc/02-libraries.md#lock-file

Composer 1.10.0 introduced `lock` option to prevent generation of `composer.lock` file:

https://github.com/composer/composer/releases/tag/1.10.0
https://getcomposer.org/doc/06-config.md#lock

This will basically make `composer install` work the same way as `composer update`.
2026-06-19 05:36:17 +02:00
Jan Tojnar 3e40d78a67 Pin development dependencies
PHP-CS-Fixer, PHPStan and Rector regularly introduce changes which break CI
in minor versions, forcing us to mix unrelated changes with CI fixes.

Let’s pin the minor version and update it on our own time e.g. with Dependabot.

This is also what PHPStan suggests for projects not using `composer.lock`:
https://phpstan.org/user-guide/backward-compatibility-promise#minimizing-impact-of-type-inference-changes
2026-06-19 05:17:48 +02:00
Jérémy BenoistandGitHub 03533f5e4f Merge pull request #111 from j0k3r/fix/tidy-php85
Fix bad encoding for `tidy_repair_string`
2026-03-27 20:34:19 +01:00
Jeremy Benoist 009b4ab9b8 Add PHP 8.5 on CI 2026-03-27 20:31:52 +01:00
Jeremy Benoist f9e73fb49a Fix bad encoding for tidy_repair_string
Tidy on PHP 8.5 is more restrictive on what can be given as input encoding. Before, it worked as an unknown value was converted to `utf8`.
2026-03-27 20:29:09 +01:00
Jérémy BenoistandGitHub 3042990efc Merge pull request #106 from jtojnar/encode
Fix character decoding regression when `title` precedes `meta[charset]`
2025-06-03 09:22:21 +02:00
Jan Tojnar 8b89d70b1a Fix character decoding regression when title precedes meta[charset]
Because of PHP 8.2 deprecation, in f14428e4c0, we stopped converting non-ASCII characters to HTML entities. Instead, we started to explicitly insert `meta[charset]` tag at the start of the document.

Later, we discovered that was breaking `html[lang]` so, in efbbc86df9, we made the insertion smarter. One of the improvements was that it would not insert the `meta[charset]` tag when it was already present.

That, however, broke websites that had `title` tag before `meta[charset]`. On those, libxml2 would decode the `title` contents as ISO-8859-1.

We could improve the logic (e.g. check that there is not text content before `meta[charset]`) or insert the tag unconditionally but it will probably be simplest to just go back to converting the non-ASCII characters to entities, just using non-deprecated function variant.
2025-05-28 01:10:59 +02:00
Jan Tojnar 3e9b15db46 tests: Check encoding was preserved in testHtmlLang
The fix introduced in efbbc86df9 alongside this test also manipulates `meta[charset]` but we were not checking if it does not break encoding.
2025-05-28 00:51:49 +02:00
7 changed files with 107 additions and 50 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
@@ -25,6 +25,7 @@ jobs:
- "8.2" - "8.2"
- "8.3" - "8.3"
- "8.4" - "8.4"
- "8.5"
steps: steps:
- name: "Checkout" - name: "Checkout"
+2
View File
@@ -30,6 +30,8 @@ return (new PhpCsFixer\Config())
'modernize_strpos' => false, 'modernize_strpos' => false,
// Pulled in by @Symfony, we cannot add property types until we bump PHP to ≥ 7.4 // Pulled in by @Symfony, we cannot add property types until we bump PHP to ≥ 7.4
'no_null_property_initialization' => false, 'no_null_property_initialization' => false,
// @Symfony:risky started removing them but we will probably want to go the opposite direction.
'declare_strict_types' => false,
]) ])
->setFinder($finder) ->setFinder($finder)
; ;
+7 -4
View File
@@ -30,12 +30,12 @@
"masterminds/html5": "^2.7" "masterminds/html5": "^2.7"
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^3.0", "friendsofphp/php-cs-fixer": "3.95.8",
"monolog/monolog": "^1.24|^2.1", "monolog/monolog": "^1.24|^2.1",
"symfony/phpunit-bridge": "^4.4|^5.3|^6.0|^7.0", "symfony/phpunit-bridge": "^4.4|^5.3|^6.0|^7.0",
"phpstan/phpstan": "^2.0", "phpstan/phpstan": "2.2.2",
"phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-phpunit": "2.0.16",
"rector/rector": "^2.0.0" "rector/rector": "2.4.6"
}, },
"suggest": { "suggest": {
"ext-tidy": "Used to clean up given HTML and to avoid problems with bad HTML structure." "ext-tidy": "Used to clean up given HTML and to avoid problems with bad HTML structure."
@@ -46,6 +46,9 @@
"autoload-dev": { "autoload-dev": {
"psr-4": { "Tests\\Readability\\": "tests/" } "psr-4": { "Tests\\Readability\\": "tests/" }
}, },
"config": {
"lock": false
},
"scripts": { "scripts": {
"fix": "php-cs-fixer fix --verbose --diff", "fix": "php-cs-fixer fix --verbose --diff",
"phpstan": "phpstan analyze --memory-limit 512M", "phpstan": "phpstan analyze --memory-limit 512M",
+8 -6
View File
@@ -43,7 +43,7 @@ class JSLikeHTMLElement extends \DOMElement
* $div->innerHTML = '<h2>Chapter 2</h2><p>The story begins...</p>'; * $div->innerHTML = '<h2>Chapter 2</h2><p>The story begins...</p>';
* ``` * ```
*/ */
public function __set($name, $value) public function __set(string $name, string $value): void
{ {
if ('innerHTML' !== $name) { if ('innerHTML' !== $name) {
$trace = debug_backtrace(); $trace = debug_backtrace();
@@ -108,7 +108,7 @@ class JSLikeHTMLElement extends \DOMElement
* $string = $div->innerHTML; * $string = $div->innerHTML;
* ``` * ```
*/ */
public function __get($name) public function __get(string $name): string
{ {
if ('innerHTML' === $name) { if ('innerHTML' === $name) {
$inner = ''; $inner = '';
@@ -124,20 +124,22 @@ class JSLikeHTMLElement extends \DOMElement
$trace = debug_backtrace(); $trace = debug_backtrace();
trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], \E_USER_NOTICE); trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . $trace[0]['line'], \E_USER_NOTICE);
return '';
} }
public function __toString() public function __toString(): string
{ {
return '[' . $this->tagName . ']'; return '[' . $this->tagName . ']';
} }
public function getInnerHtml() public function getInnerHtml(): string
{ {
return $this->__get('innerHTML'); return $this->__get('innerHTML');
} }
public function setInnerHtml($value) public function setInnerHtml(string $value): void
{ {
return $this->__set('innerHTML', $value); $this->__set('innerHTML', $value);
} }
} }
+18 -35
View File
@@ -88,7 +88,7 @@ class Readability implements LoggerAwareInterface
'enclose-text' => true, 'enclose-text' => true,
'merge-divs' => true, 'merge-divs' => true,
// 'merge-spans' => true, // 'merge-spans' => true,
'input-encoding' => '????', 'input-encoding' => 'utf8',
'output-encoding' => 'utf8', 'output-encoding' => 'utf8',
'hide-comments' => true, 'hide-comments' => true,
]; ];
@@ -1118,7 +1118,7 @@ class Readability implements LoggerAwareInterface
$topCandidates = array_filter( $topCandidates = array_filter(
$topCandidates, $topCandidates,
fn ($v, $idx) => 0 === $idx || null !== $v, static fn ($v, $idx) => 0 === $idx || null !== $v,
\ARRAY_FILTER_USE_BOTH \ARRAY_FILTER_USE_BOTH
); );
$topCandidate = $topCandidates[0]; $topCandidate = $topCandidates[0];
@@ -1422,7 +1422,7 @@ class Readability implements LoggerAwareInterface
unset($tidy); unset($tidy);
} }
$this->html = self::ensureMetaCharset((string) $this->html); $this->html = self::entitizeNonAscii((string) $this->html);
if ('html5lib' === $this->parser || 'html5' === $this->parser) { if ('html5lib' === $this->parser || 'html5' === $this->parser) {
$this->dom = (new HTML5())->loadHTML($this->html); $this->dom = (new HTML5())->loadHTML($this->html);
@@ -1481,10 +1481,17 @@ class Readability implements LoggerAwareInterface
private function hasSingleTagInsideElement(\DOMElement $node, string $tag): bool private function hasSingleTagInsideElement(\DOMElement $node, string $tag): bool
{ {
$childNodes = iterator_to_array($node->childNodes); $childNodes = iterator_to_array($node->childNodes);
$children = array_filter($childNodes, fn ($childNode) => $childNode instanceof \DOMElement); $children = array_filter($childNodes, static fn ($childNode) => $childNode instanceof \DOMElement);
// array_first() has been added in PHP 8.5, failing back for older versions
if (!\function_exists('array_first')) {
$firstChild = $children ? $children[array_key_first($children)] : null;
} else {
$firstChild = array_first($children);
}
// There should be exactly 1 element child with given tag // There should be exactly 1 element child with given tag
if (1 !== \count($children) || $children[0]->nodeName !== $tag) { if (1 !== \count($children) || $firstChild->nodeName !== $tag) {
return false; return false;
} }
@@ -1512,43 +1519,19 @@ class Readability implements LoggerAwareInterface
} }
/** /**
* Tries to insert `meta[charset]` tag into the proper place in the passed HTML document. * Converts non-ASCII UTF-8 characters to numeric HTML entities.
* *
* `DOMDocument::loadHTML` will parse HTML documents as ISO-8859-1 if there is no `meta[charset]` tag. * `DOMDocument::loadHTML` will parse HTML documents as ISO-8859-1 if there is no `meta[charset]` tag.
* This means that UTF-8-encoded HTML fragments such as those coming from JSON-LD `articleBody` field would be parsed with incorrect encoding. * This means that UTF-8-encoded HTML fragments such as those coming from JSON-LD `articleBody` field would be parsed with incorrect encoding.
* Unfortunately, we cannot just put the tag at the start of the HTML fragment, since that would cause parser to auto-insert a `html` element, losing the attributes of the original `html` tag.
* *
* @param string $html UTF-8 encoded document * @param string $html UTF-8 encoded document
*/ */
private static function ensureMetaCharset(string $html): string private static function entitizeNonAscii(string $html): string
{ {
$charsetTag = '<meta charset="utf-8">'; $convmap = [
0x80, 0x1FFFFF, 0, 0x10FFFF,
];
// Only look at first 1024 bytes since, according to HTML5 specification, return mb_encode_numericentity($html, $convmap, 'utf8', true);
// that’s where <meta> elements declaring a character encoding must be located.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#charset
$start = substr($html, 0, 1000);
if (1 === preg_match('/<meta[^>]+charset/i', $start)) {
// <meta> tag is already present, no need for modification.
return $html;
}
if (1 === preg_match('/<head[^>]*>/i', $start)) {
// <head> tag was located, <meta> tags go there.
$html = preg_replace('/<head[^>]*>/i', '$0' . $charsetTag, $html, 1);
return $html;
}
if (1 === preg_match('/<html[^>]*>/i', $start)) {
// <html> tag was located, let’s put it inside and have parser create <head>.
$html = preg_replace('/<html[^>]*>/i', '$0' . $charsetTag, $html, 1);
return $html;
}
// Fallback – just plop the <meta> at the start of the fragment.
return $charsetTag . $html;
} }
} }
+65 -5
View File
@@ -21,6 +21,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
public function testConstructDefault(): void public function testConstructDefault(): void
{ {
$readability = $this->getReadability(''); $readability = $this->getReadability('');
$this->assertSame('utf8', $readability->tidy_config['input-encoding']);
$readability->init(); $readability->init();
$this->assertNull($readability->url); $this->assertNull($readability->url);
@@ -323,7 +324,7 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$oldErrorReporting = error_reporting(\E_ALL); $oldErrorReporting = error_reporting(\E_ALL);
$oldDisplayErrors = ini_set('display_errors', '1'); $oldDisplayErrors = ini_set('display_errors', '1');
// dummy function to be used to the next test // dummy function to be used to the next test
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) { set_error_handler(static function (int $errno, string $errstr, string $errfile, int $errline) {
throw new \Exception($errstr, $errno); throw new \Exception($errstr, $errno);
}); });
@@ -463,6 +464,49 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertTrue($res); $this->assertTrue($res);
} }
public function dataForHasSingleTagInsideElement(): array
{
return [
'single matching tag, no other content' => [
'<div><p>Some text</p></div>', 'p', true,
],
'single matching tag with comment' => [
'<div><!-- comment --><p>Some text</p></div>', 'p', true,
],
'whitespace-only text around single matching tag' => [
'<div> <p>Some text</p> </div>', 'p', true,
],
'two matching tags' => [
'<div><p>One</p><p>Two</p></div>', 'p', false,
],
'non-whitespace text alongside the single tag' => [
'<div>Some text<p>One</p></div>', 'p', false,
],
'single tag with a different name' => [
'<div><span>One</span></div>', 'p', false,
],
];
}
/**
* @dataProvider dataForHasSingleTagInsideElement
*/
public function testHasSingleTagInsideElement(string $html, string $tag, bool $expected): void
{
$dom = new \DOMDocument();
$dom->loadHTML('<html><body>' . $html . '</body></html>');
$node = $dom->getElementsByTagName('div')->item(0);
$readability = $this->getReadability('', '', 'libxml', false);
$method = new \ReflectionMethod($readability, 'hasSingleTagInsideElement');
if (\PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
$this->assertSame($expected, $method->invoke($readability, $node, $tag));
}
public function testKeepFootnotes(): void public function testKeepFootnotes(): void
{ {
// from https://www.schreibdichte.de/blog/feed-aggregator-und-spaeter-lesen-dienst-im-team // from https://www.schreibdichte.de/blog/feed-aggregator-und-spaeter-lesen-dienst-im-team
@@ -529,6 +573,20 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
} }
} }
// https://github.com/wallabag/wallabag/issues/8158
public function testCharsetAfterTitle(): void
{
$readability = $this->getReadability('<!DOCTYPE html><html lang="et"><head><title>Tõde ja õigus I</title> <meta charset="utf-8"></head><body><p>See oli läinud aastasaja kolmanda veerandi lõpul. Päike lähenes silmapiirile, seistes sedavõrd madalas, et enam ei ulatunud valgustama ei mäkke ronivat hobust, kes puutelgedega vankrit vedas, ei vankril istuvat noort naist ega ka ligi kolmekümnelist meest, kes kõndis vankri kõrval.</p></body></html>', 'https://et.wikisource.org/wiki/T%C3%B5de_ja_%C3%B5igus_I/I');
$readability->convertLinksToFootnotes = true;
$res = $readability->init();
$this->assertTrue($res);
$this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getTitle());
$this->assertSame('Tõde ja õigus I', $readability->getTitle()->getInnerHtml());
$this->assertStringContainsString('Päike lähenes', $readability->getContent()->getInnerHtml());
}
/** /**
* @return array<string, array{0: string, 1: string, 2?: bool}> * @return array<string, array{0: string, 1: string, 2?: bool}>
*/ */
@@ -536,21 +594,21 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
{ {
return [ return [
'meta' => [ 'meta' => [
'<html lang="fr"><head><meta charset="utf-8"></head><body><article>' . str_repeat('<p>This is the awesome content :)</p>', 7) . '</article></body></html>', '<html lang="fr"><head><meta charset="utf-8"></head><body><article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article></body></html>',
'fr', 'fr',
], ],
'head' => [ 'head' => [
'<html lang="fr"><head><title>Foo</title></head><body><article>' . str_repeat('<p>This is the awesome content :)</p>', 7) . '</article></body></html>', '<html lang="fr"><head><title>Foo</title></head><body><article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article></body></html>',
'fr', 'fr',
], ],
'headless' => [ 'headless' => [
'<html lang="fr"><body><article>' . str_repeat('<p>This is the awesome content :)</p>', 7) . '</article></body></html>', '<html lang="fr"><body><article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article></body></html>',
'fr', 'fr',
// tidy would add <head> tag. // tidy would add <head> tag.
false, false,
], ],
'fragment' => [ 'fragment' => [
'<article>' . str_repeat('<p>This is the awesome content :)</p>', 7) . '</article>', '<article>' . str_repeat('<p>Tous les êtres humains naissent libres et égaux en dignité et en droits. Ils sont doués de raison et de conscience et doivent agir les uns envers les autres dans un esprit de fraternité.</p>', 7) . '</article>',
'', '',
// tidy would add <html>. // tidy would add <html>.
false, false,
@@ -569,6 +627,8 @@ class ReadabilityTest extends \PHPUnit\Framework\TestCase
$this->assertTrue($res); $this->assertTrue($res);
$this->assertInstanceOf(\DOMDocument::class, $readability->dom); $this->assertInstanceOf(\DOMDocument::class, $readability->dom);
$this->assertSame($lang, $readability->dom->documentElement->getAttribute('lang')); $this->assertSame($lang, $readability->dom->documentElement->getAttribute('lang'));
$this->assertInstanceOf(JSLikeHTMLElement::class, $readability->getContent());
$this->assertStringContainsString('êtres', $readability->getContent()->getInnerHtml());
} }
private function getReadability(string $html, ?string $url = null, string $parser = 'libxml', bool $useTidy = true): Readability private function getReadability(string $html, ?string $url = null, string $parser = 'libxml', bool $useTidy = true): Readability