9

I'm developing a WordPress theme using a template engine. I want my code to be as much compatible as possible with WP core functionality.

Some context first

My first problem was to find a way to resolve the template starting from a WP query. I solved that one using a library of mine, Brain\Hierarchy.

Regarding get_template_part() and other functions that loads partials like get_header(), get_footer() and similar, it was pretty easy to write wrapper to template engine partial functionality.

The issue

My problem is now how to load comments template.

WordPress function comments_template() is a ~200 lines function that does a lot of things, which I want to do as well for maximum core compatibility.

However, as soon as I call comments_template(), a file is required, it is the first of:

  • the file in the constant COMMENTS_TEMPLATE, if defined
  • comments.php in theme folder, if found
  • /theme-compat/comments.php in WP includes folder as last resort fallback

In short, there's no way to prevent the function to load a PHP file, which is not desirable for me, because I need to render my templates and not simply use require.

Current solution

At the moment, I'm shipping an empty comments.php file and I'm using 'comments_template' filter hook, to know which template WordPress wants to loads, and use feature from my template engine to load the template.

Something like this:

function engineCommentsTemplate($myEngine) {

    $toLoad = null; // this will hold the template path

    $tmplGetter = function($tmpl) use(&$toLoad) {
       $toLoad = $tmpl;

       return $tmpl;
    };

    // late priority to allow filters attached here to do their job
    add_filter('comments_template', $tmplGetter, PHP_INT_MAX);

    // this will load an empty comments.php file I ship in my theme
    comments_template();

    remove_filter('comments_template', $tmplGetter, PHP_INT_MAX);

    if (is_file($toLoad) && is_readable($toLoad)) {
       return $myEngine->render($toLoad);
    }

    return '';    
}

The question

This works, is core compatible, but... is there a way to make it work without having to ship an empty comments.php?

Because I don't like it.

4

Not sure the following solution is better than the solution in OP, let's just say is an alternative, probably more hackish, solution.

I think you can use a PHP exception to stop WordPress execution when 'comments_template' filter is applied.

You can use a custom exception class as a DTO to carry the template.

This is a draft for the excepion:

class CommentsTemplateException extends \Exception {

   protected $template;

   public static function forTemplate($template) {
     $instance = new static();
     $instance->template = $template;

     return $instance;
   }

   public function template() {
      return $this->template;
   }
}

With this exception class available, your function becomes:

function engineCommentsTemplate($myEngine) {

    $filter = function($template) {
       throw CommentsTemplateException::forTemplate($template);
    };  

    try {
       add_filter('comments_template', $filter, PHP_INT_MAX); 
       // this will throw the excption that makes `catch` block run
       comments_template();
    } catch(CommentsTemplateException $e) {
       return $myEngine->render($e->template());
    } finally {
       remove_filter('comments_template', $filter, PHP_INT_MAX);
    }
}

The finally block requires PHP 5.5+.

Works the same way, and doesn't require an empty template.

4

I have wrestled with this before and my solution was — it can knock itself out requiring file, as long as it doesn't do anything.

Here is relevant code from my Meadow templating project:

public function comments_template( \Twig_Environment $env, $context, $file = 'comments.twig', $separate_comments = false ) {

    try {
        $env->loadTemplate( $file );
    } catch ( \Twig_Error_Loader $e ) {
        ob_start();
        comments_template( '/comments.php', $separate_comments );
        return ob_get_clean();
    }

    add_filter( 'comments_template', array( $this, 'return_blank_template' ) );
    comments_template( '/comments.php', $separate_comments );
    remove_filter( 'comments_template', array( $this, 'return_blank_template' ) );

    return twig_include( $env, $context, $file );
}

public function return_blank_template() {

    return __DIR__ . '/blank.php';
}

I let comments_template() go through the motions to set up globals and such, but feed it empty PHP file to require and move on to my actual Twig template for output.

Note that this requires to be able to intercept initial comments_template() call, which I can do since my Twig template is calling intermediary abstraction rather than actual PHP function.

While I still have to ship empty file for it, I do so in library and implementing theme doesn't have to care about it at all.

  • Upvoted, thanks. I already saw your approach since I used Meadow before. What I did not like here is the fact that a blank template need to be shipped anyway. Moreover, this breaks any attempt to use comments_template filter or COMMENTS_TEMPLATE constant to customize the template. Which is not pivotal, but, as I said, I wanted to stay as much as possible compatible with core. – gmazzap May 31 '16 at 10:10
  • @gmazzap hmmm... no reason I couldn't add support for filter & constant in my wrapper though, but it gets into micromanaging. – Rarst May 31 '16 at 10:51
3

Solution: Use a temporary file – with a unique file name

After a lot of jumps and crawling into the dirtiest corners of PHP, I rephrased the question to just:

How can one trick PHP into returning TRUE for file_exists( $file )?

as the code in core just is

file_exists( apply_filters( 'comments_template', $template ) )

Then the question was solved quicker:

$template = tempnam( __DIR__, '' );

and that's it. Maybe it would be better to use wp_upload_dir() instead:

$uploads = wp_upload_dir();
$template = tempname( $uploads['basedir'], '' );

Another option might be to use get_temp_dir() which wraps WP_TEMP_DIR. Hint: It strangely falls back to /tmp/ so files will not get preserved between reboots, which /var/tmp/ would. One can do a simple string comparison at the end and check the return value and then fix this in case it's needed – which is not in this case:

$template = tempname( get_temp_dir(), '' )

Now to quickly test if there are errors thrown for a temporary file without contents:

<?php
error_reporting( E_ALL );
$template = tempnam( __DIR__, '' );
var_dump( $template );
require $template;

And: No Errors → working.

EDIT: As @toscho pointed out in the comments, there's still a better way to do it:

$template = tempnam( trailingslashit( untrailingslashit( sys_get_temp_dir() ) ), 'comments.php' );

Note: According to a users note on php.net docs, the sys_get_temp_dir() behavior differs between systems. Therefore the result gets the trailing slash removed, then added again. As the core bug #22267 is fixed, this should work on Win/ IIS servers now as well.

Your refactored function (not tested):

function engineCommentsTemplate( $engine )
{
    $template = null;

    $tmplGetter = function( $original ) use( &$template ) {
        $template = $original;
        return tempnam( 
            trailingslashit( untrailingslashit( sys_get_temp_dir() ) ),
            'comments.php'
        );
    };

    add_filter( 'comments_template', $tmplGetter, PHP_INT_MAX );

    comments_template();

    remove_filter( 'comments_template', $tmplGetter, PHP_INT_MAX );

    if ( is_file( $template ) && is_readable( $template ) ) {
        return $engine->render( $template );
    }

    return '';
}

Bonus Nr.1: tmpfile() will return NULL. Yeah, really.

Bonus Nr.2: file_exists( __DIR__ ) will return TRUE. Yeah, really … in case you forgot.

^ This leads to an actual bug in WP core.


To help others going in explorer mode and finding those (badly to undocumented pieces), I will quickly sum up what I tried:

Attempt 1: Temporary file in memory

The first attempt I made was to create a stream to a temporary file, using php://temp. From the PHP docs:

The only difference between the two is that php://memory will always store its data in memory, whereas php://temp will use a temporary file once the amount of data stored hits a predefined limit (the default is 2 MB). The location of this temporary file is determined in the same way as the sys_get_temp_dir() function.

The code:

$handle = fopen( 'php://temp', 'r+' );
fwrite( $handle, 'foo' );
rewind( $handle );
var_dump( file_exist( stream_get_contents( $handle, 5 ) );

Finding: Nope, does not work.

Attempt 2: Use a temporary file

There's tmpfile(), so why not use that?!

var_dump( file_exists( tmpfile() ) );
// boolean FALSE

Yeah, that much about this shortcut.

Attempt 3: Use a custom stream wrapper

Next I thought I could build a custom stream wrapper and register it using stream_wrapper_register(). Then I could use a virtual template from that stream to trick core into believing that we have a file. Example code below (I already deleted the full class and history does not have enough steps…)

class TemplateStreamWrapper
{
    public $context;

    public function stream_open( $path, $mode, $options, &$opened )
    {
        // return boolean
    }
}

stream_wrapper_register( 'vt://comments', 'TemplateStreamWrapper' );
// … etc. …

Again, this returned NULL on file_exists().


Tested with PHP 5.6.20

  • I think that your Attempt 3 should work in theory. In your custom stream wrapper, did you implement stream_stat()? I think that this is what file_exists() will call to make its check... php.net/manual/en/streamwrapper.stream-stat.php – Alain Schlesser May 31 '16 at 7:12
  • Upvoted because is quite nice and not very hackish. However, since my code is intended to be used in different setups, I'm afraid that writing permission could be an issue. Moreover, temporary files need to be deleted, which is not that easy on the fly, because it's not easy to intercept the full path returned by tempnam(). Using a cron job will work, but it's additional overhead... – gmazzap May 31 '16 at 10:06
  • I think writing temporary file is worse than shipping empty template. Fixed empty template will get cached into opcode. Temp file will have to be written to disk, cold-parsed (no opcode), then deleted. It's better to minimize disk hits for no good reason. – Rarst May 31 '16 at 10:53
  • @Rarst The question never was "what is better" performance wise. Question boiled down to not having the template file :) – kaiser May 31 '16 at 11:40
  • 1
    tempnam( sys_get_temp_dir(), 'comments.php' ) is written once, you can reuse the file name, and the file is empty, so it doesn't use many resources. Plus it is easy to understand in your code. By far the best solution, imho. – fuxia May 31 '16 at 21:14
3

As @AlainSchlesser suggested to follow the route (and as non working things always bug me), I retried building a stream wrapper for virtual files. I could not solve it (read: reading the return values on the docs) on my own, but solved it with the help of @HPierce on SO.

class VirtualTemplateWrapper
{
    public $context;

    public function stream_open( $path, $mode, $options, &$opened_path ) { return true; }

    public function stream_read( $count ) { return ''; }

    public function stream_eof() { return ''; }

    public function stream_stat() {
        # $user = posix_getpwuid( posix_geteuid() );
        $data = [
            'dev'     => 0,
            'ino'     => getmyinode(),
            'mode'    => 'r',
            'nlink'   => 0,
            'uid'     => getmyuid(),
            'gid'     => getmygid(),
            #'uid'     => $user['uid'],
            #'gid'     => $user['gid'],
            'rdev'    => 0,
            'size'    => 0,
            'atime'   => time(),
            'mtime'   => getlastmod(),
            'ctime'   => FALSE,
            'blksize' => 0,
            'blocks'  => 0,
        ];
        return array_merge( array_values( $data ), $data );
    }

    public function url_stat( $path, $flags ) {
        return $this->stream_stat();
    }
}

You just need to register the new class as new protocol:

add_action( 'template_redirect', function() {
    stream_wrapper_register( 'virtual', 'VirtualTemplateWrapper' );
}, 0 );

This then allows to create a virtual (non existing) file:

$template = fopen( "virtual://comments", 'r+' );

Your function can then get refactored to:

function engineCommentsTemplate( $engine )
{
    $replacement = null;
    $virtual = fopen( "virtual://comments", 'r+' );

    $tmplGetter = function( $original ) use( &$replacement, $virtual ) {
        $replacement = $original;
        return $virtual;
    };

    add_filter( 'comments_template', $tmplGetter, PHP_INT_MAX );

    comments_template();

    remove_filter( 'comments_template', $tmplGetter, PHP_INT_MAX );

    // As the PHP internals are quite unclear: Better safe then sorry
    unset( $virtual );

    if ( is_file( $replacement ) && is_readable( $replacement ) ) {
        return $engine->render( $replacement );
    }

    return '';
}

as the file_exists() check in core returns TRUE and the require $file throws no error.

I have to note that I am quite happy how this turned out as it might be really helpful with unit tests.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.