yiisoft / yii2-twig

Yii 2 Twig extension.
http://www.yiiframework.com
BSD 3-Clause "New" or "Revised" License
136 stars 61 forks source link

The $context arg in render don't preserve the "internal context" in a double call of render #128

Open ivan-redooc opened 3 years ago

ivan-redooc commented 3 years ago

Using a "render" inside a model break the ability of Twig to include afterwards includes

What steps will reproduce the problem?

File structure

models
  User.php
  ViewContext.php
controllers
  AlfaController.php
views
  alfa
    index.twig
    footer.twig
  templates
    profile.twig

Files

AlfaController.php

class AlfaController{
  funtion action index(){
     $user=new User();
     return $this->render("index.twig",['user'=>$user])
  }

}

user.php

class User{
  public function getProfile(){
        $context = new ViewContext([
            'viewPath' => "@app/views/templates",
        ]);

      return \Yii::$app->getView()->render("profile.twig",[],$context);
  }
}

ViewContext.php

class ViewContext extends BaseObject implements ViewContextInterface
{
    public $viewPath;
    public function getViewPath ()
    {
        return \Yii::getAlias($this->viewPath);
    }
}

index.twig

<html>
<body>

   {# here the second render #}
   {{user.profile}}

   {# the include will fail #}
   {{include "footer.twig"}}

</body>
</html>

profile.twig

{# nothing special here #}
<div>
I'm a user
</div>

footer.twig

{# nothing special here #}
<div>
<hr>
</div>

What's expected?

A render like this

<html>
<body>

<div>
I'm a user
</div>

<div>
<hr>
</div>

</body>
</html>

What do you get instead?

Error: \Twig\Error\LoaderError Message: Unable to find template "footer.twig" (looked into: frontend/views/templates, frontend/views). Throwing point: file: vendor/twig/twig/src/Loader/FilesystemLoader.php line: 227

Additional info

In funtion render of vendor/yiisoft/yii2-twig/src/ViewRenderer.php a new FilesystemLoader was set inside the $this->twig object, so the next call (by the include) can't find the TWIG in a different path ( in this case frontend/views/templates):

    public function render($view, $file, $params)
    {
        $this->twig->addGlobal('this', $view);
        $loader = new FilesystemLoader(dirname($file));
        if ($view instanceof View) {
            $this->addFallbackPaths($loader, $view->theme);
        }

        $this->addAliases($loader, Yii::$aliases);
        $this->twig->setLoader($loader);

        // Change lexer syntax (must be set after other settings)
        if (!empty($this->lexerOptions)) {
            $this->setLexerOptions($this->lexerOptions);
        }

        return $this->twig->render(pathinfo($file, PATHINFO_BASENAME), $params);
    }

The $context is not involved in this business-logic.

A possible solution (I'm adopting) is to define in main.php a second view with twig render in $app

Q A
Yii version 2.0.38
Yii Twig version 2.4.0
Twig version v3.0.5
PHP version 7.3
Operating system Ubuntu
ivan-redooc commented 3 years ago

@samdark I think I can help. Already have an idea, I can share if you like.

samdark commented 3 years ago

Yes, please.

ivan-redooc commented 3 years ago

Because the core of ViewContextInterface is the function getViewPath() we can use it as index of array of FilesystemLoader.

So my idea (still to test) is:

    /**
     * @var FilesystemLoader[]
     * @since
     */
    protected $loaders=[];
//....
    public function render($view, $file, $params)
    {
        $this->twig->addGlobal('this', $view);

        if(isset($this->loaders[$view->context->getViewPath()])) {
            // I reuse if already created
            $loader = $this->loaders[$view->context->getViewPath()];
        } else {
            // just one time
            $loader = new FilesystemLoader(dirname($file));
            if ($view instanceof View) {
                $this->addFallbackPaths($loader, $view->theme);
            }

            $this->addAliases($loader, Yii::$aliases);
        }
        $this->twig->setLoader($loader);

        // Change lexer syntax (must be set after other settings)
        if (!empty($this->lexerOptions)) {
            $this->setLexerOptions($this->lexerOptions);
        }

        return $this->twig->render(pathinfo($file, PATHINFO_BASENAME), $params);
    }
developedsoftware commented 1 year ago

I am having the exact same issue. Did you ever find a solution?

developedsoftware commented 1 year ago

I think I may have solved this by altering https://github.com/twigphp/Twig/tree/3.x/src/Template.php

Whenever we call loadTemplate() I am adding the path of the currently loading template to the list of paths to check

Before

protected function loadTemplate($template, $templateName = null, $line = null, $index = null)
    {

        try {

            if (\is_array($template)) {
                return $this->env->resolveTemplate($template);
            }

            if ($template instanceof self || $template instanceof TemplateWrapper) {
                return $template;
            }

            if ($template === $this->getTemplateName()) {
                $class = static::class;
                if (false !== $pos = strrpos($class, '___', -1)) {
                    $class = substr($class, 0, $pos);
                }
            } else {
                $class = $this->env->getTemplateClass($template);
            }

            return $this->env->loadTemplate($class, $template, $index);
        } catch (Error $e) {
            if (!$e->getSourceContext()) {
                $e->setSourceContext($templateName ? new Source('', $templateName) : $this->getSourceContext());
            }

            if ($e->getTemplateLine() > 0) {
                throw $e;
            }

            if (!$line) {
                $e->guess();
            } else {
                $e->setTemplateLine($line);
            }

            throw $e;
        }
    }

After (added 2 lines of code after the try block)

protected function loadTemplate($template, $templateName = null, $line = null, $index = null)
    {

        try {

            $source = $this->getSourceContext();
            $this->env->getLoader()->addPath(dirname($source->getPath()));  

            if (\is_array($template)) {
                return $this->env->resolveTemplate($template);
            }

            if ($template instanceof self || $template instanceof TemplateWrapper) {
                return $template;
            }

            if ($template === $this->getTemplateName()) {
                $class = static::class;
                if (false !== $pos = strrpos($class, '___', -1)) {
                    $class = substr($class, 0, $pos);
                }
            } else {
                $class = $this->env->getTemplateClass($template);
            }

            return $this->env->loadTemplate($class, $template, $index);
        } catch (Error $e) {
            if (!$e->getSourceContext()) {
                $e->setSourceContext($templateName ? new Source('', $templateName) : $this->getSourceContext());
            }

            if ($e->getTemplateLine() > 0) {
                throw $e;
            }

            if (!$line) {
                $e->guess();
            } else {
                $e->setTemplateLine($line);
            }

            throw $e;
        }
    }

Should I open an issue upstream? Or can we override that functionality from within the yii2-twig implementation of twig?

developedsoftware commented 1 year ago

$this->env->getLoader()->addPath(dirname($this->getSourceContext()->getPath()));

This one line inside loadTemplate() seems to do the trick ;)