af_fulltext: rule-driven extraction with a pluggable renderer

Replaces subscribing feeds through a self-hosted Full-Text RSS proxy. Feed URLs
go back to being real feed URLs and extraction happens inside tt-rss, using the
same ftr-site-config rules the proxy used.

The engine in lib/ has no tt-rss dependencies, so rules can be developed and
audited from the command line; init.php is a thin adapter over it.

Two findings from measuring the real subscription first, both of which shaped
the design:

- Firecrawl's own onlyMainContent is far too coarse to extract with (73KB of
  chrome on a Cloudflare post), but it is an excellent renderer. So it is used
  for rawHtml only and the rule engine does the extraction.
- A body rule that stops matching after a redesign falls through to Readability
  and still produces a plausible article, so the breakage is invisible. Every
  extraction now records which rule matched and whether it fell back; auditing
  the 34 live feeds surfaced five community rules that match nothing.

Custom rules included for the sites that needed them, including three comics
where the article is an image and text-scoring extractors return the wrong thing
or nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sSJdftQx5bW5HZHgUKF3i
This commit is contained in:
Michal
2026-08-25 00:16:16 +01:00
commit 9bc854ec44
146 changed files with 30221 additions and 0 deletions

View File

@@ -0,0 +1,485 @@
<?php
namespace Masterminds\HTML5\Tests;
use Masterminds\HTML5\Elements;
class ElementsTest extends TestCase
{
public $html5Elements = array(
'a',
'abbr',
'address',
'area',
'article',
'aside',
'audio',
'b',
'base',
'bdi',
'bdo',
'blockquote',
'body',
'br',
'button',
'canvas',
'caption',
'cite',
'code',
'col',
'colgroup',
'command',
// "data",
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'em',
'embed',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hgroup',
'hr',
'html',
'i',
'iframe',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'link',
'map',
'mark',
'menu',
'meta',
'meter',
'nav',
'noscript',
'object',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'pre',
'progress',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'script',
'section',
'select',
'small',
'source',
'span',
'strong',
'style',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'u',
'ul',
'var',
'video',
'wbr',
);
public $mathmlElements = array(
'maction',
'maligngroup',
'malignmark',
'math',
'menclose',
'merror',
'mfenced',
'mfrac',
'mglyph',
'mi',
'mlabeledtr',
'mlongdiv',
'mmultiscripts',
'mn',
'mo',
'mover',
'mpadded',
'mphantom',
'mroot',
'mrow',
'ms',
'mscarries',
'mscarry',
'msgroup',
'msline',
'mspace',
'msqrt',
'msrow',
'mstack',
'mstyle',
'msub',
'msup',
'msubsup',
'mtable',
'mtd',
'mtext',
'mtr',
'munder',
'munderover',
);
public $svgElements = array(
'a',
'altGlyph',
'altGlyphDef',
'altGlyphItem',
'animate',
'animateColor',
'animateMotion',
'animateTransform',
'circle',
'clipPath',
'color-profile',
'cursor',
'defs',
'desc',
'ellipse',
'feBlend',
'feColorMatrix',
'feComponentTransfer',
'feComposite',
'feConvolveMatrix',
'feDiffuseLighting',
'feDisplacementMap',
'feDistantLight',
'feFlood',
'feFuncA',
'feFuncB',
'feFuncG',
'feFuncR',
'feGaussianBlur',
'feImage',
'feMerge',
'feMergeNode',
'feMorphology',
'feOffset',
'fePointLight',
'feSpecularLighting',
'feSpotLight',
'feTile',
'feTurbulence',
'filter',
'font',
'font-face',
'font-face-format',
'font-face-name',
'font-face-src',
'font-face-uri',
'foreignObject',
'g',
'glyph',
'glyphRef',
'hkern',
'image',
'line',
'linearGradient',
'marker',
'mask',
'metadata',
'missing-glyph',
'mpath',
'path',
'pattern',
'polygon',
'polyline',
'radialGradient',
'rect',
'script',
'set',
'stop',
'style',
'svg',
'switch',
'symbol',
'text',
'textPath',
'title',
'tref',
'tspan',
'use',
'view',
'vkern',
);
public function testIsHtml5Element()
{
foreach ($this->html5Elements as $element) {
$this->assertTrue(Elements::isHtml5Element($element), 'html5 element test failed on: ' . $element);
$this->assertTrue(Elements::isHtml5Element(strtoupper($element)), 'html5 element test failed on: ' . strtoupper($element));
}
$nonhtml5 = array(
'foo',
'bar',
'baz',
);
foreach ($nonhtml5 as $element) {
$this->assertFalse(Elements::isHtml5Element($element), 'html5 element test failed on: ' . $element);
$this->assertFalse(Elements::isHtml5Element(strtoupper($element)), 'html5 element test failed on: ' . strtoupper($element));
}
}
public function testIsMathMLElement()
{
foreach ($this->mathmlElements as $element) {
$this->assertTrue(Elements::isMathMLElement($element), 'MathML element test failed on: ' . $element);
// MathML is case sensitive so these should all fail.
$this->assertFalse(Elements::isMathMLElement(strtoupper($element)), 'MathML element test failed on: ' . strtoupper($element));
}
$nonMathML = array(
'foo',
'bar',
'baz',
);
foreach ($nonMathML as $element) {
$this->assertFalse(Elements::isMathMLElement($element), 'MathML element test failed on: ' . $element);
}
}
public function testIsSvgElement()
{
foreach ($this->svgElements as $element) {
$this->assertTrue(Elements::isSvgElement($element), 'SVG element test failed on: ' . $element);
// SVG is case sensitive so these should all fail.
$this->assertFalse(Elements::isSvgElement(strtoupper($element)), 'SVG element test failed on: ' . strtoupper($element));
}
$nonSVG = array(
'foo',
'bar',
'baz',
);
foreach ($nonSVG as $element) {
$this->assertFalse(Elements::isSvgElement($element), 'SVG element test failed on: ' . $element);
}
}
public function testIsElement()
{
foreach ($this->html5Elements as $element) {
$this->assertTrue(Elements::isElement($element), 'html5 element test failed on: ' . $element);
$this->assertTrue(Elements::isElement(strtoupper($element)), 'html5 element test failed on: ' . strtoupper($element));
}
foreach ($this->mathmlElements as $element) {
$this->assertTrue(Elements::isElement($element), 'MathML element test failed on: ' . $element);
// MathML is case sensitive so these should all fail.
$this->assertFalse(Elements::isElement(strtoupper($element)), 'MathML element test failed on: ' . strtoupper($element));
}
foreach ($this->svgElements as $element) {
$this->assertTrue(Elements::isElement($element), 'SVG element test failed on: ' . $element);
// SVG is case sensitive so these should all fail. But, there is duplication
// html5 and SVG. Since html5 is case insensitive we need to make sure
// it's not a html5 element first.
if (!in_array($element, $this->html5Elements)) {
$this->assertFalse(Elements::isElement(strtoupper($element)), 'SVG element test failed on: ' . strtoupper($element));
}
}
$nonhtml5 = array(
'foo',
'bar',
'baz',
);
foreach ($nonhtml5 as $element) {
$this->assertFalse(Elements::isElement($element), 'html5 element test failed on: ' . $element);
$this->assertFalse(Elements::isElement(strtoupper($element)), 'html5 element test failed on: ' . strtoupper($element));
}
}
public function testElement()
{
foreach ($this->html5Elements as $element) {
$this->assertGreaterThan(0, Elements::element($element));
}
$nonhtml5 = array(
'foo',
'bar',
'baz',
);
foreach ($nonhtml5 as $element) {
$this->assertEquals(0, Elements::element($element));
}
}
public function testIsA()
{
$this->assertTrue(Elements::isA('script', Elements::KNOWN_ELEMENT));
$this->assertFalse(Elements::isA('scriptypoo', Elements::KNOWN_ELEMENT));
$this->assertTrue(Elements::isA('script', Elements::TEXT_RAW));
$this->assertFalse(Elements::isA('script', Elements::TEXT_RCDATA));
$voidElements = array(
'area',
'base',
'basefont',
'bgsound',
'br',
'col',
'command',
'embed',
'frame',
'hr',
'img',
);
foreach ($voidElements as $element) {
$this->assertTrue(Elements::isA($element, Elements::VOID_TAG), 'Void element test failed on: ' . $element);
}
$nonVoid = array(
'span',
'a',
'div',
);
foreach ($nonVoid as $tag) {
$this->assertFalse(Elements::isA($tag, Elements::VOID_TAG), 'Void element test failed on: ' . $tag);
}
$blockTags = array(
'address',
'article',
'aside',
'blockquote',
'canvas',
'dd',
'div',
'dl',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'noscript',
'ol',
'output',
'p',
'pre',
'section',
'table',
'tfoot',
'ul',
'video',
);
foreach ($blockTags as $tag) {
$this->assertTrue(Elements::isA($tag, Elements::BLOCK_TAG), 'Block tag test failed on: ' . $tag);
}
$nonBlockTags = array(
'span',
'img',
'label',
);
foreach ($nonBlockTags as $tag) {
$this->assertFalse(Elements::isA($tag, Elements::BLOCK_TAG), 'Block tag test failed on: ' . $tag);
}
}
public function testNormalizeSvgElement()
{
$tests = array(
'foo' => 'foo',
'altglyph' => 'altGlyph',
'BAR' => 'bar',
'fespecularlighting' => 'feSpecularLighting',
'bAz' => 'baz',
'foreignobject' => 'foreignObject',
);
foreach ($tests as $input => $expected) {
$this->assertEquals($expected, Elements::normalizeSvgElement($input));
}
}
public function testNormalizeSvgAttribute()
{
$tests = array(
'foo' => 'foo',
'attributename' => 'attributeName',
'BAR' => 'bar',
'limitingconeangle' => 'limitingConeAngle',
'bAz' => 'baz',
'patterncontentunits' => 'patternContentUnits',
);
foreach ($tests as $input => $expected) {
$this->assertEquals($expected, Elements::normalizeSvgAttribute($input));
}
}
public function testNormalizeMathMlAttribute()
{
$tests = array(
'foo' => 'foo',
'definitionurl' => 'definitionURL',
'BAR' => 'bar',
);
foreach ($tests as $input => $expected) {
$this->assertEquals($expected, Elements::normalizeMathMlAttribute($input));
}
}
}

View File

@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
</head>
<body>
<p>Žťčýů</p>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=windows-1252">
</head>
<body>
<p>Ž<EFBFBD>èýù</p>
</body>
</html>

View File

@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test</title>
</head>
<body>
<p>This is a test.</p>
</body>
</html>

View File

@@ -0,0 +1,492 @@
<?php
namespace Masterminds\HTML5\Tests;
use Masterminds\HTML5;
class Html5Test extends TestCase
{
/**
* @var HTML5
*/
private $html5;
public function setUp()
{
$this->html5 = $this->getInstance();
}
/**
* Parse and serialize a string.
*/
protected function cycle($html)
{
$dom = $this->html5->loadHTML('<!DOCTYPE html><html><body>' . $html . '</body></html>');
$out = $this->html5->saveHTML($dom);
return $out;
}
protected function cycleFragment($fragment)
{
$dom = $this->html5->loadHTMLFragment($fragment);
$out = $this->html5->saveHTML($dom);
return $out;
}
public function testImageTagsInSvg()
{
$html = '<!DOCTYPE html>
<html>
<head>
<title>foo</title>
</head>
<body>
<svg>
<image height="10" width="10"></image>
</svg>
</body>
</html>';
$doc = $this->html5->loadHTML($html);
$this->assertInstanceOf('DOMElement', $doc->getElementsByTagName('image')->item(0));
$this->assertEmpty($this->html5->getErrors());
}
public function testLoadOptions()
{
// doc
$dom = $this->html5->loadHTML($this->wrap('<t:tag/>'), array(
'implicitNamespaces' => array('t' => 'http://example.com'),
'xmlNamespaces' => true,
));
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$this->assertFalse($this->html5->hasErrors());
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('t', 'http://example.com');
$this->assertEquals(1, $xpath->query('//t:tag')->length);
// doc fragment
$frag = $this->html5->loadHTMLFragment('<t:tag/>', array(
'implicitNamespaces' => array('t' => 'http://example.com'),
'xmlNamespaces' => true,
));
$this->assertInstanceOf('\DOMDocumentFragment', $frag);
$this->assertEmpty($this->html5->getErrors());
$this->assertFalse($this->html5->hasErrors());
$frag->ownerDocument->appendChild($frag);
$xpath = new \DOMXPath($frag->ownerDocument);
$xpath->registerNamespace('t', 'http://example.com');
$this->assertEquals(1, $xpath->query('//t:tag', $frag)->length);
}
public function testEncodingUtf8()
{
$dom = $this->html5->load(__DIR__ . '/Fixtures/encoding/utf-8.html');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$this->assertFalse($this->html5->hasErrors());
$this->assertContains('Žťčýů', $dom->saveHTML());
}
public function testEncodingWindows1252()
{
$dom = $this->html5->load(__DIR__ . '/Fixtures/encoding/windows-1252.html', array(
'encoding' => 'Windows-1252',
));
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$this->assertFalse($this->html5->hasErrors());
$dumpedAsUtf8 = mb_convert_encoding($dom->saveHTML(), 'UTF-8', 'Windows-1252');
$this->assertNotFalse(mb_strpos($dumpedAsUtf8, 'Ž'));
$this->assertNotFalse(mb_strpos($dumpedAsUtf8, 'è'));
$this->assertNotFalse(mb_strpos($dumpedAsUtf8, 'ý'));
$this->assertNotFalse(mb_strpos($dumpedAsUtf8, 'ù'));
}
public function testErrors()
{
$dom = $this->html5->loadHTML('<xx as>');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertNotEmpty($this->html5->getErrors());
$this->assertTrue($this->html5->hasErrors());
}
public function testLoad()
{
$dom = $this->html5->load(__DIR__ . '/Html5Test.html');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$this->assertFalse($this->html5->hasErrors());
$file = fopen(__DIR__ . '/Html5Test.html', 'r');
$dom = $this->html5->load($file);
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$dom = $this->html5->loadHTMLFile(__DIR__ . '/Html5Test.html');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
}
public function testLoadHTML()
{
$contents = file_get_contents(__DIR__ . '/Html5Test.html');
$dom = $this->html5->loadHTML($contents);
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
}
public function testLoadHTMLWithComments()
{
$contents = '<!--[if lte IE 8]> <html class="no-js lt-ie9" lang="en"> <![endif]-->
<!--[if gt IE 8]> <!--><html class="no-js" lang="en"><!--<![endif]-->
</html>';
$dom = $this->html5->loadHTML($contents);
$this->assertInstanceOf('\DOMDocument', $dom);
$expected = '<!DOCTYPE html>
<!--[if lte IE 8]> <html class="no-js lt-ie9" lang="en"> <![endif]--><!--[if gt IE 8]> <!--><html class="no-js" lang="en"><!--<![endif]--></html>
';
$this->assertEquals($expected, $this->html5->saveHTML($dom));
}
public function testLoadHTMLFragment()
{
$fragment = '<section id="Foo"><div class="Bar">Baz</div></section>';
$dom = $this->html5->loadHTMLFragment($fragment);
$this->assertInstanceOf('\DOMDocumentFragment', $dom);
$this->assertEmpty($this->html5->getErrors());
}
public function testSaveHTML()
{
$dom = $this->html5->load(__DIR__ . '/Html5Test.html');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$saved = $this->html5->saveHTML($dom);
$this->assertRegExp('|<p>This is a test.</p>|', $saved);
}
public function testSaveHTMLFragment()
{
$fragment = '<section id="Foo"><div class="Bar">Baz</div></section>';
$dom = $this->html5->loadHTMLFragment($fragment);
$string = $this->html5->saveHTML($dom);
$this->assertEquals($fragment, $string);
}
public function testSave()
{
$dom = $this->html5->load(__DIR__ . '/Html5Test.html');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
// Test resource
$file = fopen('php://temp', 'w');
$this->html5->save($dom, $file);
$content = stream_get_contents($file, -1, 0);
$this->assertRegExp('|<p>This is a test.</p>|', $content);
// Test file
$tmpfname = tempnam(sys_get_temp_dir(), 'html5-php');
$this->html5->save($dom, $tmpfname);
$content = file_get_contents($tmpfname);
$this->assertRegExp('|<p>This is a test.</p>|', $content);
unlink($tmpfname);
}
// This test reads a document into a dom, turn the dom into a document,
// then tries to read that document again. This makes sure we are reading,
// and generating a document that works at a high level.
public function testItWorks()
{
$dom = $this->html5->load(__DIR__ . '/Html5Test.html');
$this->assertInstanceOf('\DOMDocument', $dom);
$this->assertEmpty($this->html5->getErrors());
$saved = $this->html5->saveHTML($dom);
$dom2 = $this->html5->loadHTML($saved);
$this->assertInstanceOf('\DOMDocument', $dom2);
$this->assertEmpty($this->html5->getErrors());
}
public function testConfig()
{
$html5 = $this->getInstance();
$options = $html5->getOptions();
$this->assertEquals(false, $options['encode_entities']);
$html5 = $this->getInstance(array(
'foo' => 'bar',
'encode_entities' => true,
));
$options = $html5->getOptions();
$this->assertEquals('bar', $options['foo']);
$this->assertEquals(true, $options['encode_entities']);
// Need to reset to original so future tests pass as expected.
// $this->getInstance()->setOption('encode_entities', false);
}
public function testSvg()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz">foo bar baz</div>
<svg width="150" height="100" viewBox="0 0 3 2">
<rect width="1" height="2" x="0" fill="#008d46" />
<rect width="1" height="2" x="1" fill="#ffffff" />
<rect width="1" height="2" x="2" fill="#d2232c" />
<text font-family="Verdana" font-size="32">
<textPath xlink:href="#Foo">
Test Text.
</textPath>
</text>
</svg>
</body>
</html>');
$this->assertEmpty($this->html5->getErrors());
// Test a mixed case attribute.
$list = $dom->getElementsByTagName('svg');
$this->assertNotEmpty($list->length);
$svg = $list->item(0);
$this->assertEquals('0 0 3 2', $svg->getAttribute('viewBox'));
$this->assertFalse($svg->hasAttribute('viewbox'));
// Test a mixed case tag.
// Note: getElementsByTagName is not case sensitive.
$list = $dom->getElementsByTagName('textPath');
$this->assertNotEmpty($list->length);
$textPath = $list->item(0);
$this->assertEquals('textPath', $textPath->tagName);
$this->assertNotEquals('textpath', $textPath->tagName);
$html = $this->html5->saveHTML($dom);
$this->assertRegExp('|<svg width="150" height="100" viewBox="0 0 3 2">|', $html);
$this->assertRegExp('|<rect width="1" height="2" x="0" fill="#008d46" />|', $html);
}
public function testMathMl()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz" definitionURL="http://example.com">foo bar baz</div>
<math>
<mi>x</mi>
<csymbol definitionURL="http://www.example.com/mathops/multiops.html#plusminus">
<mo>&PlusMinus;</mo>
</csymbol>
<mi>y</mi>
</math>
</body>
</html>');
$this->assertEmpty($this->html5->getErrors());
$list = $dom->getElementsByTagName('math');
$this->assertNotEmpty($list->length);
$list = $dom->getElementsByTagName('div');
$this->assertNotEmpty($list->length);
$div = $list->item(0);
$this->assertEquals('http://example.com', $div->getAttribute('definitionurl'));
$this->assertFalse($div->hasAttribute('definitionURL'));
$list = $dom->getElementsByTagName('csymbol');
$csymbol = $list->item(0);
$this->assertEquals('http://www.example.com/mathops/multiops.html#plusminus', $csymbol->getAttribute('definitionURL'));
$this->assertFalse($csymbol->hasAttribute('definitionurl'));
$html = $this->html5->saveHTML($dom);
$this->assertRegExp('|<csymbol definitionURL="http://www.example.com/mathops/multiops.html#plusminus">|', $html);
$this->assertRegExp('|<mi>y</mi>|', $html);
}
public function testUnknownElements()
{
// The : should not have special handling accourding to section 2.9 of the
// spec. This is differenant than XML. Since we don't know these elements
// they are handled as normal elements. Note, to do this is really
// an invalid example and you should not embed prefixed xml in html5.
$dom = $this->html5->loadHTMLFragment(
'<f:rug>
<f:name>Big rectangle thing</f:name>
<f:width>40</f:width>
<f:length>80</f:length>
</f:rug>
<sarcasm>um, yeah</sarcasm>');
$this->assertEmpty($this->html5->getErrors());
$markup = $this->html5->saveHTML($dom);
$this->assertRegExp('|<f:name>Big rectangle thing</f:name>|', $markup);
$this->assertRegExp('|<sarcasm>um, yeah</sarcasm>|', $markup);
}
public function testElements()
{
// Should have content.
$res = $this->cycle('<div>FOO</div>');
$this->assertRegExp('|<div>FOO</div>|', $res);
// Should be empty
$res = $this->cycle('<span></span>');
$this->assertRegExp('|<span></span>|', $res);
// Should have content.
$res = $this->cycleFragment('<div>FOO</div>');
$this->assertRegExp('|<div>FOO</div>|', $res);
// Should be empty
$res = $this->cycleFragment('<span></span>');
$this->assertRegExp('|<span></span>|', $res);
// Elements with dashes and underscores
$res = $this->cycleFragment('<sp-an></sp-an>');
$this->assertRegExp('|<sp-an></sp-an>|', $res);
$res = $this->cycleFragment('<sp_an></sp_an>');
$this->assertRegExp('|<sp_an></sp_an>|', $res);
// Should have no closing tag.
$res = $this->cycle('<hr>');
$this->assertRegExp('|<hr></body>|', $res);
}
public function testAttributes()
{
$res = $this->cycle('<use xlink:href="#svg-track" xmlns:xlink="http://www.w3.org/1999/xlink"></use>');
$this->assertContains('<use xlink:href="#svg-track" xmlns:xlink="http://www.w3.org/1999/xlink"></use>', $res);
$res = $this->cycle('<div attr="val">FOO</div>');
$this->assertRegExp('|<div attr="val">FOO</div>|', $res);
// XXX: Note that spec does NOT require attrs in the same order.
$res = $this->cycle('<div attr="val" class="even">FOO</div>');
$this->assertRegExp('|<div attr="val" class="even">FOO</div>|', $res);
$res = $this->cycle('<div xmlns:foo="http://example.com">FOO</div>');
$this->assertRegExp('|<div xmlns:foo="http://example.com">FOO</div>|', $res);
$res = $this->cycleFragment('<div attr="val">FOO</div>');
$this->assertRegExp('|<div attr="val">FOO</div>|', $res);
// XXX: Note that spec does NOT require attrs in the same order.
$res = $this->cycleFragment('<div attr="val" class="even">FOO</div>');
$this->assertRegExp('|<div attr="val" class="even">FOO</div>|', $res);
$res = $this->cycleFragment('<div xmlns:foo="http://example.com">FOO</div>');
$this->assertRegExp('|<div xmlns:foo="http://example.com">FOO</div>|', $res);
}
public function testPCData()
{
$res = $this->cycle('<a>This is a test.</a>');
$this->assertRegExp('|This is a test.|', $res);
$res = $this->cycleFragment('<a>This is a test.</a>');
$this->assertRegExp('|This is a test.|', $res);
$res = $this->cycle('This
is
a
test.');
// Check that newlines are there, but don't count spaces.
$this->assertRegExp('|This\n\s*is\n\s*a\n\s*test.|', $res);
$res = $this->cycleFragment('This
is
a
test.');
// Check that newlines are there, but don't count spaces.
$this->assertRegExp('|This\n\s*is\n\s*a\n\s*test.|', $res);
$res = $this->cycle('<a>This <em>is</em> a test.</a>');
$this->assertRegExp('|This <em>is</em> a test.|', $res);
$res = $this->cycleFragment('<a>This <em>is</em> a test.</a>');
$this->assertRegExp('|This <em>is</em> a test.|', $res);
}
public function testUnescaped()
{
$res = $this->cycle('<script>2 < 1</script>');
$this->assertRegExp('|2 < 1|', $res);
$res = $this->cycle('<style>div>div>div</style>');
$this->assertRegExp('|div>div>div|', $res);
$res = $this->cycleFragment('<script>2 < 1</script>');
$this->assertRegExp('|2 < 1|', $res);
$res = $this->cycleFragment('<style>div>div>div</style>');
$this->assertRegExp('|div>div>div|', $res);
}
public function testEntities()
{
$res = $this->cycle('<a>Apples &amp; bananas.</a>');
$this->assertRegExp('|Apples &amp; bananas.|', $res);
$res = $this->cycleFragment('<a>Apples &amp; bananas.</a>');
$this->assertRegExp('|Apples &amp; bananas.|', $res);
$res = $this->cycleFragment('<p>R&D</p>');
$this->assertRegExp('|R&amp;D|', $res);
}
public function testCaseSensitiveTags()
{
$dom = $this->html5->loadHTML(
'<html><body><Button color="red">Error</Button></body></html>',
array(
'xmlNamespaces' => true,
)
);
$out = $this->html5->saveHTML($dom);
$this->assertRegExp('|<html><body><Button color="red">Error</Button></body></html>|', $out);
}
public function testComment()
{
$res = $this->cycle('a<!-- This is a test. -->b');
$this->assertRegExp('|<!-- This is a test. -->|', $res);
$res = $this->cycleFragment('a<!-- This is a test. -->b');
$this->assertRegExp('|<!-- This is a test. -->|', $res);
}
public function testCDATA()
{
$res = $this->cycle('a<![CDATA[ This <is> a test. ]]>b');
$this->assertRegExp('|<!\[CDATA\[ This <is> a test\. \]\]>|', $res);
$res = $this->cycleFragment('a<![CDATA[ This <is> a test. ]]>b');
$this->assertRegExp('|<!\[CDATA\[ This <is> a test\. \]\]>|', $res);
}
public function testAnchorTargetQueryParam()
{
$res = $this->cycle('<a href="https://domain.com/page.php?foo=bar&target=baz">https://domain.com/page.php?foo=bar&target=baz</a>');
$this->assertContains(
'<a href="https://domain.com/page.php?foo=bar&amp;target=baz">https://domain.com/page.php?foo=bar&amp;target=baz</a>',
$res
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* @file
* Test the Scanner. This requires the InputStream tests are all good.
*/
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Parser\CharacterReference;
class CharacterReferenceTest extends \Masterminds\HTML5\Tests\TestCase
{
public function testLookupName()
{
$this->assertEquals('&', CharacterReference::lookupName('amp'));
$this->assertEquals('<', CharacterReference::lookupName('lt'));
$this->assertEquals('>', CharacterReference::lookupName('gt'));
$this->assertEquals('"', CharacterReference::lookupName('quot'));
$this->assertEquals('∌', CharacterReference::lookupName('NotReverseElement'));
$this->assertNull(CharacterReference::lookupName('StinkyCheese'));
}
public function testLookupHex()
{
$this->assertEquals('<', CharacterReference::lookupHex('3c'));
$this->assertEquals('<', CharacterReference::lookupHex('003c'));
$this->assertEquals('&', CharacterReference::lookupHex('26'));
$this->assertEquals('}', CharacterReference::lookupHex('7d'));
$this->assertEquals('Σ', CharacterReference::lookupHex('3A3'));
$this->assertEquals('Σ', CharacterReference::lookupHex('03A3'));
$this->assertEquals('Σ', CharacterReference::lookupHex('3a3'));
$this->assertEquals('Σ', CharacterReference::lookupHex('03a3'));
}
public function testLookupDecimal()
{
$this->assertEquals('&', CharacterReference::lookupDecimal(38));
$this->assertEquals('&', CharacterReference::lookupDecimal('38'));
$this->assertEquals('<', CharacterReference::lookupDecimal(60));
$this->assertEquals('Σ', CharacterReference::lookupDecimal(931));
$this->assertEquals('Σ', CharacterReference::lookupDecimal('0931'));
}
}

View File

@@ -0,0 +1,743 @@
<?php
/**
* @file
* Test the Tree Builder.
*/
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Parser\Scanner;
use Masterminds\HTML5\Parser\Tokenizer;
use Masterminds\HTML5\Parser\DOMTreeBuilder;
/**
* These tests are functional, not necessarily unit tests.
*/
class DOMTreeBuilderTest extends \Masterminds\HTML5\Tests\TestCase
{
protected $errors = array();
/**
* Convenience function for parsing.
*/
protected function parse($string, array $options = array())
{
$treeBuilder = new DOMTreeBuilder(false, $options);
$scanner = new Scanner($string);
$parser = new Tokenizer($scanner, $treeBuilder);
$parser->parse();
$this->errors = $treeBuilder->getErrors();
return $treeBuilder->document();
}
/**
* Utility function for parsing a fragment of HTML5.
*/
protected function parseFragment($string)
{
$treeBuilder = new DOMTreeBuilder(true);
$scanner = new Scanner($string);
$parser = new Tokenizer($scanner, $treeBuilder);
$parser->parse();
$this->errors = $treeBuilder->getErrors();
return $treeBuilder->fragment();
}
public function testDocument()
{
$html = '<!DOCTYPE html><html></html>';
$doc = $this->parse($html);
$this->assertEquals('UTF-8', $doc->encoding);
$this->assertInstanceOf('\DOMDocument', $doc);
$this->assertEquals('html', $doc->documentElement->tagName);
$this->assertEquals('http://www.w3.org/1999/xhtml', $doc->documentElement->namespaceURI);
}
public function testBareAmpersand()
{
$html = "<!doctype html>
<html>
<body>
<img src='a&b' />
<img src='a&=' />
<img src='a&=c' />
<img src='a&=9' />
</body>
</html>";
$doc = $this->parse($html);
$this->assertEmpty($this->errors);
$this->assertXmlStringEqualsXmlString('
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><body>
<img src="a&amp;b"/>
<img src="a&amp;="/>
<img src="a&amp;=c"/>
<img src="a&amp;=9"/>
</body>
</html>', $doc->saveXML());
}
public function testBareAmpersandNotAllowedInAttributes()
{
$html = "<!doctype html>
<html>
<body>
<img src='a&' />
<img src='a&+' />
</body>
</html>";
$doc = $this->parse($html);
$this->assertCount(2, $this->errors);
$this->assertXmlStringEqualsXmlString('
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><body>
<img src="a&amp;"/>
<img src="a&amp;+"/>
</body>
</html>', $doc->saveXML());
}
public function testBareAmpersandNotAllowedInBody()
{
$html = '<!doctype html>
<html>
<body>
a&b
a&=
a&=c
a&=9
a&+
a& -- valid
</body>
</html>';
$doc = $this->parse($html);
$this->assertCount(5, $this->errors);
$this->assertXmlStringEqualsXmlString('
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><body>
a&amp;b
a&amp;=
a&amp;=c
a&amp;=9
a&amp;+
a&amp; -- valid
</body>
</html>', $doc->saveXML());
}
public function testEntityAtEndOfFile()
{
$fragment = $this->parseFragment('&#');
$this->assertInstanceOf('DOMDocumentFragment', $fragment);
$this->assertSame('&#', $fragment->textContent);
$this->assertEquals('Line 1, Col 2: Expected &#DEC; &#HEX;, got EOF', $this->errors[0]);
}
public function testStrangeCapitalization()
{
$html = '<!doctype html>
<html>
<head>
<Title>Hello, world!</TitlE>
</head>
<body>TheBody<script>foo</script></body>
</html>';
$doc = $this->parse($html);
$this->assertInstanceOf('\DOMDocument', $doc);
$this->assertEquals('html', $doc->documentElement->tagName);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('x', 'http://www.w3.org/1999/xhtml');
$this->assertEquals('Hello, world!', $xpath->query('//x:title')->item(0)->nodeValue);
$this->assertEquals('foo', $xpath->query('//x:script')->item(0)->nodeValue);
}
public function testDocumentWithDisabledNamespaces()
{
$html = '<!DOCTYPE html><html></html>';
$doc = $this->parse($html, array('disable_html_ns' => true));
$this->assertInstanceOf('\DOMDocument', $doc);
$this->assertEquals('html', $doc->documentElement->tagName);
$this->assertNull($doc->documentElement->namespaceURI);
}
public function testDocumentWithATargetDocument()
{
$targetDom = new \DOMDocument();
$html = '<!DOCTYPE html><html></html>';
$doc = $this->parse($html, array('target_document' => $targetDom));
$this->assertInstanceOf('\DOMDocument', $doc);
$this->assertSame($doc, $targetDom);
$this->assertEquals('html', $doc->documentElement->tagName);
}
public function testDocumentFakeAttrAbsence()
{
$html = '<!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><body>foo</body></html>';
$doc = $this->parse($html, array('xmlNamespaces' => true));
$xp = new \DOMXPath($doc);
$this->assertEquals(0, $xp->query('//@html5-php-fake-id-attribute')->length);
}
public function testFragment()
{
$html = '<div>test</div><span>test2</span>';
$doc = $this->parseFragment($html);
$this->assertInstanceOf('\DOMDocumentFragment', $doc);
$this->assertTrue($doc->hasChildNodes());
$this->assertEquals('div', $doc->childNodes->item(0)->tagName);
$this->assertEquals('test', $doc->childNodes->item(0)->textContent);
$this->assertEquals('span', $doc->childNodes->item(1)->tagName);
$this->assertEquals('test2', $doc->childNodes->item(1)->textContent);
}
public function testElements()
{
$html = '<!DOCTYPE html><html><head><title></title></head><body></body></html>';
$doc = $this->parse($html);
$root = $doc->documentElement;
$this->assertEquals('html', $root->tagName);
$this->assertEquals('html', $root->localName);
$this->assertEquals('html', $root->nodeName);
$this->assertEquals(2, $root->childNodes->length);
$kids = $root->childNodes;
$this->assertEquals('head', $kids->item(0)->tagName);
$this->assertEquals('body', $kids->item(1)->tagName);
$head = $kids->item(0);
$this->assertEquals(1, $head->childNodes->length);
$this->assertEquals('title', $head->childNodes->item(0)->tagName);
}
public function testImplicitNamespaces()
{
$dom = $this->parse('<!DOCTYPE html><html><body><a xlink:href="bar">foo</a></body></html>');
$a = $dom->getElementsByTagName('a')->item(0);
$attr = $a->getAttributeNode('xlink:href');
$this->assertEquals('http://www.w3.org/1999/xlink', $attr->namespaceURI);
$dom = $this->parse('<!DOCTYPE html><html><body><a xml:base="bar">foo</a></body></html>');
$a = $dom->getElementsByTagName('a')->item(0);
$attr = $a->getAttributeNode('xml:base');
$this->assertEquals('http://www.w3.org/XML/1998/namespace', $attr->namespaceURI);
}
public function testCustomImplicitNamespaces()
{
$dom = $this->parse('<!DOCTYPE html><html><body><a t:href="bar">foo</a></body></html>', array(
'implicitNamespaces' => array(
't' => 'http://www.example.com',
),
));
$a = $dom->getElementsByTagName('a')->item(0);
$attr = $a->getAttributeNode('t:href');
$this->assertEquals('http://www.example.com', $attr->namespaceURI);
$dom = $this->parse('<!DOCTYPE html><html><body><t:a>foo</t:a></body></html>', array(
'implicitNamespaces' => array(
't' => 'http://www.example.com',
),
));
$list = $dom->getElementsByTagNameNS('http://www.example.com', 'a');
$this->assertEquals(1, $list->length);
}
public function testXmlNamespaces()
{
$dom = $this->parse(
'<!DOCTYPE html><html>
<t:body xmlns:t="http://www.example.com">
<a t:href="bar">foo</a>
</body>
<div>foo</div>
</html>', array(
'xmlNamespaces' => true,
));
$a = $dom->getElementsByTagName('a')->item(0);
$attr = $a->getAttributeNode('t:href');
$this->assertEquals('http://www.example.com', $attr->namespaceURI);
$list = $dom->getElementsByTagNameNS('http://www.example.com', 'body');
$this->assertEquals(1, $list->length);
}
public function testXmlNamespaceNesting()
{
$dom = $this->parse(
'<!DOCTYPE html><html>
<body xmlns:x="http://www.prefixed.com" id="body">
<a id="bar1" xmlns="http://www.prefixed.com/bar1">
<b id="bar4" xmlns="http://www.prefixed.com/bar4"><x:prefixed id="prefixed"/></b>
</a>
<svg id="svg"></svg>
<c id="bar2" xmlns="http://www.prefixed.com/bar2"></c>
<div id="div"></div>
<d id="bar3"></d>
<xn:d xmlns:xn="http://www.prefixed.com/xn" xmlns="http://www.prefixed.com/bar5_x" id="bar5"><x id="bar5_x"/></xn:d>
</body>
</html>', array(
'xmlNamespaces' => true,
));
$this->assertEmpty($this->errors);
$div = $dom->getElementById('div');
$this->assertEquals('http://www.w3.org/1999/xhtml', $div->namespaceURI);
$body = $dom->getElementById('body');
$this->assertEquals('http://www.w3.org/1999/xhtml', $body->namespaceURI);
$bar1 = $dom->getElementById('bar1');
$this->assertEquals('http://www.prefixed.com/bar1', $bar1->namespaceURI);
$bar2 = $dom->getElementById('bar2');
$this->assertEquals('http://www.prefixed.com/bar2', $bar2->namespaceURI);
$bar3 = $dom->getElementById('bar3');
$this->assertEquals('http://www.w3.org/1999/xhtml', $bar3->namespaceURI);
$bar4 = $dom->getElementById('bar4');
$this->assertEquals('http://www.prefixed.com/bar4', $bar4->namespaceURI);
$svg = $dom->getElementById('svg');
$this->assertEquals('http://www.w3.org/2000/svg', $svg->namespaceURI);
$prefixed = $dom->getElementById('prefixed');
$this->assertEquals('http://www.prefixed.com', $prefixed->namespaceURI);
$prefixed = $dom->getElementById('bar5');
$this->assertEquals('http://www.prefixed.com/xn', $prefixed->namespaceURI);
$prefixed = $dom->getElementById('bar5_x');
$this->assertEquals('http://www.prefixed.com/bar5_x', $prefixed->namespaceURI);
}
public function testMoveNonInlineElements()
{
$doc = $this->parse('<p>line1<br/><hr/>line2</p>');
$this->assertEquals('<html xmlns="http://www.w3.org/1999/xhtml"><p>line1<br/></p><hr/>line2</html>', $doc->saveXML($doc->documentElement), 'Move non-inline elements outside of inline containers.');
$doc = $this->parse('<p>line1<div>line2</div></p>');
$this->assertEquals('<html xmlns="http://www.w3.org/1999/xhtml"><p>line1</p><div>line2</div></html>', $doc->saveXML($doc->documentElement), 'Move non-inline elements outside of inline containers.');
}
public function testAttributes()
{
$html = "<!DOCTYPE html>
<html>
<head><title></title></head>
<body id='a' class='b c'></body>
</html>";
$doc = $this->parse($html);
$root = $doc->documentElement;
$body = $root->GetElementsByTagName('body')->item(0);
$this->assertEquals('body', $body->tagName);
$this->assertTrue($body->hasAttributes());
$this->assertEquals('a', $body->getAttribute('id'));
$this->assertEquals('b c', $body->getAttribute('class'));
$body2 = $doc->getElementById('a');
$this->assertEquals('body', $body2->tagName);
$this->assertEquals('a', $body2->getAttribute('id'));
}
public function testSVGAttributes()
{
$html = "<!DOCTYPE html>
<html><body>
<svg width='150' viewbox='2'>
<rect textlength='2'/>
<animatecolor>foo</animatecolor>
</svg>
</body></html>";
$doc = $this->parse($html);
$root = $doc->documentElement;
$svg = $root->getElementsByTagName('svg')->item(0);
$this->assertTrue($svg->hasAttribute('viewBox'));
$rect = $root->getElementsByTagName('rect')->item(0);
$this->assertTrue($rect->hasAttribute('textLength'));
$ac = $root->getElementsByTagName('animateColor');
$this->assertEquals(1, $ac->length);
}
public function testMathMLAttribute()
{
$html = '<!doctype html>
<html lang="en">
<body>
<math>
<mi>x</mi>
<csymbol definitionurl="http://www.example.com/mathops/multiops.html#plusminus">
<mo>&PlusMinus;</mo>
</csymbol>
<mi>y</mi>
</math>
</body>
</html>';
$doc = $this->parse($html);
$root = $doc->documentElement;
$csymbol = $root->getElementsByTagName('csymbol')->item(0);
$this->assertTrue($csymbol->hasAttribute('definitionURL'));
}
public function testMissingHtmlTag()
{
$html = '<!DOCTYPE html><title>test</title>';
$doc = $this->parse($html);
$this->assertEquals('html', $doc->documentElement->tagName);
$this->assertEquals('title', $doc->documentElement->childNodes->item(0)->tagName);
}
public function testComment()
{
$html = '<html><!--Hello World.--></html>';
$doc = $this->parse($html);
$comment = $doc->documentElement->childNodes->item(0);
$this->assertEquals(XML_COMMENT_NODE, $comment->nodeType);
$this->assertEquals('Hello World.', $comment->data);
$html = '<!--Hello World.--><html></html>';
$doc = $this->parse($html);
$comment = $doc->childNodes->item(1);
$this->assertEquals(XML_COMMENT_NODE, $comment->nodeType);
$this->assertEquals('Hello World.', $comment->data);
$comment = $doc->childNodes->item(2);
$this->assertEquals(XML_ELEMENT_NODE, $comment->nodeType);
$this->assertEquals('html', $comment->tagName);
}
public function testCDATA()
{
$html = '<!DOCTYPE html><html><math><![CDATA[test]]></math></html>';
$doc = $this->parse($html);
$wrapper = $doc->getElementsByTagName('math')->item(0);
$this->assertEquals(1, $wrapper->childNodes->length);
$cdata = $wrapper->childNodes->item(0);
$this->assertEquals(XML_CDATA_SECTION_NODE, $cdata->nodeType);
$this->assertEquals('test', $cdata->data);
}
public function testText()
{
$html = '<!DOCTYPE html><html><head></head><body><math>test</math></body></html>';
$doc = $this->parse($html);
$wrapper = $doc->getElementsByTagName('math')->item(0);
$this->assertEquals(1, $wrapper->childNodes->length);
$data = $wrapper->childNodes->item(0);
$this->assertEquals(XML_TEXT_NODE, $data->nodeType);
$this->assertEquals('test', $data->data);
// The DomTreeBuilder has special handling for text when in before head mode.
$html = '<!DOCTYPE html><html>
Foo<head></head><body></body></html>';
$doc = $this->parse($html);
$this->assertEquals('Line 0, Col 0: Unexpected text. Ignoring: Foo', $this->errors[0]);
$headElement = $doc->documentElement->firstChild;
$this->assertEquals('head', $headElement->tagName);
}
public function testParseErrors()
{
$html = '<!DOCTYPE html><html><math><![CDATA[test';
$doc = $this->parse($html);
// We're JUST testing that we can access errors. Actual testing of
// error messages happen in the Tokenizer's tests.
$this->assertGreaterThan(0, count($this->errors));
$this->assertTrue(is_string($this->errors[0]));
}
public function testProcessingInstruction()
{
// Test the simple case, which is where PIs are inserted into the DOM.
$doc = $this->parse('<!DOCTYPE html><html><?foo bar?>');
$this->assertEquals(1, $doc->documentElement->childNodes->length);
$pi = $doc->documentElement->firstChild;
$this->assertInstanceOf('\DOMProcessingInstruction', $pi);
$this->assertEquals('foo', $pi->nodeName);
$this->assertEquals('bar', $pi->data);
// Leading xml PIs should be ignored.
$doc = $this->parse('<?xml version="1.0"?><!DOCTYPE html><html><head></head></html>');
$this->assertEquals(2, $doc->childNodes->length);
$this->assertInstanceOf('\DOMDocumentType', $doc->childNodes->item(0));
$this->assertInstanceOf('\DOMElement', $doc->childNodes->item(1));
}
public function testAutocloseP()
{
$html = '<!DOCTYPE html><html><body><p><figure></body></html>';
$doc = $this->parse($html);
$p = $doc->getElementsByTagName('p')->item(0);
$this->assertEquals(0, $p->childNodes->length);
$this->assertEquals('figure', $p->nextSibling->tagName);
}
public function testAutocloseLI()
{
$html = '<!doctype html>
<html lang="en">
<body>
<ul><li>Foo<li>Bar<li>Baz</ul>
</body>
</html>';
$doc = $this->parse($html);
$length = $doc->getElementsByTagName('ul')->item(0)->childNodes->length;
$this->assertEquals(3, $length);
}
public function testMathML()
{
$html = '<!doctype html>
<html lang="en">
<body>
<math xmlns="http://www.w3.org/1998/Math/MathML">
<mi>x</mi>
<csymbol definitionurl="http://www.example.com/mathops/multiops.html#plusminus">
<mo>&PlusMinus;</mo>
</csymbol>
<mi>y</mi>
</math>
</body>
</html>';
$doc = $this->parse($html);
$math = $doc->getElementsByTagName('math')->item(0);
$this->assertEquals('math', $math->tagName);
$this->assertEquals('math', $math->nodeName);
$this->assertEquals('math', $math->localName);
$this->assertEquals('http://www.w3.org/1998/Math/MathML', $math->namespaceURI);
}
public function testSVG()
{
$html = '<!doctype html>
<html lang="en">
<body>
<svg width="150" height="100" viewBox="0 0 3 2" xmlns="http://www.w3.org/2000/svg">
<rect width="1" height="2" x="2" fill="#d2232c" />
<text font-family="Verdana" font-size="32">
<textpath xlink:href="#Foo">
Test Text.
</textPath>
</text>
</svg>
</body>
</html>';
$doc = $this->parse($html);
$svg = $doc->getElementsByTagName('svg')->item(0);
$this->assertEquals('svg', $svg->tagName);
$this->assertEquals('svg', $svg->nodeName);
$this->assertEquals('svg', $svg->localName);
$this->assertEquals('http://www.w3.org/2000/svg', $svg->namespaceURI);
$textPath = $doc->getElementsByTagName('textPath')->item(0);
$this->assertEquals('textPath', $textPath->tagName);
}
public function testNoScript()
{
$html = '<!DOCTYPE html><html><head><noscript>No JS</noscript></head></html>';
$doc = $this->parse($html);
$this->assertEmpty($this->errors);
$noscript = $doc->getElementsByTagName('noscript')->item(0);
$this->assertEquals('noscript', $noscript->tagName);
$html = '<!DOCTYPE html><html><body><noscript><p>No JS</p></noscript></body></html>';
$doc = $this->parse($html);
$this->assertEmpty($this->errors);
$p = $doc->getElementsByTagName('p')->item(0);
$this->assertEquals('p', $p->tagName);
}
/**
* Regression for issue #13.
*/
public function testRegressionHTMLNoBody()
{
$html = '<!DOCTYPE html><html><span id="test">Test</span></html>';
$doc = $this->parse($html);
$span = $doc->getElementById('test');
$this->assertEmpty($this->errors);
$this->assertEquals('span', $span->tagName);
$this->assertEquals('Test', $span->textContent);
}
public function testInstructionProcessor()
{
$string = '<!DOCTYPE html><html><?foo bar ?></html>';
$treeBuilder = new DOMTreeBuilder();
$is = new InstructionProcessorMock();
$treeBuilder->setInstructionProcessor($is);
$scanner = new Scanner($string);
$parser = new Tokenizer($scanner, $treeBuilder);
$parser->parse();
$dom = $treeBuilder->document();
$div = $dom->getElementsByTagName('div')->item(0);
$this->assertEquals(1, $is->count);
$this->assertEquals('foo', $is->name);
$this->assertEquals('bar ', $is->data);
$this->assertEquals('div', $div->tagName);
$this->assertEquals('foo', $div->textContent);
}
public function testSelectGroupedOptions()
{
$html = <<<EOM
<!DOCTYPE html>
<html>
<head>
<title>testSelectGroupedOptions</title>
</head>
<body>
<select>
<optgroup id="first" label="first">
<option value="foo">foo</option>
<option value="bar">bar</option>
<option value="baz">baz</option>
</optgroup>
<optgroup id="second" label="second">
<option value="lorem">lorem</option>
<option value="ipsum">ipsum</option>
</optgroup>
</select>
</body>
</html>
EOM;
$dom = $this->parse($html);
$this->assertSame(3, $dom->getElementById('first')->getElementsByTagName('option')->length);
$this->assertSame(2, $dom->getElementById('second')->getElementsByTagName('option')->length);
}
public function testVoidTag()
{
$html = <<<EOM
<!DOCTYPE html>
<html>
<head>
<title>testVoidTag</title>
<meta>
<meta>
</head>
<body></body>
</html>
EOM;
$dom = $this->parse($html);
$this->assertSame(2, $dom->getElementsByTagName('meta')->length);
$this->assertSame(0, $dom->getElementsByTagName('meta')->item(0)->childNodes->length);
$this->assertSame(0, $dom->getElementsByTagName('meta')->item(1)->childNodes->length);
}
public function testIgnoreSelfClosingTag()
{
$html = <<<EOM
<!DOCTYPE html>
<html>
<head>
<title>testIllegalSelfClosingTag</title>
</head>
<body>
<div /><span>Hello, World!</span></div>
</body>
</html>
EOM;
$dom = $this->parse($html);
$this->assertSame(1, $dom->getElementsByTagName('div')->item(0)->childNodes->length);
}
public function testIAudioInParagraph()
{
$html = <<<EOM
<!DOCTYPE html>
<html>
<head>
<title>testIllegalSelfClosingTag</title>
</head>
<body>
<p>
<audio preload="none" controls="controls">
<source src="https://example.com/test.mp3" type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</p>
</body>
</html>>
</html>
EOM;
$dom = $this->parse($html);
$audio = $dom->getElementsByTagName('audio')->item(0);
$this->assertSame('p', $audio->parentNode->nodeName);
$this->assertSame(3, $audio->childNodes->length);
}
public function testClosingBr()
{
$html = <<<EOM
<!DOCTYPE html>
<html>
<head>
<title>testClosingBr</title>
</head>
<body>
<p>
This line ends with a normal line break <br class="attribute-should-be-retained">
This line ends with a line break marked up as a closing tag </br class="attribute-should-be-discarded">
</p>
</body>
</html>>
</html>
EOM;
$dom = $this->parse($html);
$this->assertSame(2, $dom->getElementsByTagName('br')->length);
$this->assertSame(1, $dom->getElementsByTagName('br')->item(0)->attributes->length);
$this->assertSame(0, $dom->getElementsByTagName('br')->item(1)->attributes->length);
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Elements;
use Masterminds\HTML5\Parser\EventHandler;
/**
* This testing class gathers events from a parser and builds a stack of events.
* It is useful for checking the output of a tokenizer.
*
* IMPORTANT:
*
* The startTag event also kicks the parser into TEXT_RAW when it encounters
* script or pre tags. This is to match the behavior required by the HTML5 spec,
* which says that the tree builder must tell the tokenizer when to switch states.
*/
class EventStack implements EventHandler
{
protected $stack;
public function __construct()
{
$this->stack = array();
}
/**
* Get the event stack.
*/
public function events()
{
return $this->stack;
}
public function depth()
{
return count($this->stack);
}
public function get($index)
{
return $this->stack[$index];
}
protected function store($event, $data = null)
{
$this->stack[] = array(
'name' => $event,
'data' => $data,
);
}
public function doctype($name, $type = 0, $id = null, $quirks = false)
{
$args = array(
$name,
$type,
$id,
$quirks,
);
$this->store('doctype', $args);
}
public function startTag($name, $attributes = array(), $selfClosing = false)
{
$args = func_get_args();
$this->store('startTag', $args);
if ('pre' == $name || 'script' == $name) {
return Elements::TEXT_RAW;
}
}
public function endTag($name)
{
$this->store('endTag', array(
$name,
));
}
public function comment($cdata)
{
$this->store('comment', array(
$cdata,
));
}
public function cdata($data)
{
$this->store('cdata', func_get_args());
}
public function text($cdata)
{
// fprintf(STDOUT, "Received TEXT event with: " . $cdata);
$this->store('text', array(
$cdata,
));
}
public function eof()
{
$this->store('eof');
}
public function parseError($msg, $line, $col)
{
// throw new EventStackParseError(sprintf("%s (line %d, col %d)", $msg, $line, $col));
// $this->store(sprintf("%s (line %d, col %d)", $msg, $line, $col));
$this->store('error', func_get_args());
}
public function processingInstruction($name, $data = null)
{
$this->store('pi', func_get_args());
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Masterminds\HTML5\Tests\Parser;
class EventStackError extends \Exception
{
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Masterminds\HTML5\Tests\Parser;
class InstructionProcessorMock implements \Masterminds\HTML5\InstructionProcessor
{
public $name = null;
public $data = null;
public $count = 0;
public function process(\DOMElement $element, $name, $data)
{
$this->name = $name;
$this->data = $data;
++$this->count;
$div = $element->ownerDocument->createElement('div');
$div->nodeValue = 'foo';
$element->appendChild($div);
return $div;
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* @file
* Test the Scanner. This requires the InputStream tests are all good.
*/
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Parser\StringInputStream;
use Masterminds\HTML5\Parser\Scanner;
class ScannerTest extends \Masterminds\HTML5\Tests\TestCase
{
/**
* A canary test to make sure the basics are setup and working.
*/
public function testConstructDeprecated()
{
$is = new StringInputStream('abc');
$s = new Scanner($is);
$this->assertInstanceOf('\Masterminds\HTML5\Parser\Scanner', $s);
}
public function testConstruct()
{
$this->assertInstanceOf('\Masterminds\HTML5\Parser\Scanner', new Scanner('abc'));
}
public function testNextDeprecated()
{
$s = new Scanner(new StringInputStream('abc'));
$this->assertEquals('b', $s->next());
$this->assertEquals('c', $s->next());
}
public function testNext()
{
$s = new Scanner('abc');
$this->assertEquals('b', $s->next());
$this->assertEquals('c', $s->next());
}
public function testPosition()
{
$s = new Scanner('abc');
$this->assertEquals(0, $s->position());
$s->next();
$this->assertEquals(1, $s->position());
}
public function testPeek()
{
$s = new Scanner('abc');
$this->assertEquals('b', $s->peek());
$s->next();
$this->assertEquals('c', $s->peek());
}
public function testCurrent()
{
$s = new Scanner('abc');
// Before scanning the string begins the current is empty.
$this->assertEquals('a', $s->current());
$c = $s->next();
$this->assertEquals('b', $s->current());
// Test movement through the string.
$c = $s->next();
$this->assertEquals('c', $s->current());
}
public function testUnconsume()
{
$s = new Scanner('abcdefghijklmnopqrst');
// Get initial position.
$s->next();
$start = $s->position();
// Move forward a bunch of positions.
$amount = 7;
for ($i = 0; $i < $amount; ++$i) {
$s->next();
}
// Roll back the amount we moved forward.
$s->unconsume($amount);
$this->assertEquals($start, $s->position());
}
public function testGetHex()
{
$s = new Scanner('ab13ck45DE*');
$this->assertEquals('ab13c', $s->getHex());
$s->next();
$this->assertEquals('45DE', $s->getHex());
}
public function testGetAsciiAlpha()
{
$s = new Scanner('abcdef1%mnop*');
$this->assertEquals('abcdef', $s->getAsciiAlpha());
// Move past the 1% to scan the next group of text.
$s->next();
$s->next();
$this->assertEquals('mnop', $s->getAsciiAlpha());
}
public function testGetAsciiAlphaNum()
{
$s = new Scanner('abcdef1ghpo#mn94op');
$this->assertEquals('abcdef1ghpo', $s->getAsciiAlphaNum());
// Move past the # to scan the next group of text.
$s->next();
$this->assertEquals('mn94op', $s->getAsciiAlphaNum());
}
public function testGetNumeric()
{
$s = new Scanner('1784a 45 9867 #');
$this->assertEquals('1784', $s->getNumeric());
// Move past the 'a ' to scan the next group of text.
$s->next();
$s->next();
$this->assertEquals('45', $s->getNumeric());
}
public function testCurrentLine()
{
$s = new Scanner("1784a\n45\n9867 #\nThis is a test.");
$this->assertEquals(1, $s->currentLine());
// Move to the next line.
$s->getAsciiAlphaNum();
$s->next();
$this->assertEquals(2, $s->currentLine());
}
public function testColumnOffset()
{
$s = new Scanner("1784a a\n45 9867 #\nThis is a test.");
// Move the pointer to the space.
$s->getAsciiAlphaNum();
$this->assertEquals(5, $s->columnOffset());
// We move the pointer ahead. There must be a better way to do this.
$s->next();
$s->next();
$s->next();
$s->next();
$s->next();
$s->next();
$this->assertEquals(3, $s->columnOffset());
}
public function testRemainingChars()
{
$string = "\n45\n9867 #\nThis is a test.";
$s = new Scanner("1784a\n45\n9867 #\nThis is a test.");
$s->getAsciiAlphaNum();
$this->assertEquals($string, $s->remainingChars());
}
}

View File

@@ -0,0 +1,978 @@
<?php
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Parser\UTF8Utils;
use Masterminds\HTML5\Parser\Scanner;
use Masterminds\HTML5\Parser\Tokenizer;
class TokenizerTest extends \Masterminds\HTML5\Tests\TestCase
{
// ================================================================
// Additional assertions.
// ================================================================
/**
* Tests that an event matches both the event type and the expected value.
*
* @param string $type
* Expected event type
* @param string $expects
* The value expected in $event['data'][0]
*/
public function assertEventEquals($type, $expects, $event)
{
$this->assertEquals($type, $event['name'], "Event $type for " . print_r($event, true));
if (is_array($expects)) {
$this->assertEquals($expects, $event['data'], "Event $type should equal " . print_r($expects, true) . ': ' . print_r($event, true));
} else {
$d = (is_array($event['data']) ? $event['data'][0] : null);
$this->assertEquals($expects, $d, "Event $type should equal $expects: " . print_r($event, true));
}
}
/**
* Assert that a given event is 'error'.
*/
public function assertEventError($event)
{
$this->assertEquals('error', $event['name'], 'Expected error for event: ' . print_r($event, true));
}
/**
* Asserts that all of the tests are good.
*
* This loops through a map of tests/expectations and runs a few assertions on each test.
*
* Checks:
* - depth (if depth is > 0)
* - event name
* - matches on event 0.
*/
protected function isAllGood($name, $depth, $tests, $debug = false)
{
foreach ($tests as $try => $expects) {
if ($debug) {
fprintf(STDOUT, "%s expects %s\n", $try, print_r($expects, true));
}
$e = $this->parse($try);
if ($depth > 0) {
$this->assertEquals($depth, $e->depth(), "Expected depth $depth for test $try." . print_r($e, true));
}
$this->assertEventEquals($name, $expects, $e->get(0));
}
}
// ================================================================
// Utility functions.
// ================================================================
public function testParse()
{
list($tok, $events) = $this->createTokenizer('');
$tok->parse();
$e1 = $events->get(0);
$this->assertEquals(1, $events->Depth());
$this->assertEquals('eof', $e1['name']);
}
public function testWhitespace()
{
$spaces = ' ';
list($tok, $events) = $this->createTokenizer($spaces);
$tok->parse();
$this->assertEquals(2, $events->depth());
$e1 = $events->get(0);
$this->assertEquals('text', $e1['name']);
$this->assertEquals($spaces, $e1['data'][0]);
}
public function testCharacterReference()
{
$good = array(
'&amp;' => '&',
'&#x0003c;' => '<',
'&#38;' => '&',
'&' => '&',
);
$this->isAllGood('text', 2, $good);
// Test with broken charref
$str = '&foo';
$events = $this->parse($str);
$e1 = $events->get(0);
$this->assertEquals('error', $e1['name']);
$str = '&#xfoo';
$events = $this->parse($str);
$e1 = $events->get(0);
$this->assertEquals('error', $e1['name']);
$str = '&#foo';
$events = $this->parse($str);
$e1 = $events->get(0);
$this->assertEquals('error', $e1['name']);
// FIXME: Once the text processor is done, need to verify that the
// tokens are transformed correctly into text.
}
public function testBogusComment()
{
$bogus = array(
'</+this is a bogus comment. +>',
'<!+this is a bogus comment. !>',
'<!D OCTYPE foo bar>',
'<!DOCTYEP foo bar>',
'<![CADATA[ TEST ]]>',
'<![CDATA Hello ]]>',
'<![CDATA[ Hello [[>',
'<!CDATA[[ test ]]>',
'<![CDATA[',
'<![CDATA[hellooooo hello',
'<? Hello World ?>',
'<? Hello World',
);
foreach ($bogus as $str) {
$events = $this->parse($str);
$this->assertEventError($events->get(0));
$this->assertEventEquals('comment', $str, $events->get(1));
}
}
public function testEndTag()
{
$succeed = array(
'</a>' => 'a',
'</test>' => 'test',
'</test
>' => 'test',
'</thisIsTheTagThatDoesntEndItJustGoesOnAndOnMyFriend>' => 'thisisthetagthatdoesntenditjustgoesonandonmyfriend',
// See 8.2.4.10, which requires this and does not say error.
'</a<b>' => 'a<b',
);
$this->isAllGood('endTag', 2, $succeed);
// Recoverable failures
$fail = array(
'</a class="monkey">' => 'a',
'</a <b>' => 'a',
'</a <b <c>' => 'a',
'</a is the loneliest letter>' => 'a',
'</a' => 'a',
);
foreach ($fail as $test => $result) {
$events = $this->parse($test);
$this->assertEquals(3, $events->depth());
// Should have triggered an error.
$this->assertEventError($events->get(0));
// Should have tried to parse anyway.
$this->assertEventEquals('endTag', $result, $events->get(1));
}
// BogoComments
$comments = array(
'</>' => '</>',
'</ >' => '</ >',
'</ a>' => '</ a>',
);
foreach ($comments as $test => $result) {
$events = $this->parse($test);
$this->assertEquals(3, $events->depth());
// Should have triggered an error.
$this->assertEventError($events->get(0));
// Should have tried to parse anyway.
$this->assertEventEquals('comment', $result, $events->get(1));
}
}
public function testComment()
{
$good = array(
'<!--easy-->' => 'easy',
'<!-- 1 > 0 -->' => ' 1 > 0 ',
'<!-- --$i -->' => ' --$i ',
'<!----$i-->' => '--$i',
"<!--\nHello World.\na-->" => "\nHello World.\na",
'<!-- <!-- -->' => ' <!-- ',
);
foreach ($good as $test => $expected) {
$events = $this->parse($test);
$this->assertEventEquals('comment', $expected, $events->get(0));
}
$fail = array(
'<!-->' => '',
'<!--Hello' => 'Hello',
"<!--\0Hello" => UTF8Utils::FFFD . 'Hello',
'<!--' => '',
);
foreach ($fail as $test => $expected) {
$events = $this->parse($test);
$this->assertEquals(3, $events->depth());
$this->assertEventError($events->get(0));
$this->assertEventEquals('comment', $expected, $events->get(1));
}
}
public function testCDATASection()
{
$good = array(
'<![CDATA[ This is a test. ]]>' => ' This is a test. ',
'<![CDATA[CDATA]]>' => 'CDATA',
'<![CDATA[ ]] > ]]>' => ' ]] > ',
'<![CDATA[ ]]>' => ' ',
);
$this->isAllGood('cdata', 2, $good);
}
public function testDoctype()
{
$good = array(
'<!DOCTYPE html>' => array(
'html',
0,
null,
false,
),
'<!doctype html>' => array(
'html',
0,
null,
false,
),
'<!DocType html>' => array(
'html',
0,
null,
false,
),
"<!DOCTYPE\nhtml>" => array(
'html',
0,
null,
false,
),
"<!DOCTYPE\fhtml>" => array(
'html',
0,
null,
false,
),
'<!DOCTYPE html PUBLIC "foo bar">' => array(
'html',
EventStack::DOCTYPE_PUBLIC,
'foo bar',
false,
),
"<!DOCTYPE html PUBLIC 'foo bar'>" => array(
'html',
EventStack::DOCTYPE_PUBLIC,
'foo bar',
false,
),
'<!DOCTYPE html PUBLIC "foo bar" >' => array(
'html',
EventStack::DOCTYPE_PUBLIC,
'foo bar',
false,
),
"<!DOCTYPE html \nPUBLIC\n'foo bar'>" => array(
'html',
EventStack::DOCTYPE_PUBLIC,
'foo bar',
false,
),
'<!DOCTYPE html SYSTEM "foo bar">' => array(
'html',
EventStack::DOCTYPE_SYSTEM,
'foo bar',
false,
),
"<!DOCTYPE html SYSTEM 'foo bar'>" => array(
'html',
EventStack::DOCTYPE_SYSTEM,
'foo bar',
false,
),
'<!DOCTYPE html SYSTEM "foo/bar" >' => array(
'html',
EventStack::DOCTYPE_SYSTEM,
'foo/bar',
false,
),
"<!DOCTYPE html \nSYSTEM\n'foo bar'>" => array(
'html',
EventStack::DOCTYPE_SYSTEM,
'foo bar',
false,
),
);
$this->isAllGood('doctype', 2, $good);
$bad = array(
'<!DOCTYPE>' => array(
null,
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE >' => array(
null,
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo PUB' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo PUB>' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo PUB "Looks good">' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo SYSTME "Looks good"' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
// Can't tell whether these are ids or ID types, since the context is chopped.
'<!DOCTYPE foo PUBLIC' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo PUBLIC>' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo SYSTEM' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE foo SYSTEM>' => array(
'foo',
EventStack::DOCTYPE_NONE,
null,
true,
),
'<!DOCTYPE html SYSTEM "foo bar"' => array(
'html',
EventStack::DOCTYPE_SYSTEM,
'foo bar',
true,
),
'<!DOCTYPE html SYSTEM "foo bar" more stuff>' => array(
'html',
EventStack::DOCTYPE_SYSTEM,
'foo bar',
true,
),
);
foreach ($bad as $test => $expects) {
$events = $this->parse($test);
// fprintf(STDOUT, $test . PHP_EOL);
$this->assertEquals(3, $events->depth(), "Counting events for '$test': " . print_r($events, true));
$this->assertEventError($events->get(0));
$this->assertEventEquals('doctype', $expects, $events->get(1));
}
}
public function testProcessorInstruction()
{
$good = array(
'<?hph ?>' => 'hph',
'<?hph echo "Hello World"; ?>' => array(
'hph',
'echo "Hello World"; ',
),
"<?hph \necho 'Hello World';\n?>" => array(
'hph',
"echo 'Hello World';\n",
),
);
$this->isAllGood('pi', 2, $good);
}
/**
* This tests just simple tags.
*/
public function testSimpleTags()
{
$open = array(
'<foo>' => 'foo',
'<FOO>' => 'foo',
'<fOO>' => 'foo',
'<foo >' => 'foo',
"<foo\n\n\n\n>" => 'foo',
'<foo:bar>' => 'foo:bar',
);
$this->isAllGood('startTag', 2, $open);
$selfClose = array(
'<foo/>' => 'foo',
'<FOO/>' => 'foo',
'<foo />' => 'foo',
"<foo\n\n\n\n/>" => 'foo',
'<foo:bar/>' => 'foo:bar',
);
foreach ($selfClose as $test => $expects) {
$events = $this->parse($test);
$this->assertEquals(2, $events->depth(), "Counting events for '$test'" . print_r($events, true));
$this->assertEventEquals('startTag', $expects, $events->get(0));
$event = $events->get(0);
$this->assertTrue($event['data'][2]);
}
$bad = array(
'<foo' => 'foo',
'<foo ' => 'foo',
'<foo/' => 'foo',
'<foo /' => 'foo',
);
foreach ($bad as $test => $expects) {
$events = $this->parse($test);
$this->assertEquals(3, $events->depth(), "Counting events for '$test': " . print_r($events, true));
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', $expects, $events->get(1));
}
}
public function testTagsWithAttributeAndMissingName()
{
$cases = array(
'<id="top_featured">' => 'id',
'<color="white">' => 'color',
"<class='neaktivni_stranka'>" => 'class',
'<bgcolor="white">' => 'bgcolor',
'<class="nom">' => 'class',
);
foreach ($cases as $html => $expected) {
$events = $this->parse($html);
$this->assertEventError($events->get(0));
$this->assertEventError($events->get(1));
$this->assertEventError($events->get(2));
$this->assertEventEquals('startTag', $expected, $events->get(3));
$this->assertEventEquals('eof', null, $events->get(4));
}
}
public function testTagNotClosedAfterTagName()
{
$cases = array(
'<noscript<img>' => array(
'noscript',
'img',
),
'<center<a>' => array(
'center',
'a',
),
'<br<br>' => array(
'br',
'br',
),
);
foreach ($cases as $html => $expected) {
$events = $this->parse($html);
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', $expected[0], $events->get(1));
$this->assertEventEquals('startTag', $expected[1], $events->get(2));
$this->assertEventEquals('eof', null, $events->get(3));
}
$events = $this->parse('<span<>02</span>');
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', 'span', $events->get(1));
$this->assertEventError($events->get(2));
$this->assertEventEquals('text', '>02', $events->get(3));
$this->assertEventEquals('endTag', 'span', $events->get(4));
$this->assertEventEquals('eof', null, $events->get(5));
$events = $this->parse('<p</p>');
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', 'p', $events->get(1));
$this->assertEventEquals('endTag', 'p', $events->get(2));
$this->assertEventEquals('eof', null, $events->get(3));
$events = $this->parse('<strong><WordPress</strong>');
$this->assertEventEquals('startTag', 'strong', $events->get(0));
$this->assertEventError($events->get(1));
$this->assertEventEquals('startTag', 'wordpress', $events->get(2));
$this->assertEventEquals('endTag', 'strong', $events->get(3));
$this->assertEventEquals('eof', null, $events->get(4));
$events = $this->parse('<src=<a>');
$this->assertEventError($events->get(0));
$this->assertEventError($events->get(1));
$this->assertEventError($events->get(2));
$this->assertEventEquals('startTag', 'src', $events->get(3));
$this->assertEventEquals('startTag', 'a', $events->get(4));
$this->assertEventEquals('eof', null, $events->get(5));
$events = $this->parse('<br...<a>');
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', 'br', $events->get(1));
$this->assertEventEquals('eof', null, $events->get(2));
}
public function testIllegalTagNames()
{
$cases = array(
'<li">' => 'li',
'<p">' => 'p',
'<b&nbsp; >' => 'b',
'<static*all>' => 'static',
'<h*0720/>' => 'h',
'<st*ATTRIBUTE />' => 'st',
);
foreach ($cases as $html => $expected) {
$events = $this->parse($html);
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', $expected, $events->get(1));
}
}
public function testTagAttributes()
{
// Opening tags.
$good = array(
'<foo bar="baz">' => array(
'foo',
array(
'bar' => 'baz',
),
false,
),
'<foo bar=" baz ">' => array(
'foo',
array(
'bar' => ' baz ',
),
false,
),
"<foo bar=\"\nbaz\n\">" => array(
'foo',
array(
'bar' => "\nbaz\n",
),
false,
),
"<foo bar='baz'>" => array(
'foo',
array(
'bar' => 'baz',
),
false,
),
'<foo bar="A full sentence.">' => array(
'foo',
array(
'bar' => 'A full sentence.',
),
false,
),
"<foo a='1' b=\"2\">" => array(
'foo',
array(
'a' => '1',
'b' => '2',
),
false,
),
"<foo ns:bar='baz'>" => array(
'foo',
array(
'ns:bar' => 'baz',
),
false,
),
"<foo a='blue&red'>" => array(
'foo',
array(
'a' => 'blue&red',
),
false,
),
"<foo a='blue&amp;red'>" => array(
'foo',
array(
'a' => 'blue&red',
),
false,
),
"<foo a='blue&&amp;&red'>" => array(
'foo',
array(
'a' => 'blue&&&red',
),
false,
),
"<foo a='blue&&amp;red'>" => array(
'foo',
array(
'a' => 'blue&&red',
),
false,
),
"<foo\nbar='baz'\n>" => array(
'foo',
array(
'bar' => 'baz',
),
false,
),
'<doe a deer>' => array(
'doe',
array(
'a' => null,
'deer' => null,
),
false,
),
'<foo bar=baz>' => array(
'foo',
array(
'bar' => 'baz',
),
false,
),
// Updated for 8.1.2.3
'<foo bar = "baz" >' => array(
'foo',
array(
'bar' => 'baz',
),
false,
),
// The spec allows an unquoted value '/'. This will not be a closing
// tag.
'<foo bar=/>' => array(
'foo',
array(
'bar' => '/',
),
false,
),
'<foo bar=baz/>' => array(
'foo',
array(
'bar' => 'baz/',
),
false,
),
);
$this->isAllGood('startTag', 2, $good);
// Self-closing tags.
$withEnd = array(
'<foo bar="baz"/>' => array(
'foo',
array(
'bar' => 'baz',
),
true,
),
'<foo BAR="baz"/>' => array(
'foo',
array(
'bar' => 'baz',
),
true,
),
'<foo BAR="BAZ"/>' => array(
'foo',
array(
'bar' => 'BAZ',
),
true,
),
"<foo a='1' b=\"2\" c=3 d/>" => array(
'foo',
array(
'a' => '1',
'b' => '2',
'c' => '3',
'd' => null,
),
true,
),
);
$this->isAllGood('startTag', 2, $withEnd);
// Cause a parse error.
$bad = array(
// This will emit an entity lookup failure for &+dark.
"<foo a='blue&+dark'>" => array(
'foo',
array(
'a' => 'blue&+dark',
),
false,
),
'<foo bar=>' => array(
'foo',
array(
'bar' => null,
),
false,
),
'<foo bar="oh' => array(
'foo',
array(
'bar' => 'oh',
),
false,
),
'<foo bar=oh">' => array(
'foo',
array(
'bar' => 'oh"',
),
false,
),
// these attributes are ignored because of current implementation
// of method "DOMElement::setAttribute"
// see issue #23: https://github.com/Masterminds/html5-php/issues/23
'<foo b"="baz">' => array(
'foo',
array(),
false,
),
'<foo 2abc="baz">' => array(
'foo',
array(),
false,
),
'<foo ?="baz">' => array(
'foo',
array(),
false,
),
'<foo foo?bar="baz">' => array(
'foo',
array(),
false,
),
)
;
foreach ($bad as $test => $expects) {
$events = $this->parse($test);
$this->assertEquals(3, $events->depth(), "Counting events for '$test': " . print_r($events, true));
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', $expects, $events->get(1));
}
// Cause multiple parse errors.
$reallyBad = array(
'<foo ="bar">' => array(
'foo',
array(
'=' => null,
'"bar"' => null,
),
false,
),
'<foo////>' => array(
'foo',
array(),
true,
),
// character "&" in unquoted attribute shouldn't cause an infinite loop
'<foo bar=index.php?str=1&amp;id=29>' => array(
'foo',
array(
'bar' => 'index.php?str=1&id=29',
),
false,
),
);
foreach ($reallyBad as $test => $expects) {
$events = $this->parse($test);
// fprintf(STDOUT, $test . print_r($events, true));
$this->assertEventError($events->get(0));
$this->assertEventError($events->get(1));
// $this->assertEventEquals('startTag', $expects, $events->get(1));
}
// Regression: Malformed elements should be detected.
// '<foo baz="1" <bar></foo>' => array('foo', array('baz' => '1'), false),
$events = $this->parse('<foo baz="1" <bar></foo>');
$this->assertEventError($events->get(0));
$this->assertEventEquals('startTag', array(
'foo',
array(
'baz' => '1',
),
false,
), $events->get(1));
$this->assertEventEquals('startTag', array(
'bar',
array(),
false,
), $events->get(2));
$this->assertEventEquals('endTag', array(
'foo',
), $events->get(3));
}
public function testRawText()
{
$good = array(
'<script>abcd efg hijk lmnop</script> ' => 'abcd efg hijk lmnop',
'<script><not/><the/><tag></script>' => '<not/><the/><tag>',
'<script><<<<<<<<</script>' => '<<<<<<<<',
'<script>hello</script</script>' => 'hello</script',
"<script>\nhello</script\n</script>" => "\nhello</script\n",
'<script>&amp;</script>' => '&amp;',
'<script><!--not a comment--></script>' => '<!--not a comment-->',
'<script><![CDATA[not a comment]]></script>' => '<![CDATA[not a comment]]>',
);
foreach ($good as $test => $expects) {
$events = $this->parse($test);
$this->assertEventEquals('startTag', 'script', $events->get(0));
$this->assertEventEquals('text', $expects, $events->get(1));
$this->assertEventEquals('endTag', 'script', $events->get(2));
}
$bad = array(
'<script>&amp;</script' => '&amp;</script',
'<script>Hello world' => 'Hello world',
);
foreach ($bad as $test => $expects) {
$events = $this->parse($test);
$this->assertEquals(4, $events->depth(), "Counting events for '$test': " . print_r($events, true));
$this->assertEventEquals('startTag', 'script', $events->get(0));
$this->assertEventError($events->get(1));
$this->assertEventEquals('text', $expects, $events->get(2));
}
// Testing case sensitivity
$events = $this->parse('<TITLE>a test</TITLE>');
$this->assertEventEquals('startTag', 'title', $events->get(0));
$this->assertEventEquals('text', 'a test', $events->get(1));
$this->assertEventEquals('endTag', 'title', $events->get(2));
// Testing end tags with whitespaces
$events = $this->parse('<title>Whitespaces are tasty</title >');
$this->assertEventEquals('startTag', 'title', $events->get(0));
$this->assertEventEquals('text', 'Whitespaces are tasty', $events->get(1));
$this->assertEventEquals('endTag', 'title', $events->get(2));
}
public function testRcdata()
{
list($tok, $events) = $this->createTokenizer('<title>&#x27;<!-- not a comment --></TITLE>');
$tok->setTextMode(\Masterminds\HTML5\Elements::TEXT_RCDATA, 'title');
$tok->parse();
$this->assertEventEquals('text', "'<!-- not a comment -->", $events->get(1));
}
public function testText()
{
$events = $this->parse('a<br>b');
$this->assertEquals(4, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('text', 'a', $events->get(0));
$this->assertEventEquals('startTag', 'br', $events->get(1));
$this->assertEventEquals('text', 'b', $events->get(2));
$events = $this->parse('<a>Test</a>');
$this->assertEquals(4, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('startTag', 'a', $events->get(0));
$this->assertEventEquals('text', 'Test', $events->get(1));
$this->assertEventEquals('endTag', 'a', $events->get(2));
$events = $this->parse('<p>0</p><p>1</p>');
$this->assertEquals(7, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('startTag', 'p', $events->get(0));
$this->assertEventEquals('text', '0', $events->get(1));
$this->assertEventEquals('endTag', 'p', $events->get(2));
$this->assertEventEquals('startTag', 'p', $events->get(3));
$this->assertEventEquals('text', '1', $events->get(4));
$this->assertEventEquals('endTag', 'p', $events->get(5));
$events = $this->parse('a<![CDATA[test]]>b');
$this->assertEquals(4, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('text', 'a', $events->get(0));
$this->assertEventEquals('cdata', 'test', $events->get(1));
$this->assertEventEquals('text', 'b', $events->get(2));
$events = $this->parse('a<!--test-->b');
$this->assertEquals(4, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('text', 'a', $events->get(0));
$this->assertEventEquals('comment', 'test', $events->get(1));
$this->assertEventEquals('text', 'b', $events->get(2));
$events = $this->parse('a&amp;b');
$this->assertEquals(2, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('text', 'a&b', $events->get(0));
$events = $this->parse('a&sup2;b');
$this->assertEquals(2, $events->depth(), 'Events: ' . print_r($events, true));
$this->assertEventEquals('text', 'a²b', $events->get(0));
}
// ================================================================
// Utility functions.
// ================================================================
protected function createTokenizer($string, $debug = false)
{
$eventHandler = new EventStack();
$scanner = new Scanner($string);
$scanner->debug = $debug;
return array(
new Tokenizer($scanner, $eventHandler),
$eventHandler,
);
}
public function parse($string, $debug = false)
{
list($tok, $events) = $this->createTokenizer($string, $debug);
$tok->parse();
return $events;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* @file
* Test the Tree Builder's special-case rules.
*/
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Parser\TreeBuildingRules;
use Masterminds\HTML5\Parser\Tokenizer;
use Masterminds\HTML5\Parser\Scanner;
use Masterminds\HTML5\Parser\DOMTreeBuilder;
/**
* These tests are functional, not necessarily unit tests.
*/
class TreeBuildingRulesTest extends \Masterminds\HTML5\Tests\TestCase
{
const HTML_STUB = '<!DOCTYPE html><html><head><title>test</title></head><body>%s</body></html>';
/**
* Convenience function for parsing.
*/
protected function parse($string)
{
$treeBuilder = new DOMTreeBuilder();
$scanner = new Scanner($string);
$parser = new Tokenizer($scanner, $treeBuilder);
$parser->parse();
return $treeBuilder->document();
}
/**
* Convenience function for parsing fragments.
*/
protected function parseFragment($string)
{
$events = new DOMTreeBuilder(true);
$scanner = new Scanner($string);
$parser = new Tokenizer($scanner, $events);
$parser->parse();
return $events->fragment();
}
public function testTDFragment()
{
$frag = $this->parseFragment('<td>This is a test of the HTML5 parser</td>');
$td = $frag->childNodes->item(0);
$this->assertEquals(1, $frag->childNodes->length);
$this->assertEquals('td', $td->tagName);
$this->assertEquals('This is a test of the HTML5 parser', $td->nodeValue);
}
public function testHasRules()
{
$doc = new \DOMDocument('1.0');
$engine = new TreeBuildingRules($doc);
$this->assertTrue($engine->hasRules('li'));
$this->assertFalse($engine->hasRules('imaginary'));
}
public function testHandleLI()
{
$html = sprintf(self::HTML_STUB, '<ul id="a"><li>test<li>test2</ul><a></a>');
$doc = $this->parse($html);
$list = $doc->getElementById('a');
$this->assertEquals(2, $list->childNodes->length);
foreach ($list->childNodes as $ele) {
$this->assertEquals('li', $ele->tagName);
}
}
public function testHandleDT()
{
$html = sprintf(self::HTML_STUB, '<dl id="a"><dt>Hello<dd>Hi</dl><a></a>');
$doc = $this->parse($html);
$list = $doc->getElementById('a');
$this->assertEquals(2, $list->childNodes->length);
$this->assertEquals('dt', $list->firstChild->tagName);
$this->assertEquals('dd', $list->lastChild->tagName);
}
public function testHandleOptionGroupAndOption()
{
$html = sprintf(self::HTML_STUB, '<optgroup id="foo" label="foo" ><option value="foo">bar</option></optgroup>');
$doc = $this->parse($html);
$list = $doc->getElementById('foo');
$this->assertEquals(1, $list->childNodes->length);
$option = $list->childNodes->item(0);
$this->assertEquals('option', $option->tagName);
}
public function testTable()
{
$html = sprintf(self::HTML_STUB, '<table><thead id="a"><th>foo<td>bar<td>baz');
$doc = $this->parse($html);
$list = $doc->getElementById('a');
$this->assertEquals(3, $list->childNodes->length);
$this->assertEquals('th', $list->firstChild->tagName);
$this->assertEquals('td', $list->lastChild->tagName);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Masterminds\HTML5\Tests\Parser;
use Masterminds\HTML5\Parser\UTF8Utils;
class UTF8UtilsTest extends \Masterminds\HTML5\Tests\TestCase
{
public function testConvertToUTF8()
{
$out = UTF8Utils::convertToUTF8('éàa', 'ISO-8859-1');
$this->assertEquals('éàa', $out);
}
/**
* @todo add tests for invalid codepoints
*/
public function testCheckForIllegalCodepoints()
{
$smoke = 'Smoke test';
$err = UTF8Utils::checkForIllegalCodepoints($smoke);
$this->assertEmpty($err);
$data = "Foo Bar \0 Baz";
$errors = UTF8Utils::checkForIllegalCodepoints($data);
$this->assertContains('null-character', $errors);
}
}

View File

@@ -0,0 +1,652 @@
<?php
namespace Masterminds\HTML5\Tests\Serializer;
use Masterminds\HTML5\Serializer\OutputRules;
use Masterminds\HTML5\Serializer\Traverser;
use Masterminds\HTML5;
class OutputRulesTest extends \Masterminds\HTML5\Tests\TestCase
{
protected $markup = '<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test</title>
</head>
<body>
<p>This is a test.</p>
</body>
</html>';
/**
* @var HTML5
*/
protected $html5;
public function setUp()
{
$this->html5 = $this->getInstance();
}
/**
* Using reflection we make a protected method accessible for testing.
*
* @param string $name
* The name of the method on the Traverser class to test
*
* @return \ReflectionMethod for the specified method
*/
public function getProtectedMethod($name)
{
$class = new \ReflectionClass('\Masterminds\HTML5\Serializer\OutputRules');
$method = $class->getMethod($name);
$method->setAccessible(true);
return $method;
}
public function getTraverserProtectedProperty($name)
{
$class = new \ReflectionClass('\Masterminds\HTML5\Serializer\Traverser');
$property = $class->getProperty($name);
$property->setAccessible(true);
return $property;
}
public function getOutputRules($options = array())
{
$options = $options + $this->html5->getOptions();
$stream = fopen('php://temp', 'w');
$dom = $this->html5->loadHTML($this->markup);
$r = new OutputRules($stream, $options);
$t = new Traverser($dom, $stream, $r, $options);
return array(
$r,
$stream,
);
}
public function testDocument()
{
$dom = $this->html5->loadHTML('<!doctype html><html lang="en"><body>foo</body></html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$r->document($dom);
$expected = '<!DOCTYPE html>' . PHP_EOL . '<html lang="en"><body>foo</body></html>' . PHP_EOL;
$this->assertEquals($expected, stream_get_contents($stream, -1, 0));
}
public function testEmptyDocument()
{
$dom = $this->html5->loadHTML('');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$r->document($dom);
$expected = '<!DOCTYPE html>' . PHP_EOL;
$this->assertEquals($expected, stream_get_contents($stream, -1, 0));
}
public function testDoctype()
{
$dom = $this->html5->loadHTML('<!doctype html><html lang="en"><body>foo</body></html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$m = $this->getProtectedMethod('doctype');
$m->invoke($r, 'foo');
$this->assertEquals('<!DOCTYPE html>' . PHP_EOL, stream_get_contents($stream, -1, 0));
}
public function testElement()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz">foo bar baz</div>
<svg width="150" height="100" viewBox="0 0 3 2">
<rect width="1" height="2" x="0" fill="#008d46" />
<rect width="1" height="2" x="1" fill="#ffffff" />
<rect width="1" height="2" x="2" fill="#d2232c" />
</svg>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$r->element($list->item(0));
$this->assertEquals('<div id="foo" class="bar baz">foo bar baz</div>', stream_get_contents($stream, -1, 0));
}
public function testSerializeWithNamespaces()
{
$this->html5 = $this->getInstance(array(
'xmlNamespaces' => true,
));
$source = '
<!DOCTYPE html>
<html><body id="body" xmlns:x="http://www.prefixed.com">
<a id="bar1" xmlns="http://www.prefixed.com/bar1">
<b id="bar4" xmlns="http://www.prefixed.com/bar4"><x:prefixed id="prefixed">xy</x:prefixed></b>
</a>
<svg id="svg">svg</svg>
<c id="bar2" xmlns="http://www.prefixed.com/bar2"></c>
<div id="div"></div>
<d id="bar3"></d>
<xn:d id="bar5" xmlns:xn="http://www.prefixed.com/xn" xmlns="http://www.prefixed.com/bar5_x"><x id="bar5_x">y</x></xn:d>
</body>
</html>';
$dom = $this->html5->loadHTML($source, array(
'xmlNamespaces' => true,
));
$this->assertFalse($this->html5->hasErrors(), print_r($this->html5->getErrors(), 1));
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$t->walk();
$rendered = stream_get_contents($stream, -1, 0);
$clear = function ($s) {
return trim(preg_replace('/[\s]+/', ' ', $s));
};
$this->assertEquals($clear($source), $clear($rendered));
}
public function testElementWithScript()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<head>
<script>
var $jQ = jQuery.noConflict();
// Use jQuery via $jQ(...)
$jQ(document).ready(function () {
$jQ("#mktFrmSubmit").wrap("<div class=\'buttonSubmit\'></div>");
$jQ(".buttonSubmit").prepend("<span></span>");
});
</script>
</head>
<body>
<div id="foo" class="bar baz">foo bar baz</div>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$script = $dom->getElementsByTagName('script');
$r->element($script->item(0));
$this->assertEquals(
'<script>
var $jQ = jQuery.noConflict();
// Use jQuery via $jQ(...)
$jQ(document).ready(function () {
$jQ("#mktFrmSubmit").wrap("<div class=\'buttonSubmit\'></div>");
$jQ(".buttonSubmit").prepend("<span></span>");
});
</script>', stream_get_contents($stream, -1, 0));
}
public function testElementWithStyle()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<head>
<style>
body > .bar {
display: none;
}
</style>
</head>
<body>
<div id="foo" class="bar baz">foo bar baz</div>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$style = $dom->getElementsByTagName('style');
$r->element($style->item(0));
$this->assertEquals('<style>
body > .bar {
display: none;
}
</style>', stream_get_contents($stream, -1, 0));
}
public function testOpenTag()
{
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz">foo bar baz</div>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$m = $this->getProtectedMethod('openTag');
$m->invoke($r, $list->item(0));
$this->assertEquals('<div id="foo" class="bar baz">', stream_get_contents($stream, -1, 0));
}
public function testCData()
{
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<body>
<div><![CDATA[bar]]></div>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$r->cdata($list->item(0)->childNodes->item(0));
$this->assertEquals('<![CDATA[bar]]>', stream_get_contents($stream, -1, 0));
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<body>
<div id="foo"></div>
</body>
</html>');
$dom->getElementById('foo')->appendChild(new \DOMCdataSection(']]>Foo<[![CDATA test ]]>'));
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$r->cdata($list->item(0)->childNodes->item(0));
$this->assertEquals('<![CDATA[]]]]><![CDATA[>Foo<[![CDATA test ]]]]><![CDATA[>]]>', stream_get_contents($stream, -1, 0));
}
public function testComment()
{
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<body>
<div><!-- foo --></div>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$r->comment($list->item(0)->childNodes->item(0));
$this->assertEquals('<!-- foo -->', stream_get_contents($stream, -1, 0));
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<body>
<div id="foo"></div>
</body>
</html>');
$dom->getElementById('foo')->appendChild(new \DOMComment('<!-- --> --> Foo -->'));
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$r->comment($list->item(0)->childNodes->item(0));
// Could not find more definitive guidelines on what this should be. Went with
// what the HTML5 spec says and what \DOMDocument::saveXML() produces.
$this->assertEquals('<!--<!-- --> --> Foo -->-->', stream_get_contents($stream, -1, 0));
}
public function testText()
{
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<head>
<script>baz();</script>
</head>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('script');
$r->text($list->item(0)->childNodes->item(0));
$this->assertEquals('baz();', stream_get_contents($stream, -1, 0));
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<head id="foo"></head>
</html>');
$foo = $dom->getElementById('foo');
$foo->appendChild(new \DOMText('<script>alert("hi");</script>'));
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$r->text($foo->firstChild);
$this->assertEquals('&lt;script&gt;alert("hi");&lt;/script&gt;', stream_get_contents($stream, -1, 0));
}
public function testNl()
{
list($o, $s) = $this->getOutputRules();
$m = $this->getProtectedMethod('nl');
$m->invoke($o);
$this->assertEquals(PHP_EOL, stream_get_contents($s, -1, 0));
}
public function testWr()
{
list($o, $s) = $this->getOutputRules();
$m = $this->getProtectedMethod('wr');
$m->invoke($o, 'foo');
$this->assertEquals('foo', stream_get_contents($s, -1, 0));
}
public function getEncData()
{
return array(
array(
false,
'&\'<>"',
'&amp;\'&lt;&gt;"',
'&amp;&apos;&lt;&gt;&quot;',
),
array(
false,
'This + is. a < test',
'This + is. a &lt; test',
'This &plus; is&period; a &lt; test',
),
array(
false,
'.+#',
'.+#',
'&period;&plus;&num;',
),
array(
true,
'.+#\'',
'.+#\'',
'&period;&plus;&num;&apos;',
),
array(
true,
'&".<',
'&amp;&quot;.<',
'&amp;&quot;&period;&lt;',
),
array(
true,
'&\'<>"',
'&amp;\'<>&quot;',
'&amp;&apos;&lt;&gt;&quot;',
),
array(
true,
"\xc2\xa0\"'",
'&nbsp;&quot;\'',
'&nbsp;&quot;&apos;',
),
);
}
/**
* Test basic encoding of text.
*
* @dataProvider getEncData
*/
public function testEnc($isAttribute, $test, $expected, $expectedEncoded)
{
list($o, $s) = $this->getOutputRules();
$m = $this->getProtectedMethod('enc');
$this->assertEquals($expected, $m->invoke($o, $test, $isAttribute));
list($o, $s) = $this->getOutputRules(array(
'encode_entities' => true,
));
$m = $this->getProtectedMethod('enc');
$this->assertEquals($expectedEncoded, $m->invoke($o, $test, $isAttribute));
}
/**
* Test basic encoding of text.
*
* @dataProvider getEncData
*/
public function testEscape($isAttribute, $test, $expected, $expectedEncoded)
{
list($o, $s) = $this->getOutputRules();
$m = $this->getProtectedMethod('escape');
$this->assertEquals($expected, $m->invoke($o, $test, $isAttribute));
}
public function booleanAttributes()
{
return array(
array('<img alt="" ismap>'),
array('<img alt="">'),
array('<input type="radio" readonly>'),
array('<input type="radio" checked disabled>'),
array('<input type="checkbox" checked disabled>'),
array('<input type="radio" value="" checked disabled>'),
array('<div data-value=""></div>'),
array('<select disabled></select>'),
array('<div ng-app></div>'),
array('<script defer></script>'),
);
}
/**
* @dataProvider booleanAttributes
*/
public function testBooleanAttrs($html)
{
$dom = $this->html5->loadHTML('<!doctype html><html lang="en"><body>' . $html . '</body></html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$node = $dom->getElementsByTagName('body')->item(0)->firstChild;
$m = $this->getProtectedMethod('attrs');
$m->invoke($r, $node);
$content = stream_get_contents($stream, -1, 0);
$html = preg_replace('~<[a-z]+(.*)></[a-z]+>~', '\1', $html);
$html = preg_replace('~<[a-z]+(.*)/?>~', '\1', $html);
$this->assertEquals($content, $html);
}
public function testAttrs()
{
$dom = $this->html5->loadHTML('<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz">foo bar baz</div>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('div');
$m = $this->getProtectedMethod('attrs');
$m->invoke($r, $list->item(0));
$content = stream_get_contents($stream, -1, 0);
$this->assertEquals(' id="foo" class="bar baz"', $content);
}
public function testSvg()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz">foo bar baz</div>
<svg width="150" height="100" viewBox="0 0 3 2">
<rect width="1" height="2" x="0" fill="#008d46" />
<rect width="1" height="2" x="1" fill="#ffffff" />
<rect width="1" height="2" x="2" fill="#d2232c" />
<rect id="Bar" x="300" y="100" width="300" height="100" fill="rgb(255,255,0)">
<animate attributeName="x" attributeType="XML" begin="0s" dur="9s" fill="freeze" from="300" to="0" />
</rect>
</svg>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('svg');
$r->element($list->item(0));
$contents = stream_get_contents($stream, -1, 0);
$this->assertRegExp('|<svg width="150" height="100" viewBox="0 0 3 2">|', $contents);
$this->assertRegExp('|<rect width="1" height="2" x="0" fill="#008d46" />|', $contents);
$this->assertRegExp('|<rect id="Bar" x="300" y="100" width="300" height="100" fill="rgb\(255,255,0\)">|', $contents);
}
public function testMath()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<body>
<div id="foo" class="bar baz">foo bar baz</div>
<math>
<mi>x</mi>
<csymbol definitionURL="http://www.example.com/mathops/multiops.html#plusminus">
<mo>&PlusMinus;</mo>
</csymbol>
<mi>y</mi>
</math>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('math');
$r->element($list->item(0));
$content = stream_get_contents($stream, -1, 0);
$this->assertRegExp('|<math>|', $content);
$this->assertRegExp('|<csymbol definitionURL="http://www.example.com/mathops/multiops.html#plusminus">|', $content);
}
public function testProcessorInstruction()
{
$dom = $this->html5->loadHTMLFragment('<?foo bar ?>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$r->processorInstruction($dom->firstChild);
$content = stream_get_contents($stream, -1, 0);
$this->assertRegExp('|<\?foo bar \?>|', $content);
}
public function testAddressTag()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en">
<body>
<address>
<a href="../People/Raggett/">Dave Raggett</a>,
<a href="../People/Arnaud/">Arnaud Le Hors</a>,
contact persons for the <a href="Activity">W3C HTML Activity</a>
</address>
</body>
</html>');
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$list = $dom->getElementsByTagName('address');
$r->element($list->item(0));
$contents = stream_get_contents($stream, -1, 0);
$this->assertRegExp('|<address>|', $contents);
$this->assertRegExp('|<a href="../People/Raggett/">Dave Raggett</a>,|', $contents);
$this->assertRegExp('|<a href="../People/Arnaud/">Arnaud Le Hors</a>,|', $contents);
$this->assertRegExp('|contact persons for the <a href="Activity">W3C HTML Activity</a>|', $contents);
$this->assertRegExp('|</address>|', $contents);
}
/**
* Ensure direct DOM manipulation doesn't break TEXT_RAW elements (iframe, script, etc...).
*/
public function testHandlingInvalidRawContent()
{
$dom = $this->html5->loadHTML(
'<!doctype html>
<html lang="en" id="base">
<body>
<script id="template" type="x-tmpl-mustache">
<h1>Hello!</h1>
</script>
</body>
</html>');
$badNode = $dom->createElement('p', 'Bar');
// modify the content of the TEXT_RAW element: <script id="template"> appending dom nodes
$styleElement = $dom->getElementById('template');
$styleElement->appendChild($badNode);
$contents = $this->html5->saveHTML($dom);
$this->assertTrue(false !== strpos($contents, '<script id="template" type="x-tmpl-mustache">
<h1>Hello!</h1>
<p>Bar</p></script>'));
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Masterminds\HTML5\Tests\Serializer;
use Masterminds\HTML5\Serializer\OutputRules;
use Masterminds\HTML5\Serializer\Traverser;
class TraverserTest extends \Masterminds\HTML5\Tests\TestCase
{
protected $markup = '<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Test</title>
</head>
<body>
<p>This is a test.</p>
</body>
</html>';
public function setUp()
{
$this->html5 = $this->getInstance();
}
/**
* Using reflection we make a protected method accessible for testing.
*
* @param string $name
* The name of the method on the Traverser class to test
*
* @return \ReflectionMethod \ReflectionMethod for the specified method
*/
public function getProtectedMethod($name)
{
$class = new \ReflectionClass('\Masterminds\HTML5\Serializer\Traverser');
$method = $class->getMethod($name);
$method->setAccessible(true);
return $method;
}
public function getTraverser()
{
$stream = fopen('php://temp', 'w');
$dom = $this->html5->loadHTML($this->markup);
$t = new Traverser($dom, $stream, $html5->getOptions());
// We return both the traverser and stream so we can pull from it.
return array(
$t,
$stream,
);
}
public function testConstruct()
{
// The traverser needs a place to write the output to. In our case we
// use a stream in temp space.
$stream = fopen('php://temp', 'w');
$html5 = $this->getInstance();
$r = new OutputRules($stream, $this->html5->getOptions());
$dom = $this->html5->loadHTML($this->markup);
$t = new Traverser($dom, $stream, $r, $html5->getOptions());
$this->assertInstanceOf('\Masterminds\HTML5\Serializer\Traverser', $t);
}
public function testFragmentDeprecated()
{
$html = '<span class="bar">foo</span><span></span><div>bar</div>';
$input = new \Masterminds\HTML5\Parser\StringInputStream($html);
$dom = $this->html5->parseFragment($input);
$this->assertInstanceOf('\DOMDocumentFragment', $dom);
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$t->walk();
$this->assertEquals($html, stream_get_contents($stream, -1, 0));
}
public function testFragment()
{
$html = '<span class="bar">foo</span><span></span><div>bar</div>';
$dom = $this->html5->parseFragment($html);
$this->assertInstanceOf('\DOMDocumentFragment', $dom);
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$t->walk();
$this->assertEquals($html, stream_get_contents($stream, -1, 0));
}
public function testProcessorInstructionDeprecated()
{
$html = '<?foo bar ?>';
$input = new \Masterminds\HTML5\Parser\StringInputStream($html);
$dom = $this->html5->parseFragment($input);
$this->assertInstanceOf('\DOMDocumentFragment', $dom);
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$t->walk();
$this->assertEquals($html, stream_get_contents($stream, -1, 0));
}
public function testProcessorInstruction()
{
$html = '<?foo bar ?>';
$dom = $this->html5->parseFragment($html);
$this->assertInstanceOf('\DOMDocumentFragment', $dom);
$stream = fopen('php://temp', 'w');
$r = new OutputRules($stream, $this->html5->getOptions());
$t = new Traverser($dom, $stream, $r, $this->html5->getOptions());
$t->walk();
$this->assertEquals($html, stream_get_contents($stream, -1, 0));
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Masterminds\HTML5\Tests;
use Masterminds\HTML5;
use PHPUnit\Framework\TestCase as BaseTestCase;
class TestCase extends BaseTestCase
{
const DOC_OPEN = '<!DOCTYPE html><html><head><title>test</title></head><body>';
const DOC_CLOSE = '</body></html>';
public function getInstance(array $options = array())
{
return new HTML5($options);
}
protected function wrap($fragment)
{
return self::DOC_OPEN . $fragment . self::DOC_CLOSE;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
<?php
require __DIR__ . '/../../vendor/autoload.php';
$iterations = isset($argv[1]) ? $argv[1] : 100;
$html5 = new Masterminds\HTML5();
$content = file_get_contents(__DIR__ . '/example.html');
$dom = $html5->loadHTML($content);
$samples = array();
for ($i = 0; $i < $iterations; ++$i) {
$t = microtime(true);
$dom = $html5->loadHTML($content);
$samples[] = microtime(true) - $t;
}
$time = array_sum($samples) / count($samples);
echo 'Loading: ' . ($time * 1000) . "\n";
$samples = array();
for ($i = 0; $i < $iterations; ++$i) {
$t = microtime(true);
$html5->saveHTML($dom);
$samples[] = microtime(true) - $t;
}
$time = array_sum($samples) / count($samples);
echo 'Writing: ' . ($time * 1000) . "\n";
exit(0);