Recently I created my first WordPress template (the one you are looking at right now) and I was left both in shock and in great awe. This piece of software written in PHP has many users (like myself), yet it is so very outdated. The filtering mechanism by which you can modify HTML after it is generated by WordPress feels very dirty. But because a lot of the HTML snippets that WordPress generates are not suitable for decorating with Twitter Bootstrap's beautiful CSS rules, I used those dirty filters extensively.

HTML string manipulation

The default WordPress HTML fragments needed some cleaning and rearranging of elements. I soon found myself calling preg_replace() and str_replace() to accomplish this. It worked, but not in a very secure way. I realised this was not very "forward compatible": I was modifying HTML with some very insecure assumptions in mind. For example, I wanted to add a class to input elements of type "text":

$form = str_replace('type="text"', 'type="text" class="span4"', $form);

This is absolutely unreliable: what would happen if the input elements already had a class attribute? Though the consequences would probably not be so severe, some of my other genius regular expressions could easily demolish an entire HTML page.

Manipulate the DOM directly using FluentDOM

I thought: why not load and manipulate fragments of HTML using the PHP DOM extension? You could load HTML in a DOM tree, modify some nodes and attributes, and dump the result back to an HTML string. I thought it would be really nice to have a fluid interface for this, like the one jQuery provides. I found this "jQuery-for-PHP" on the web, it's called FluentDOM and it's great. It's methods are indeed inspired by jQuery and it has a fluid interface.

The downside of FluentDOM is that it only accepts XPath queries. Well, I want to use CSS selectors, since I already speak this "language" fluently.

Bring on the Symfony2 CssSelector component

Symfony2 to the rescue: we shall use the CssSelector component, since it does exactly what we need: it converts CSS selectors to their XPath equivalents.

To be able to use the CssSelector it's best to also use Symfony2's ClassLoader, which takes care of loading the necessary classes. Copy the files of these Symfony2 components to your /vendor/symfony/src directory and add the following lines to your project:

require_once __DIR__.'/vendor/symfony/src/Symfony/Component/ClassLoader/UniversalClassLoader.php';

$loader = new Symfony\Component\ClassLoader\UniversalClassLoader();
$loader->registerNamespace('Symfony', __DIR__.'/vendor/symfony/src');
$loader->register();

Also download and copy FluentDOM to /vendor/FluentDOM and require the main file in your project:

require_once __DIR__.'/vendor/FluentDOM/src/FluentDOM.php';

Now, you can manually call the CssSelector::toXPath() method to convert a CSS selector to an XPath expression like this:

$html = '<input type="text" name="some_text" />';
$fluentDom = new FluentDOM();
$fluentDom->load($html, 'text/html');

$expression = Symfony\Component\CssSelector\CssSelector::toXPath('input[type=text]');
$fluentDom->find($expression)->addClass('span4');

Or you can use the wrapper I quickly wrote, which automatically translates CSS selectors to XPath expressions behind the scenes. It's basic version looks like this:

class FluentCssDom
{
    public function __construct()
    {
        $cssSelectorClass = 'Symfony\\Component\\CssSelector\\CssSelector';
        if (!class_exists($cssSelectorClass)) {
            throw new \RuntimeException(sprintf('Class %s is not loaded', $cssSelectorClass));
        }

        $this->fluentDom = new \FluentDOM();
    }

    public function __call($methodName, array $arguments = array())
    {
        $reflectionMethod = new \ReflectionMethod($this->fluentDom, $methodName);
        if (!$reflectionMethod->isPublic()) {
            throw new \BadMethodCallException(sprintf('Method %s::%s() is not public', get_class($this->fluentDom), $methodName));
        }

        $parameters = $reflectionMethod->getParameters();

        foreach ($parameters as $key => $parameter) {
            /* @var $parameter \ReflectionParameter */

            if ($parameter->getName() == 'expr' || $parameter->getName() == 'selector') {
                if (isset($arguments[$key]) && is_string($arguments[$key])) {
                    $arguments[$key] = \Symfony\Component\CssSelector\CssSelector::toXPath($arguments[$key]);
                }
            }
        }

        return $reflectionMethod->invokeArgs($this->fluentDom, $arguments);
    }

    public function __toString()
    {
        return (string) $this->fluentDom;
    }
}

Using this class, you can use the much shorter version:

$html = '<input type="text" name="some_text" />';
$fluentDom = new FluentCssDom();
$fluentDom->load($html, 'text/html');

$fluentDom->find('input[type=text]')->addClass('span4');

This unfortunately means you won't have the nice autocomplete suggestions in your IDE that you would have if you use FluentDOM directly and do the translation of CSS selectors manually.

Example

Let's see what we can do with a form (like it may be generated by WordPress). I want to add a class "span4" to all text input fields, I want to remove all <p> tags, but not it's children tags. Also I would like to put the checkbox inside the for better usability. For example, we may receive something like this inside a filter function:

$html = '
<form action="" method="post">
<p><label>Some text</label><input type="text" id="some_text" /></p>
<p><label>Some more text</label><input type="text" id="some_more_text" /></p>
<p><input type="checkbox" id="some_checkbox" value="1" />
<label for="some_checkbox">Check this</label></p>
<input type="submit" id="save" value="Save" />
</form>
';

This is the way we can make my wishes come true:

$fluentDom = new FluentCssDom();
$fluentDom->load($html, 'text/html');

// add a class "span4" to all text input elements
$fluentDom->find('input[type=text]')->addClass('span4');

// put the checkbox inside it's label
$checkbox = $fluentDom->find('input[id=some_checkbox]');
$label = $fluentDom->find('label[for=some_checkbox]');
$checkbox->prependTo($label);
$label->removeAttr('for')->addClass('checkbox');

// kill all paragraphs, but save their children
$paragraphs = $fluentDom->find('p');
$paragraphs->each(function($paragraph) {
    /* @var $paragraph DOMElement */
    $paragraphDom = new FluentCssDom();
    $paragraphDom->load($paragraph);
    $paragraphDom->replaceWith($paragraphDom->children());
});

So...

Please stop manipulating HTML using string manipulation functions. Modify DOM elements instead. And if you have the opportunity, use FluentDOM & CssSelector...

PHP Symfony2 WordPress DOM CSS XPath
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Renoir Boulanger

Hello Matthias

I am currently doing a similar project. I wonder if you were aware of PSSBlogBunddle. so far I rebuilt some of the main queries using the QueryBuilder and my site is using it. You can see on my project's (fork) Wiki (https://github.com/renoirb/... how I intend to do.

Love the quality of your posts! Keep up the good work!

Matthias Noback

Thanks! To me it is very satisfying to wrap old things in something new and shiny ;) It makes working with them bearable.

cordoval

I would like to see an example of the replacing for say a twitter bootstrap section or button. Do you have your theme available somewhere on github so we can fork and keep on improving?

Thanks!

Matthias Noback

Yesterday I added an example for customizing form elements. See also the FluentDOM API for many more ways of manipulating the DOM.

Matthias Noback

Also, one improvement might be to add caching, like this:

[php]
$hash = md5($html);
if ($cache->has($hash)) {
return $cache->get($hash);
}

$fluentDom = new FluentCssDom();
//...
$modifiedHtml = (string) $fluentDom;
$cache->set($hash, $modifiedHtml);

return $modifiedHtml;
[/php]

Matthias Noback

Thanks for your suggestion. I will put my theme on GitHub later on, but for now I've added an example of what you can do with a form to make it "Twitter Bootstrap" compliant.