Add target _blank to external link - Parsedown PHP

Viewed 2017

I'm using Parsedown to parse HTML from the database to my site. With Parsedown, you can't really add target="_blank" to the links.

So what I'm trying to do is to add target="_blank" to external links. I've found this function in Parsedown.php:

protected function inlineLink($Excerpt)
{
    $Element = array(
        'name' => 'a',
        'handler' => 'line',
        'text' => null,
        'attributes' => array(
            'href' => null,
            'title' => null,
        ),
    );

    $extent = 0;

    $remainder = $Excerpt['text'];

    if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
    {
        $Element['text'] = $matches[1];

        $extent += strlen($matches[0]);

        $remainder = substr($remainder, $extent);
    }
    else
    {
        return;
    }

    if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches))
    {
        $Element['attributes']['href'] = $matches[1];

        if (isset($matches[2]))
        {
            $Element['attributes']['title'] = substr($matches[2], 1, - 1);
        }

        $extent += strlen($matches[0]);
    }
    else
    {
        if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
        {
            $definition = strlen($matches[1]) ? $matches[1] : $Element['text'];
            $definition = strtolower($definition);

            $extent += strlen($matches[0]);
        }
        else
        {
            $definition = strtolower($Element['text']);
        }

        if ( ! isset($this->DefinitionData['Reference'][$definition]))
        {
            return;
        }

        $Definition = $this->DefinitionData['Reference'][$definition];

        $Element['attributes']['href'] = $Definition['url'];
        $Element['attributes']['title'] = $Definition['title'];
    }

    $Element['attributes']['href'] = str_replace(array('&', '<'), array('&amp;', '&lt;'), $Element['attributes']['href']);

    return array(
        'extent' => $extent,
        'element' => $Element,
    );
}

Now, what I've tried is this (added a comment of what I changed):

protected function inlineLink($Excerpt)
{
    $Element = array(
        'name' => 'a',
        'handler' => 'line',
        'text' => null,
        'attributes' => array(
            'href' => null,
            'target' => null, // added this
            'title' => null,
        ),
    );

    $extent = 0;

    $remainder = $Excerpt['text'];

    if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
    {
        $Element['text'] = $matches[1];

        $extent += strlen($matches[0]);

        $remainder = substr($remainder, $extent);
    }
    else
    {
        return;
    }

    if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches))
    {
        $Element['attributes']['href'] = $matches[1];

        if (isset($matches[2]))
        {
            $Element['attributes']['title'] = substr($matches[2], 1, - 1);
        }

        $extent += strlen($matches[0]);
    }
    else
    {
        if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
        {
            $definition = strlen($matches[1]) ? $matches[1] : $Element['text'];
            $definition = strtolower($definition);

            $extent += strlen($matches[0]);
        }
        else
        {
            $definition = strtolower($Element['text']);
        }

        if ( ! isset($this->DefinitionData['Reference'][$definition]))
        {
            return;
        }

        $Definition = $this->DefinitionData['Reference'][$definition];

        $Element['attributes']['href'] = $Definition['url'];
        if (strpos($Definition['url'], 'example.com') !== false) { // added this aswell, checking if its our own URL
            $Element['attributes']['target'] = '_blank';
        }
        $Element['attributes']['title'] = $Definition['title'];
    }

    $Element['attributes']['href'] = str_replace(array('&', '<'), array('&amp;', '&lt;'), $Element['attributes']['href']);

    return array(
        'extent' => $extent,
        'element' => $Element,
    );
}

Any suggestions to accomplish this?

4 Answers

Ran into this issue today. I wanted to have all links from a different host open up in a new target automatically. Unfortunately, the accepted answer recommends editing the Parsedown class file, which is a bad idea imo.

I created a new PHP class which extends Parsedown, and created an override for the element method. Here is the whole class:

class ParsedownExtended extends Parsedown
{
    protected function element(array $Element)
    {
        if ($this->safeMode) {
            $Element = $this->sanitiseElement($Element);
        }

        $markup = '<' . $Element['name'];

        if (isset($Element['name']) && $Element['name'] == 'a') {
            $server_host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : null;
            $href_host = isset($Element['attributes']['href']) ? parse_url($Element['attributes']['href'], PHP_URL_HOST) : null;

            if ($server_host != $href_host) {
                $Element['attributes']['target'] = '_blank';
            }
        }

        if (isset($Element['attributes'])) {
            foreach ($Element['attributes'] as $name => $value) {
                if ($value === null) {
                    continue;
                }

                $markup .= ' ' . $name . '="' . self::escape($value) . '"';
            }
        }

        if (isset($Element['text'])) {
            $markup .= '>';

            if (!isset($Element['nonNestables'])) {
                $Element['nonNestables'] = array();
            }

            if (isset($Element['handler'])) {
                $markup .= $this->{$Element['handler']}($Element['text'], $Element['nonNestables']);
            }
            else {
                $markup .= self::escape($Element['text'], true);
            }

            $markup .= '</' . $Element['name'] . '>';
        }
        else {
            $markup .= ' />';
        }

        return $markup;
    }
}

Here is where the magic happens:

if (isset($Element['name']) && $Element['name'] == 'a') {
    $server_host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : null;
    $href_host = isset($Element['attributes']['href']) ? parse_url($Element['attributes']['href'], PHP_URL_HOST) : null;

    if ($server_host != $href_host) {
        $Element['attributes']['target'] = '_blank';
    }
}

Now I simply use ParsedownExtended instead of Parsedown when parsing content, e.g.:

$parsedown = new ParsedownExtended();

return $parsedown->text($this->body);

Hope this helps someone.

Such issue already exists on GitHub. Please see this comment.

My extension can automatically set rel="nofollow" and target="_blank" attributes to a link when it is detected as an external link. You can also set those attributes manually through the attribute block:

[text](http://example.com) {rel="nofollow" target="_blank"}

Automatic rel="nofollow" Attribute on External Links

// custom external link attributes
$parser->links_external_attr = array(
    'rel' => 'nofollow',
    'target' => '_blank'
);

If you want to make changes in Parsedown class without using the parsedown-extra-plugin extension, you can do as follows:

1) In \Parsedown::element method after the first line $markup = '<'.$Element['name']; add this line $Element = $this->additionalProcessElement($Element);

2) Add new method to Parsedown class:

protected function additionalProcessElement($Element) { }

3) Extend Parsedown class and save it as MyParsedown.php file:

<?php
namespace myapps;

require_once __DIR__.'/Parsedown.php';

/**
 * Class MyParsedown
 * @package app
 */
class MyParsedown extends \Parsedown
{
    /**
     * @param array $Element
     * @return array
     */
    protected function additionalProcessElement($Element)
    {
        if ($Element['name'] == 'a' && $this->isExternalUrl($Element['attributes']['href'])) {
            $Element['attributes']['target'] = '_blank';
        }

        return $Element;
    }

    /**
     * Modification of the funciton from answer to the question "How To Check Whether A URL Is External URL or Internal URL With PHP?"
     * @param string $url
     * @param null $internalHostName
     * @see https://stackoverflow.com/a/22964930/7663972
     * @return bool
     */
    protected function isExternalUrl($url, $internalHostName = null) {
        $components = parse_url($url);
        $internalHostName = ($internalHostName == null) ? $_SERVER['HTTP_HOST'] : $internalHostName;
        // we will treat url like '/relative.php' as relative
        if (empty($components['host'])) {
            return false;
        }
        // url host looks exactly like the local host
        if (strcasecmp($components['host'], $internalHostName) === 0) {
            return false;
        }

        $isNotSubdomain = strrpos(strtolower($components['host']), '.'.$internalHostName) !== strlen($components['host']) - strlen('.'.$internalHostName);

        return $isNotSubdomain;
    }
}

4) Create test.php file and run it:

require_once __DIR__.'/MyParsedown.php';

$parsedown = new \myapps\MyParsedown();
$text = 'External link to [example.com](http://example.com/abc)';
echo $parsedown->text($text);

This HTML code will be displayed on the browser page (if your host is not example.com, of course):

<p>External link to <a href="http://example.com/abc" target="_blank">example.com</a></p>

Just like kjdion84 I'd also extend the Parsedown class. I suggest to not copy and change the element method but overwrite inlineLink; it's less work and more future proof if the base code changes.

Heads up: the urlIsExternal method is by no means complete (host check is missing).

class ParsedownExtended extends Parsedown
{
    protected function inlineLink($Excerpt)
    {
        $link = parent::inlineLink($Excerpt);

        if ($this->urlIsExternal($link['element']['attributes']['href'])) {
            $link['element']['attributes'] += [
                'target' => '_blank',
                'rel'    => 'nofollow',
            ];
        }

        return $link;
    }

    protected function urlIsExternal($url)
    {
        $scheme = parse_url($url, PHP_URL_SCHEME);
        $host   = parse_url($url, PHP_URL_HOST);

        if (!$scheme || !$host) {
            return false;
        }

        if (strpos(strtolower($scheme), 'http') !== 0) {
            return false;
        }

        // @TODO check the host

        return true;
    }
}

This will work.

<?php

declare(strict_types=1);

namespace YourNamespace;

class ParsedownExt extends \Parsedown
{
    // Add target to links
    protected function element(array $Element)
    {
        if (strcasecmp($Element['name'], 'a')===0)
            $Element['attributes']['target'] = '_blank';
        return parent::element($Element);
    }
}
Related