thephpleague / plates

Native PHP template system
https://platesphp.com
MIT License
1.47k stars 180 forks source link

Nested templates with their own variables #5

Closed manuhabitela closed 10 years ago

manuhabitela commented 10 years ago

Hey,

Wouldn't it be great if we could have nested templates with their own variables, not shared with the parent template?

I quickly hacked something that works for my case but I'm not sure it's okay so I didn't go much further:

class Template
{
    public function insert($name, Array $data = null)
    {
        //save current template data
        $templateData = $this->getPublicVars();

        //show the nested template with its own data
        $this->data($data);
        include $this->_internal['engine']->resolvePath($name);

        //delete nested template data not existing in the parent template
        $toDelete = array_diff(array_keys($data), array_keys($templateData));
        foreach ($toDelete as $name) {
            unset($this->$name);
        }

        //put back parent template data for the rest of the page
        $this->data($templateData);
    }

    public function getPublicVars() {
        return call_user_func('get_object_vars', $this);
    }

What do you think? :)

Cheers, Leimi

reinink commented 10 years ago

Wouldn't it be great if we could have nested templates with their own variables, not shared with the parent template?

@Leimi Thanks for your interest in Plates! Can expand on why this would be great? Because I actually designed it the opposite way intentionally. To me it was more helpful to have all variables available to all rendered views (the template, layouts, and nested).

Thanks!

manuhabitela commented 10 years ago

Well, it would prevent to have conflicts between the template and nested templates (the "elements").

Here is a concrete example: I want to render a page that shows a list of people + shows a detailed view of one guy.

//main template
//a 'user' object (for the detailed view) and
//a 'people' array (for the list of users) is set

echo "My name is ".$this->user->name; //Jonathan;

//show the list of people with an element:
foreach ($this->people as $key => $user) {
    $this->insert('elements/user', array('user' => $user));
}

//show again the name of the user: oops
//$this->user is now the last item of $this->people because of the insert
echo "My name is ".$this->user->name; //Emmanuel; 

Leimi :)

SxDx commented 10 years ago

This would probably break backwards compatibility, because there might be other nested templates that rely on changes on other templates? I don't think working like this is a good idea but nonetheless it would brake these applications. I'm not sure if this satisfies a version bump to 2.x.x.

manuhabitela commented 10 years ago

Well, we could add an array of options to the insert method with a "standalone" key that defaults to false or something. With a possibility to set default behavior of the insert method at plates initialization, would be perfect. Le 15 févr. 2014 16:09, "Rene Koller" notifications@github.com a écrit :

This would probably break backwards compatibility, because there might be other nested templates that rely on changes on other templates? I don't think working like this is a good ideas but nonetheless this would brake these applications. I'm not sure if this satisfies a version bump to 2.x.x.

Reply to this email directly or view it on GitHubhttps://github.com/thephpleague/plates/issues/5#issuecomment-35158222 .

reinink commented 10 years ago

@Leimi @SxDx Gents, thanks for your input. I'm not opposed to having an option for "private" templates. I just want to think through how best to implement this from an API standpoint. Emmanuel, your "standalone" key approach would certainly work, but since no other Plates configuration is set this way, I want to see if there is a better approach first.

kfriend commented 10 years ago

I agree that this is a feature that should be added. As @SxDx mentioned, some people might be expecting this behavior, which seems like a poor design choice to rely on the insert() method in this way, but since it already behaves in the fashion, it might be best to add an additional method to include via a closure of sorts.

I actually just whipped up something similar for a project I'm currently working on. It provides an include() method via a template extension. The include() methods pulls in the desired file within a closure, and extract()'s the passed array. I created it in a way that $this is still available, and provides a somewhat hacky solution to allow $this in PHP 5.3. We probably wouldn't need this hack if include() belonged to League\Plates\Template.

Note: I removed any application namespaces from the following code and I haven't tested without them. I also haven't done any benchmarking.

IncludeExtension.php

use League\Plates\Extension\ExtensionInterface;

class IncludeExtension implements ExtensionInterface
{
    public $engine;
    public $template;

    public function getFunctions()
    {
        return array(
            'returnInclude' => 'returnIncludeClosure',
            'include' => 'includeClosure',
        );
    }

    public function returnIncludeClosure($file, array $vars = array())
    {
        // If we're using PHP > 5.3, we'll create a closure and bind it to a specific context, so we
        // can use `$this` variable, like the rest of Plates.
        if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
            $closure = function($__engine, $__file, $__vars) {
                extract($__vars);

                ob_start();
                include($__engine->resolvePath($__file));
                return ob_get_clean();
            };

            $closure = $closure->bindTo($this->template);
        }

        // Otherwise, if we're using PHP 5.3, we'll mimic a closure using the TemplateIncludeClosure
        // class, which acts as a proxy to a template instance. This allows the use of `$this` in
        // the templates still. A bit hacky, but it works.
        //
        // Note: Haven't done any performance testing to see how slow (or not) this is.
        else
        {
            $closure = new TemplateIncludeClosure($this->template);
        }

        return $closure($this->engine, $file, $vars);
    }

    public function includeClosure($file, array $vars = array())
    {
        echo $this->returnIncludeClosure($file, $vars);
    }
}

TemplateIncludeClosure.php

class TemplateIncludeClosure
{
    protected $to;

    public function __construct($to)
    {
        $this->to = $to;
    }

    public function __invoke($__engine, $__file, array $__vars = array())
    {
        extract($__vars);

        ob_start();
        include($__engine->resolvePath($__file));
        return ob_get_clean();
    }

    public function __call($method, $args)
    {
        // Switch + count is (supposedly) faster than call_user_func_array().
        // TODO (someday) -- Make use of PHP 5.6's argument unpacking
        switch (count($args)) {
            case 0: return $this->to->{$method}(); break;
            case 1: return $this->to->{$method}($args[0]); break;
            case 2: return $this->to->{$method}($args[0], $args[1]); break;
            case 3: return $this->to->{$method}($args[0], $args[1], $args[2]); break;
            case 4: return $this->to->{$method}($args[0], $args[1], $args[2], $args[3]); break;
            case 5: return $this->to->{$method}($args[0], $args[1], $args[2], $args[3], $args[4]); break;
            default: return call_user_func_array(array($this->to, $method), $args); break;
        }
    }

    public function __get($prop)
    {
        return $this->to->{$prop};
    }

    public function __set($prop, $val)
    {
        $this->to->{$prop} = $val;
    }
}

Simple template example:

people.php

<?php

$this->users = [
    [
        'name' => 'Peter',
        'age' => 33,
    ],
    [
        'name' => 'Joe',
        'age' => 28,
    ],
];

?>
...
<?php foreach ($this->users as $user): ?>
    <?php $this->include('partial::person', ['person' => $user]) ?>
<?php endforeach; ?>
<?php var_dump($this->person) // Never set ?>

person.php

<p>
    Name: <?php echo $person['name'] ?><br />
    Age: <?php echo $person['age'] ?>
</p>
reinink commented 10 years ago

@Leimi @SxDx @kwoodfriend Thanks for your continued interest in this issue, and I'm not ignoring it, rather thinking how best to handle it.

I'm getting to the point that I agree that the nested templates (using <?php $this->insert() ?>) shouldn't share variables with the main template. I do feel that the layout template should share variables, which is especially important when using inheritance.

By creating a new template object for nested templates, they would then become self contained objects, with their own variables. They could actually have their own layouts as well, if ever that made sense. Right now, calling $this->layout() within a nested template actually changes the layout of the main template, not the nested template, since it's all technically one template object.

Note, however, at this point any content generated in the nested templates using the inheritance functions ($this->start() and $this->end()) would not be available in the main template, nor it's layout. I don't necessarily see that as an issue—in fact this current behaviour seems a little odd. It makes more sense that the main template fulfills all inherited layout requirements, rather than some (potentially deeply) nested template. It could still use a nested template to fulfill the inherited layout though, for example:

<?php $this->start('sidebar') ?>
    <?php $this->insert('blog-sidebar') ?>
<?php $this->end() ?>

How do these rules sound? I do think this could potentially break backwards compatibility, so a version bump to 2.x.x would be required—good call @SxDx.

@kwoodfriend FYI, I'm actually avoiding the use of extract() to put variables in local scope. It can certainly be done, as you know, but for consistency I'm avoiding it. You can read more about the use of the $this operator here.

reinink commented 10 years ago

Okay, I've made some these changes, see c2e95880d941d6e5313a05eff8ed6700380b9009.

Nested templates are now self contained objects, meaning they have their own variables and layouts. This will definitely require a major version bump, as some people may be using nesting for page headers and footers, with shared variables. For example:

<?php $this->title = "My page title used by the header template" ?>
<?php $this->insert('header') ?>

<p>Your content.</p>

<?php $this->insert('footer') ?>

Moving forward, this will have to be done like this:

<?php $this->insert('header', array('title' => 'My page title used by the header template')) ?>

<p>Your content.</p>

<?php $this->insert('footer') ?>

As noted, this means that nested templates can now also have their own layouts. I've taken this a step further even, and as per issue #10, I've added the ability to "stack" layouts. Consider this example:

<!-- template.tpl -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title><?=$this->title?></title>
</head>

<body>

<?=$this->content()?>

</body>
</html>
<!-- blog.tpl -->
<?php $this->layout('template') ?>

<h1>The Blog</h1>

<section>
    <article>
        <?=$this->content()?>
    </article>
    <aside>
        <?=$this->insert('blog-sidebar')?>
    </aside>
</section>
<!-- blog-article.tpl -->
<?php $this->title = $this->article->title ?>
<?php $this->layout('blog') ?>

<h2><?=$this->article->title?></h2>
<?=$this->article->content?>

Finally, with these changes I've added a new content() method, which essentially replaces the existing child() method (although the child() method still exists as an alias). Seems to me that content() is a more appropriate name.

For now this update is only available via "dev-master", until I update the documentation and do the major version release.

manuhabitela commented 10 years ago

Great! Thanks a lot :)

lalop commented 10 years ago

Hello, I'm all new on this project and want to test it. I use to work with twig and I see plates like php template with twig functionalities. In this way I was especting the possibility to explicitly overwrite data when I insert a child template but keep the other data accessible. Since insert method need an array I can't give it the current context ( this ) so I don't see any simple way to access all my data in a child template. Am I missing something ?

If I'm right I can make a little PR in this way.

reinink commented 10 years ago

@lalop What your suggesting sounds like how Plates previously worked. Most people preferred that inserted templates have their own scope, so it was changed to work that way.

However, to be sure I understand your request, can you please post some example code to show me exactly what you're trying to do?

lalop commented 10 years ago

Yes sure, here is a little crappy hack I did to do that :

    public function renderPartial($name, Array $data = null)
    {
        $inherit_data = [];
        foreach($this->template as $k=>$v){
            $inherit_data[$k] = $v;
        }
        $data = $data? array_merge($inherit_data, $data) : $inherit_data;
        echo $this->engine->makeTemplate()->render($name, $data);
    }

Like that I can use all the parent's data in the child, I can overwrite the data I want and what happens in the child doesn't interact with the parent. Is that more clear ?

A more clean should easily be possible if the data isn't setted at the root of the template but in an data property ( for exemple ), is there a reason why is not the case ?