diff --git a/_config/config.yml b/_config/config.yml index 8b91fc39..8e169ffa 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -4,3 +4,6 @@ LeftAndMain: Security: extensions: - ErrorPageControllerExtension +HtmlEditorField: + extensions: + - SiteTreeLinkTracking_Highlighter diff --git a/code/model/SiteTreeLinkTracking.php b/code/model/SiteTreeLinkTracking.php index da661517..5286e572 100644 --- a/code/model/SiteTreeLinkTracking.php +++ b/code/model/SiteTreeLinkTracking.php @@ -1,15 +1,27 @@ '%$SiteTreeLinkTracking_Parser' + ); + private static $db = array( "HasBrokenFile" => "Boolean", "HasBrokenLink" => "Boolean" @@ -32,38 +44,32 @@ class SiteTreeLinkTracking extends DataExtension { $linkedFiles = array(); $htmlValue = Injector::inst()->create('HTMLValue', $record->$field); + $links = $this->parser->process($htmlValue); // Populate link tracking for internal links & links to asset files. - if($links = $htmlValue->getElementsByTagName('a')) foreach($links as $link) { - $href = Director::makeRelative($link->getAttribute('href')); - - if($href) { - if(preg_match('/\[(sitetree|file)_link[,\s]id=([0-9]+)\]/i', $href, $matches)) { - $type = $matches[1]; - $id = $matches[2]; - - if($type === 'sitetree') { - if(SiteTree::get()->byID($id)) { - $linkedPages[] = $id; - } else { - $record->HasBrokenLink = true; - } - } else if($type === 'file') { - if(File::get()->byID($id)) { - $linkedFiles[] = $id; - } else { - $record->HasBrokenFile = true; - } + foreach ($links as $link) { + switch ($link['Type']) { + case 'sitetree': + if ($link['Broken']) { + $record->HasBrokenLink = true; + } else { + $linkedPages[] = $link['Target']; } - } else if($href == '' || $href[0] == '/') { - $record->HasBrokenLink = true; - } else if(stristr($href, '#')) { - // Deals-to broken anchors (Links with no anchor) - $find = preg_replace("/^(.+)?#(.+)+$/","$2", $href); - if(!preg_match("#(name|id)=\"{$find}\"#", $record->$field)) { + break; + + case 'file': + if ($link['Broken']) { + $record->HasBrokenFile = true; + } else { + $linkedFiles[] = $link['Target']; + } + break; + + default: + if ($link['Broken']) { $record->HasBrokenLink = true; } - } + break; } } @@ -71,8 +77,7 @@ class SiteTreeLinkTracking extends DataExtension { if($images = $htmlValue->getElementsByTagName('img')) foreach($images as $img) { if($image = File::find($path = urldecode(Director::makeRelative($img->getAttribute('src'))))) { $linkedFiles[] = $image->ID; - } - else { + } else { if(substr($path, 0, strlen(ASSETS_DIR) + 1) == ASSETS_DIR . '/') { $record->HasBrokenFile = true; } @@ -108,7 +113,6 @@ class SiteTreeLinkTracking extends DataExtension { } } - function augmentSyncLinkTracking() { // Reset boolean broken flags $this->owner->HasBrokenLink = false; @@ -128,4 +132,150 @@ class SiteTreeLinkTracking extends DataExtension { foreach($htmlFields as $field) $this->trackLinksInField($field); } -} \ No newline at end of file +} + +/** + * Extension for enabling highlighting of broken links in the HtmlEditorFields. + */ +class SiteTreeLinkTracking_Highlighter extends Extension { + + public $parser; + + private static $dependencies = array( + 'parser' => '%$SiteTreeLinkTracking_Parser' + ); + + /** + * Adds an ability to highlight broken links in the content. + * It reuses the parser the SiteTreeLinkTracking uses for maintaining the references and the "broken" flags + * to make sure all pages listed in the BrokenLinkChecker highlight these in their content. + */ + public function onBeforeRender($field) { + // Handle situation when the field has been customised, i.e. via $properties on the HtmlEditorField::Field call. + $obj = $this->owner->getCustomisedObj() ?: $this->owner; + $value = $obj->value; + + // Parse the text as DOM. + $htmlValue = Injector::inst()->create('HTMLValue', $value); + $links = $this->parser->process($htmlValue); + + foreach ($links as $link) { + $classStr = $link['DOMReference']->getAttribute('class'); + $classes = explode(' ', $classStr); + + // Add or remove the broken class from the link, depending on the link status. + if ($link['Broken']) { + $classes = array_unique(array_merge($classes, array('ss-broken'))); + } else { + $classes = array_diff($classes, array('ss-broken')); + } + $link['DOMReference']->setAttribute('class', implode(' ', $classes)); + } + + $obj->customise(array( + 'Value' => htmlentities($htmlValue->getContent(), ENT_COMPAT, 'UTF-8') + )); + } + +} + +/** + * A helper object for extracting information about links. + */ +class SiteTreeLinkTracking_Parser { + + /** + * Finds the links that are of interest for the link tracking automation. Checks for brokenness and attaches + * extracted metadata so consumers can decide what to do with the DOM element (provided as DOMReference). + * + * @param SS_HTMLValue $htmlValue Object to parse the links from. + * @return array Associative array containing found links with the following field layout: + * Type: string, name of the link type + * Target: any, a reference to the target object, depends on the Type + * Anchor: string, anchor part of the link + * DOMReference: DOMElement, reference to the link to apply changes. + * Broken: boolean, a flag highlighting whether the link should be treated as broken. + */ + public function process(SS_HTMLValue $htmlValue) { + $results = array(); + + $links = $htmlValue->getElementsByTagName('a'); + if(!$links) return $results; + + foreach($links as $link) { + if (!$link->hasAttribute('href')) continue; + + $href = Director::makeRelative($link->getAttribute('href')); + + // Definitely broken links. + if($href == '' || $href[0] == '/') { + $results[] = array( + 'Type' => 'broken', + 'Target' => null, + 'Anchor' => null, + 'DOMReference' => $link, + 'Broken' => true + ); + + continue; + } + + // Link to a page on this site. + $matches = array(); + if(preg_match('/\[sitetree_link(?:\s*|%20|,)?id=([0-9]+)\](#(.*))?/i', $href, $matches)) { + $page = DataObject::get_by_id('SiteTree', $matches[1]); + if (!$page) { + // Page doesn't exist. + $broken = true; + } else if (!empty($matches[3]) && !preg_match("/(name|id)=\"{$matches[3]}\"/", $page->Content)) { + // Broken anchor on the target page. + $broken = true; + } else { + $broken = false; + } + + $results[] = array( + 'Type' => 'sitetree', + 'Target' => $matches[1], + 'Anchor' => empty($matches[3]) ? null : $matches[3], + 'DOMReference' => $link, + 'Broken' => $broken + ); + + continue; + } + + // Link to a file on this site. + $matches = array(); + if(preg_match('/\[file_link(?:\s*|%20|,)?id=([0-9]+)\]/i', $href, $matches)) { + $results[] = array( + 'Type' => 'file', + 'Target' => $matches[1], + 'Anchor' => null, + 'DOMReference' => $link, + 'Broken' => !DataObject::get_by_id('File', $matches[1]) + ); + + continue; + } + + // Local anchor. + $matches = array(); + if(preg_match('/^#(.*)/i', $href, $matches)) { + $results[] = array( + 'Type' => 'localanchor', + 'Target' => null, + 'Anchor' => $matches[1], + 'DOMReference' => $link, + 'Broken' => !preg_match("#(name|id)=\"{$matches[1]}\"#", $htmlValue->getContent()) + ); + + continue; + } + + } + + return $results; + } + +} diff --git a/tests/model/SiteTreeBrokenLinksTest.php b/tests/model/SiteTreeBrokenLinksTest.php index 08dbda41..90fb9144 100644 --- a/tests/model/SiteTreeBrokenLinksTest.php +++ b/tests/model/SiteTreeBrokenLinksTest.php @@ -5,7 +5,7 @@ */ class SiteTreeBrokenLinksTest extends SapphireTest { protected static $fixture_file = 'SiteTreeBrokenLinksTest.yml'; - + public function testBrokenLinksBetweenPages() { $obj = $this->objFromFixture('Page','content'); @@ -17,31 +17,44 @@ class SiteTreeBrokenLinksTest extends SapphireTest { $obj->syncLinkTracking(); $this->assertFalse($obj->HasBrokenLink, 'Page does NOT have a broken link'); } - + + public function testBrokenAnchorBetweenPages() { + $obj = $this->objFromFixture('Page','content'); + $target = $this->objFromFixture('Page', 'about'); + + $obj->Content = "ID}]#no-anchor-here\">this is a broken link"; + $obj->syncLinkTracking(); + $this->assertTrue($obj->HasBrokenLink, 'Page has a broken link'); + + $obj->Content = "ID}]#yes-anchor-here\">this is not a broken link"; + $obj->syncLinkTracking(); + $this->assertFalse($obj->HasBrokenLink, 'Page does NOT have a broken link'); + } + public function testBrokenVirtualPages() { $obj = $this->objFromFixture('Page','content'); $vp = new VirtualPage(); - $vp->CopyContentFromID = $obj->ID; + $vp->CopyContentFromID = $obj->ID; $vp->syncLinkTracking(); $this->assertFalse($vp->HasBrokenLink, 'Working virtual page is NOT marked as broken'); - $vp->CopyContentFromID = 12345678; + $vp->CopyContentFromID = 12345678; $vp->syncLinkTracking(); $this->assertTrue($vp->HasBrokenLink, 'Broken virtual page IS marked as such'); } - + public function testBrokenInternalRedirectorPages() { $obj = $this->objFromFixture('Page','content'); $rp = new RedirectorPage(); $rp->RedirectionType = 'Internal'; - $rp->LinkToID = $obj->ID; + $rp->LinkToID = $obj->ID; $rp->syncLinkTracking(); $this->assertFalse($rp->HasBrokenLink, 'Working redirector page is NOT marked as broken'); - $rp->LinkToID = 12345678; + $rp->LinkToID = 12345678; $rp->syncLinkTracking(); $this->assertTrue($rp->HasBrokenLink, 'Broken redirector page IS marked as such'); } @@ -77,7 +90,8 @@ class SiteTreeBrokenLinksTest extends SapphireTest { $liveObj = Versioned::get_one_by_stage("SiteTree", "Live", "\"SiteTree\".\"ID\" = $obj->ID"); $this->assertEquals(1, $liveObj->HasBrokenFile); - } + } + public function testDeletingMarksBackLinkedPagesAsBroken() { $this->logInWithPermission('ADMIN'); @@ -142,7 +156,6 @@ class SiteTreeBrokenLinksTest extends SapphireTest { WHERE \"ID\" = $linkSrc->ID")->value()); } - public function testRestoreFixesBrokenLinks() { // Create page and virtual page $p = new Page(); @@ -300,5 +313,6 @@ class SiteTreeBrokenLinksTest extends SapphireTest { $obj->syncLinkTracking(); $this->assertFalse($obj->HasBrokenLink, 'Page doesn\'t have a broken anchor or skiplink'); } + } diff --git a/tests/model/SiteTreeLinkTrackingTest.php b/tests/model/SiteTreeLinkTrackingTest.php new file mode 100644 index 00000000..7fc43f9f --- /dev/null +++ b/tests/model/SiteTreeLinkTrackingTest.php @@ -0,0 +1,68 @@ +create('HTMLValue', $content); + $links = $parser->process($htmlValue); + + if (empty($links[0])) return false; + return $links[0]['Broken']; + } + + function testParser() { + $this->assertTrue($this->isBroken('link')); + $this->assertTrue($this->isBroken('link')); + $this->assertTrue($this->isBroken('link')); + $this->assertTrue($this->isBroken('link')); + $this->assertTrue($this->isBroken('link')); + + $this->assertFalse($this->isBroken('anchor')); + $this->assertFalse($this->isBroken('anchor')); + + $page = new Page(); + $page->Content = 'nameid'; + $page->write(); + + $file = new File(); + $file->write(); + + $this->assertFalse($this->isBroken("ID]\">link")); + $this->assertFalse($this->isBroken("ID]#yes-name-anchor\">link")); + $this->assertFalse($this->isBroken("ID]#yes-id-anchor\">link")); + $this->assertFalse($this->isBroken("ID]\">link")); + } + + function highlight($content) { + $field = new SiteTreeLinkTrackingTest_Field('Test'); + $field->setValue($content); + $newContent = html_entity_decode($field->Field(), ENT_COMPAT, 'UTF-8'); + return $newContent; + } + + function testHighlighter() { + $content = $this->highlight('link'); + $this->assertEquals(substr_count($content, 'ss-broken'), 1, 'A ss-broken class is added to the broken link.'); + $this->assertEquals(substr_count($content, 'existing-class'), 1, 'Existing class is not removed.'); + + $content = $this->highlight('link'); + $this->assertEquals(substr_count($content, 'ss-broken'), 1, 'ss-broken class is added to the broken link.'); + + $page = new Page(); + $page->Content = ''; + $page->write(); + + $content = $this->highlight( + "ID]\" class=\"existing-class ss-broken ss-broken\">link" + ); + $this->assertEquals(substr_count($content, 'ss-broken'), 0, 'All ss-broken classes are removed from good link'); + $this->assertEquals(substr_count($content, 'existing-class'), 1, 'Existing class is not removed.'); + } +} + +class SiteTreeLinkTrackingTest_Field extends HtmlEditorField implements TestOnly { + private static $extensions = array( + 'SiteTreeLinkTracking_Highlighter' + ); +}