From cf7d8300ccaa03c53364f9fbae81cfde845bd1b5 Mon Sep 17 00:00:00 2001
From: Ulrik Nielsen <me@ulrik.co>
Date: Wed, 24 Nov 2021 12:11:38 +0100
Subject: [PATCH] updated twig to version 3 and added wrapper for translation
 (#1531)

updated twig to version 3 and added wrapper for translation via symfony/twig-bridge

Co-authored-by: Ulrik nielsen <un@ipwsystems.dk>
---
 composer.json                                 |   6 +-
 composer.lock                                 | 335 +++++++++++-------
 lib/SimpleSAML/Locale/TwigTranslator.php      |  45 +++
 lib/SimpleSAML/XHTML/Template.php             |  12 +-
 modules/admin/templates/config.twig           |   4 +-
 modules/admin/templates/federation.twig       |   3 +-
 .../admin/templates/metadata_converter.twig   |  10 +-
 modules/admin/templates/status.twig           |   4 +-
 modules/core/templates/cardinality_error.twig |  11 +-
 modules/core/templates/loginuserpass.twig     |   5 +-
 modules/core/templates/logout-iframe.twig     |  11 +-
 .../saml/templates/proxy/invalid_session.twig |   2 +-
 templates/_table.twig                         |   4 +-
 templates/auth_status.twig                    |   4 +-
 templates/base.twig                           |   4 +-
 templates/sandbox.twig                        |   8 +-
 .../XHTML/TemplateTranslationTest.php         | 152 ++++++++
 17 files changed, 455 insertions(+), 165 deletions(-)
 create mode 100644 lib/SimpleSAML/Locale/TwigTranslator.php
 create mode 100644 tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php

diff --git a/composer.json b/composer.json
index 14126d797..7bd71f2ef 100644
--- a/composer.json
+++ b/composer.json
@@ -52,7 +52,6 @@
         "phpmailer/phpmailer": "^6.1",
         "simplesamlphp/assert": "^0.2.7",
         "simplesamlphp/saml2": "^4.2.3",
-        "simplesamlphp/twig-configurable-i18n": "^2.3",
         "symfony/cache": "^5.0",
         "symfony/config": "^5.0",
         "symfony/console": "^5.0",
@@ -62,10 +61,11 @@
         "symfony/http-foundation": "^5.0",
         "symfony/http-kernel": "^5.0",
         "symfony/routing": "^5.0",
+        "symfony/twig-bridge": "^5.3",
         "symfony/var-exporter": "^5.0",
         "symfony/yaml": "^5.0",
-        "twig/twig": "~2.0",
-        "twig/intl-extra": "^3.3"
+        "twig/intl-extra": "^3.3",
+        "twig/twig": "^3.0"
     },
     "require-dev": {
         "ext-curl": "*",
diff --git a/composer.lock b/composer.lock
index b134a1059..8f7f350ab 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "0c38c49fd3cfcb907c48e95defee76dd",
+    "content-hash": "ee5222307d77291802fab2d7bf9ff285",
     "packages": [
         {
             "name": "gettext/gettext",
@@ -590,62 +590,6 @@
             },
             "time": "2021-08-24T12:21:57+00:00"
         },
-        {
-            "name": "simplesamlphp/twig-configurable-i18n",
-            "version": "v2.3.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/simplesamlphp/twig-configurable-i18n.git",
-                "reference": "e2bffc7eed3112a0b3870ef5b4da0fd74c7c4b8a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/twig-configurable-i18n/zipball/e2bffc7eed3112a0b3870ef5b4da0fd74c7c4b8a",
-                "reference": "e2bffc7eed3112a0b3870ef5b4da0fd74c7c4b8a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1",
-                "twig/extensions": "@dev"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.5",
-                "sensiolabs/security-checker": "~6.0.3",
-                "simplesamlphp/simplesamlphp-test-framework": "~0.1.2",
-                "squizlabs/php_codesniffer": "^3.5",
-                "twig/twig": "^2.13"
-            },
-            "type": "project",
-            "autoload": {
-                "psr-4": {
-                    "SimpleSAML\\TwigConfigurableI18n\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-2.1"
-            ],
-            "authors": [
-                {
-                    "name": "Jaime Perez",
-                    "email": "jaime.perez@uninett.no"
-                }
-            ],
-            "description": "This is an extension on top of Twig's i18n extension, allowing you to customize which functions to use for translations.",
-            "keywords": [
-                "extension",
-                "gettext",
-                "i18n",
-                "internationalization",
-                "translation",
-                "twig"
-            ],
-            "support": {
-                "issues": "https://github.com/simplesamlphp/twig-configurable-i18n/issues",
-                "source": "https://github.com/simplesamlphp/twig-configurable-i18n"
-            },
-            "time": "2020-08-27T12:51:10+00:00"
-        },
         {
             "name": "symfony/cache",
             "version": "v5.3.8",
@@ -2831,6 +2775,205 @@
             ],
             "time": "2021-08-26T08:00:08+00:00"
         },
+        {
+            "name": "symfony/translation-contracts",
+            "version": "v2.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/translation-contracts.git",
+                "reference": "95c812666f3e91db75385749fe219c5e494c7f95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/95c812666f3e91db75385749fe219c5e494c7f95",
+                "reference": "95c812666f3e91db75385749fe219c5e494c7f95",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5"
+            },
+            "suggest": {
+                "symfony/translation-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.4-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Translation\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to translation",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/translation-contracts/tree/v2.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-03-23T23:28:01+00:00"
+        },
+        {
+            "name": "symfony/twig-bridge",
+            "version": "v5.3.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/twig-bridge.git",
+                "reference": "503e12aded4d5cbda4f8d1f3824c6a108119822f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/503e12aded4d5cbda4f8d1f3824c6a108119822f",
+                "reference": "503e12aded4d5cbda4f8d1f3824c6a108119822f",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/translation-contracts": "^1.1|^2",
+                "twig/twig": "^2.13|^3.0.4"
+            },
+            "conflict": {
+                "phpdocumentor/reflection-docblock": "<3.2.2",
+                "phpdocumentor/type-resolver": "<1.4.0",
+                "symfony/console": "<4.4",
+                "symfony/form": "<5.3",
+                "symfony/http-foundation": "<5.3",
+                "symfony/http-kernel": "<4.4",
+                "symfony/translation": "<5.2",
+                "symfony/workflow": "<5.2"
+            },
+            "require-dev": {
+                "doctrine/annotations": "^1.12",
+                "egulias/email-validator": "^2.1.10|^3",
+                "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
+                "symfony/asset": "^4.4|^5.0",
+                "symfony/console": "^4.4|^5.0",
+                "symfony/dependency-injection": "^4.4|^5.0",
+                "symfony/expression-language": "^4.4|^5.0",
+                "symfony/finder": "^4.4|^5.0",
+                "symfony/form": "^5.3",
+                "symfony/http-foundation": "^5.3",
+                "symfony/http-kernel": "^4.4|^5.0",
+                "symfony/intl": "^4.4|^5.0",
+                "symfony/mime": "^5.2",
+                "symfony/polyfill-intl-icu": "~1.0",
+                "symfony/property-info": "^4.4|^5.1",
+                "symfony/routing": "^4.4|^5.0",
+                "symfony/security-acl": "^2.8|^3.0",
+                "symfony/security-core": "^4.4|^5.0",
+                "symfony/security-csrf": "^4.4|^5.0",
+                "symfony/security-http": "^4.4|^5.0",
+                "symfony/serializer": "^5.2",
+                "symfony/stopwatch": "^4.4|^5.0",
+                "symfony/translation": "^5.2",
+                "symfony/web-link": "^4.4|^5.0",
+                "symfony/workflow": "^5.2",
+                "symfony/yaml": "^4.4|^5.0",
+                "twig/cssinliner-extra": "^2.12|^3",
+                "twig/inky-extra": "^2.12|^3",
+                "twig/markdown-extra": "^2.12|^3"
+            },
+            "suggest": {
+                "symfony/asset": "For using the AssetExtension",
+                "symfony/expression-language": "For using the ExpressionExtension",
+                "symfony/finder": "",
+                "symfony/form": "For using the FormExtension",
+                "symfony/http-kernel": "For using the HttpKernelExtension",
+                "symfony/routing": "For using the RoutingExtension",
+                "symfony/security-core": "For using the SecurityExtension",
+                "symfony/security-csrf": "For using the CsrfExtension",
+                "symfony/security-http": "For using the LogoutUrlExtension",
+                "symfony/stopwatch": "For using the StopwatchExtension",
+                "symfony/translation": "For using the TranslationExtension",
+                "symfony/var-dumper": "For using the DumpExtension",
+                "symfony/web-link": "For using the WebLinkExtension",
+                "symfony/yaml": "For using the YamlExtension"
+            },
+            "type": "symfony-bridge",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Bridge\\Twig\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides integration for Twig with various Symfony components",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/twig-bridge/tree/v5.3.7"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-08-26T07:28:06+00:00"
+        },
         {
             "name": "symfony/var-dumper",
             "version": "v5.3.8",
@@ -3067,66 +3210,6 @@
             ],
             "time": "2021-07-29T06:20:01+00:00"
         },
-        {
-            "name": "twig/extensions",
-            "version": "v1.5.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/twigphp/Twig-extensions.git",
-                "reference": "57873c8b0c1be51caa47df2cdb824490beb16202"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/twigphp/Twig-extensions/zipball/57873c8b0c1be51caa47df2cdb824490beb16202",
-                "reference": "57873c8b0c1be51caa47df2cdb824490beb16202",
-                "shasum": ""
-            },
-            "require": {
-                "twig/twig": "^1.27|^2.0"
-            },
-            "require-dev": {
-                "symfony/phpunit-bridge": "^3.4",
-                "symfony/translation": "^2.7|^3.4"
-            },
-            "suggest": {
-                "symfony/translation": "Allow the time_diff output to be translated"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.5-dev"
-                }
-            },
-            "autoload": {
-                "psr-0": {
-                    "Twig_Extensions_": "lib/"
-                },
-                "psr-4": {
-                    "Twig\\Extensions\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                }
-            ],
-            "description": "Common additional features for Twig that do not directly belong in core",
-            "keywords": [
-                "i18n",
-                "text"
-            ],
-            "support": {
-                "issues": "https://github.com/twigphp/Twig-extensions/issues",
-                "source": "https://github.com/twigphp/Twig-extensions/tree/master"
-            },
-            "abandoned": true,
-            "time": "2018-12-05T18:34:18+00:00"
-        },
         {
             "name": "twig/intl-extra",
             "version": "v3.3.3",
@@ -3198,16 +3281,16 @@
         },
         {
             "name": "twig/twig",
-            "version": "v2.14.7",
+            "version": "v3.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/twigphp/Twig.git",
-                "reference": "8e202327ee1ed863629de9b18a5ec70ac614d88f"
+                "reference": "a27fa056df8a6384316288ca8b0fa3a35fdeb569"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/twigphp/Twig/zipball/8e202327ee1ed863629de9b18a5ec70ac614d88f",
-                "reference": "8e202327ee1ed863629de9b18a5ec70ac614d88f",
+                "url": "https://api.github.com/repos/twigphp/Twig/zipball/a27fa056df8a6384316288ca8b0fa3a35fdeb569",
+                "reference": "a27fa056df8a6384316288ca8b0fa3a35fdeb569",
                 "shasum": ""
             },
             "require": {
@@ -3222,13 +3305,10 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.14-dev"
+                    "dev-master": "3.3-dev"
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "Twig_": "lib/"
-                },
                 "psr-4": {
                     "Twig\\": "src/"
                 }
@@ -3261,7 +3341,7 @@
             ],
             "support": {
                 "issues": "https://github.com/twigphp/Twig/issues",
-                "source": "https://github.com/twigphp/Twig/tree/v2.14.7"
+                "source": "https://github.com/twigphp/Twig/tree/v3.3.3"
             },
             "funding": [
                 {
@@ -3273,7 +3353,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-09-17T08:39:54+00:00"
+            "time": "2021-09-17T08:44:23+00:00"
         },
         {
             "name": "webmozart/assert",
@@ -6441,6 +6521,7 @@
                 "issues": "https://github.com/webmozart/path-util/issues",
                 "source": "https://github.com/webmozart/path-util/tree/2.3.0"
             },
+            "abandoned": "symfony/filesystem",
             "time": "2015-12-17T08:42:14+00:00"
         }
     ],
diff --git a/lib/SimpleSAML/Locale/TwigTranslator.php b/lib/SimpleSAML/Locale/TwigTranslator.php
new file mode 100644
index 000000000..4017bab9f
--- /dev/null
+++ b/lib/SimpleSAML/Locale/TwigTranslator.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * Wrap twig trans to allow us to override the translator used.
+ *
+ * @package SimpleSAMLphp
+ */
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Locale;
+
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class TwigTranslator implements TranslatorInterface
+{
+    private $translator;
+
+    /**
+     * @param callable|null $translator
+     */
+    public function __construct(callable $translator = null)
+    {
+        if (!is_callable($translator)) {
+            $translator = fn($string) => gettext($string);
+        }
+
+        $this->translator = $translator;
+    }
+
+    /**
+     * Translate message via configured translator.
+     *
+     * @param string $id
+     * @param array $parameters
+     * @param string|null $domain
+     * @param string|null $locale
+     */
+    public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null)
+    {
+        $this->locale = $locale;
+
+        return call_user_func_array($this->translator, func_get_args());
+    }
+}
diff --git a/lib/SimpleSAML/XHTML/Template.php b/lib/SimpleSAML/XHTML/Template.php
index 35d29204d..e62062d14 100644
--- a/lib/SimpleSAML/XHTML/Template.php
+++ b/lib/SimpleSAML/XHTML/Template.php
@@ -15,12 +15,15 @@ use SimpleSAML\Configuration;
 use SimpleSAML\Locale\Language;
 use SimpleSAML\Locale\Localization;
 use SimpleSAML\Locale\Translate;
+use SimpleSAML\Locale\TwigTranslator;
 use SimpleSAML\Logger;
 use SimpleSAML\Module;
 use SimpleSAML\TwigConfigurableI18n\Twig\Environment as Twig_Environment;
 use SimpleSAML\TwigConfigurableI18n\Twig\Extensions\Extension\I18n as Twig_Extensions_Extension_I18n;
 use SimpleSAML\Utils;
+use Symfony\Bridge\Twig\Extension\TranslationExtension;
 use Symfony\Component\HttpFoundation\Response;
+use Twig\Environment;
 use Twig\Loader\FilesystemLoader;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
@@ -263,7 +266,7 @@ class Template extends Response
      * @return \Twig\Environment
      * @throws \Exception if the template does not exist
      */
-    private function setupTwig(): \Twig\Environment
+    private function setupTwig(): Environment
     {
         $auto_reload = $this->configuration->getBoolean('template.auto_reload', true);
         $cache = $this->configuration->getString('template.cache', false);
@@ -289,12 +292,11 @@ class Template extends Response
             'auto_reload' => $auto_reload,
             'cache' => $cache,
             'strict_variables' => true,
-            'translation_function' => [Translate::class, 'translateSingularGettext'],
-            'translation_function_plural' => [Translate::class, 'translatePluralGettext'],
         ];
 
-        $twig = new Twig_Environment($loader, $options);
-        $twig->addExtension(new Twig_Extensions_Extension_I18n());
+        $twig = new Environment($loader, $options);
+        $twigTranslator = new TwigTranslator([Translate::class, 'translateSingularGettext']);
+        $twig->addExtension(new TranslationExtension($twigTranslator));
         $twig->addExtension(new \Twig\Extra\Intl\IntlExtension());
 
         $twig->addFunction(new TwigFunction('moduleURL', [Module::class, 'getModuleURL']));
diff --git a/modules/admin/templates/config.twig b/modules/admin/templates/config.twig
index 0fddf8fdb..0487bbc12 100644
--- a/modules/admin/templates/config.twig
+++ b/modules/admin/templates/config.twig
@@ -18,7 +18,9 @@
     <div class="message-box">
       {% trans %}SimpleSAMLphp is installed in:{% endtrans %}
       <kbd>{{ directory }}</kbd><br/>
-      {% trans %}You are running version <kbd>{{ version }}</kbd>.{% endtrans %}
+      {% trans with {
+          '%version%': version
+      } %}You are running version <kbd>%version%</kbd>.{% endtrans %}
     </div>
     <h2>{% trans %}Configuration{% endtrans %}</h2>
     <div class="enablebox mini">
diff --git a/modules/admin/templates/federation.twig b/modules/admin/templates/federation.twig
index 6ae2aa26f..492dd246b 100644
--- a/modules/admin/templates/federation.twig
+++ b/modules/admin/templates/federation.twig
@@ -56,8 +56,7 @@
               <div id="xml-{{ key }}" class="code-box-content xml">{{ set.metadata }}</div>
             </dd>
             <dt>{% trans %}SimpleSAMLphp Metadata{% endtrans %}</dt>
-            <dd>{% trans %}Use this if you are using a SimpleSAMLphp entity on
-              {#- #} the other side:{% endtrans %}</dd>
+            <dd>{% trans %}Use this if you are using a SimpleSAMLphp entity on the other side:{% endtrans %}</dd>
             <dd class="code-box hljs">
               <div class="pure-button-group top-right-corner">
                 <a class="pure-button copy hljs" data-clipboard-target="#php-{{ key }}"
diff --git a/modules/admin/templates/metadata_converter.twig b/modules/admin/templates/metadata_converter.twig
index 5c08911c1..5d37f58cd 100644
--- a/modules/admin/templates/metadata_converter.twig
+++ b/modules/admin/templates/metadata_converter.twig
@@ -8,7 +8,7 @@
 
     <h2>{{ pagetitle }}</h2>
     <form method="post" class="pure-form" enctype="multipart/form-data" action="#converted">
-        <h3> {% trans 'XML metadata' %}</h3>
+        <h3> {{ 'XML metadata'|trans }}</h3>
         <div class="pure-control-group">
             <textarea name="xmldata" rows="20" class="text-area edge xmldata">{{ xmldata }}</textarea>
         </div>
@@ -29,9 +29,10 @@
     {% if output -%}
     <br>
     <h2 id="converted">{{ 'Converted metadata'|trans }}</h2>
-        {% for type, text in output if text -%}
+        {% for type, text in output -%}
+            {%- if text -%}
 {# spaceless is to work around a clipboard.js bug that would add extra whitespace #}
-{% spaceless %}
+{% apply spaceless %}
     <div class="code-box">
         <div class="code-box-title">
             <h3>{{ type }}</h3>
@@ -43,9 +44,10 @@
             <pre id="metadata{{ loop.index }}">{{ text|escape }}</pre>
         </div>
     </div>
-{% endspaceless %}
+{% endapply %}
             <br><br>
             {%- set i=i+1 %}
+            {%- endif -%}
         {%- endfor -%}
     {% elseif error is not null %}
     <br>
diff --git a/modules/admin/templates/status.twig b/modules/admin/templates/status.twig
index 38fc86399..d68bd6e9a 100644
--- a/modules/admin/templates/status.twig
+++ b/modules/admin/templates/status.twig
@@ -9,7 +9,9 @@
      it lasts until it times out and all the attributes that are attached to your session.{% endtrans %}</p>
 
     {% if remaining %}
-        <p>{% trans %}Your session is valid for {{ remaining }} seconds from now.{% endtrans %}</p>
+        <p>{% trans with {
+            '%remaining%': remaining
+        } %}Your session is valid for %remaining% seconds from now.{% endtrans %}</p>
     {% endif %}
 
     <h2>{{ '{status:attributes_header}'|trans }}</h2>
diff --git a/modules/core/templates/cardinality_error.twig b/modules/core/templates/cardinality_error.twig
index 30b864c0f..a904218bd 100644
--- a/modules/core/templates/cardinality_error.twig
+++ b/modules/core/templates/cardinality_error.twig
@@ -4,9 +4,9 @@
 
 <h1>{{ pagetitle }}</h1>
 
-<p>{% trans 'One or more of the attributes supplied by your identity provider did not contain the expected number of values.' %}</p>
+<p>{{ 'One or more of the attributes supplied by your identity provider did not contain the expected number of values.'|trans }}</p>
 
-<h3>{% trans 'The problematic attribute(s) are:' %}</h3>
+<h3>{{ 'The problematic attribute(s) are:'|trans }}</h3>
 
 <dl class="cardinalityErrorAttributes">
 {% for attr,issues in cardinalityErrorAttributes %}
@@ -14,12 +14,15 @@
   {% set want = issues[1] %}
 
   <dt>{{ attr }}</dt>
-  <dd>{% trans %}got {{ got }} values, want {{ want }}{% endtrans %}</dd>
+  <dd>{% trans with {
+      '%got%': got,
+      '%want%': want
+  } %}got %got% values, want %want%{% endtrans %}</dd>
 {% endfor %}
 </dl>
 
 {% if LogoutURL is defined %}
-    <p><a href="{{ LogoutURL }}">{% trans 'Logout' %}</a></p>
+    <p><a href="{{ LogoutURL }}">{{ 'Logout'|trans }}</a></p>
 {% endif %}
 
 {% endblock %}
diff --git a/modules/core/templates/loginuserpass.twig b/modules/core/templates/loginuserpass.twig
index 3564ae601..2f0e4c051 100644
--- a/modules/core/templates/loginuserpass.twig
+++ b/modules/core/templates/loginuserpass.twig
@@ -10,10 +10,7 @@
     {%- if not isProduction %}
 
     <div class="message-box warning">
-      {% trans %}You are now accessing a pre-production system. This authentication setup
-      {#- #} is for testing and pre-production verification only. If someone sent you
-      {#- #} a link that pointed you here, and you are not <i>a tester</i> you
-      {#- #} probably got the wrong link, and should <b>not be here</b>.{% endtrans %}
+      {% trans %}You are now accessing a pre-production system. This authentication setup is for testing and pre-production verification only. If someone sent you a link that pointed you here, and you are not <i>a tester</i> you probably got the wrong link, and should <b>not be here</b>.{% endtrans %}
     </div>
     {% endif -%}
     {% if errorcode -%}
diff --git a/modules/core/templates/logout-iframe.twig b/modules/core/templates/logout-iframe.twig
index e3959ba62..7ed6e2c40 100644
--- a/modules/core/templates/logout-iframe.twig
+++ b/modules/core/templates/logout-iframe.twig
@@ -20,7 +20,9 @@
   {%- if terminated_service %}
     {%- set SP = terminated_service['name']|translateFromArray|default('the service'|trans)|e %}
 
-    <p>{% trans %}You are now successfully logged out from {{ SP }}.{% endtrans %}</p>
+    <p>{% trans with {
+        '%SP%': SP
+    } %}You are now successfully logged out from %SP%.{% endtrans %}</p>
   {%- endif %}
   {%- if remaining_services %}
     {%- set failed = false  %}
@@ -74,8 +76,7 @@
     <br>
     <div id="error-message"{% if not failed or type == 'init' %} class="hidden"{% endif %}>
       <div class="message-box error">
-        {% trans %}Unable to log out of one or more services. To ensure that all your
-        {#- #} sessions are closed, you are encouraged to <i>close your webbrowser</i>.{% endtrans %}
+        {% trans %}Unable to log out of one or more services. To ensure that all your sessions are closed, you are encouraged to <i>close your webbrowser</i>.{% endtrans %}
       </div>
     </div>
     <form id="error-form" action="logout-iframe-done.php"
@@ -101,7 +102,9 @@
           <input type="hidden" name="id" value="{{ auth_state }}">
           <input type="hidden" name="cancel" value="">
           <button id="btn-cancel" class="pure-button" type="submit">
-            {%- if terminated_service %}{% trans %}No, only {{ SP }}{% endtrans %}
+            {%- if terminated_service %}{% trans  with {
+                '%SP%': SP
+            } %}No, only %SP%{% endtrans %}
             {%- else %}{% trans %}No{% endtrans %}{% endif -%}
           </button>
         </form>
diff --git a/modules/saml/templates/proxy/invalid_session.twig b/modules/saml/templates/proxy/invalid_session.twig
index 23161a1c8..a7e40beee 100644
--- a/modules/saml/templates/proxy/invalid_session.twig
+++ b/modules/saml/templates/proxy/invalid_session.twig
@@ -3,7 +3,7 @@
 
 {% block content %}
     <h2>{{ 'Invalid Identity Provider'|trans }}</h2>
-    <p>{{ 'You already have a valid session with an identity provider (<em>%IDP%</em>) that is not accepted by <em>%SP%</em>. Would you like to log out from your existing session and log in again with another identity provider?'|trans({"%IDP%": entity_idp|entityDiplayName, "%SP%": entity_sp|entityDisplayName}, "app")|raw</p>
+    <p>{{ 'You already have a valid session with an identity provider (<em>%IDP%</em>) that is not accepted by <em>%SP%</em>. Would you like to log out from your existing session and log in again with another identity provider?'|trans({"%IDP%": entity_idp|entityDisplayName, "%SP%": entity_sp|entityDisplayName}, "app")|raw }}</p>
     <form method="post" action="?">
         <input type="hidden" name="AuthState" value="{{ AuthState|escape('html') }}">
         <input type="submit" name="continue" value="{{ 'Yes, continue'|trans }}">
diff --git a/templates/_table.twig b/templates/_table.twig
index 6af434995..2ada6a8a1 100644
--- a/templates/_table.twig
+++ b/templates/_table.twig
@@ -7,7 +7,7 @@
             <td class="attrname">{{ name }}</td>
         {%- endblock %}
 
-        <td class="attrvalue">{% spaceless %}
+        <td class="attrvalue">{% apply spaceless %}
                 {% for value in values %}
                     {% if loop.length>1 and loop.first %}<ul>{% endif %}
                     {% if loop.length>1 %}<li>{% endif -%}
@@ -17,7 +17,7 @@
                     {% if loop.length>1 %}</li>{% endif %}
                     {% if loop.length>1 and loop.last %}</ul>{% endif %}
                 {% endfor %}
-            {% endspaceless -%}
+            {% endapply -%}
         </td>
     </tr>
 {% endfor %}
diff --git a/templates/auth_status.twig b/templates/auth_status.twig
index 8a80f413d..5ba1be20a 100644
--- a/templates/auth_status.twig
+++ b/templates/auth_status.twig
@@ -7,7 +7,9 @@
     <p>{% trans %}Hi, this is the status page of SimpleSAMLphp. Here you can see if your session is timed out, how long it lasts until it times out and all the attributes that are attached to your session.{% endtrans %}</p>
 
 {% if remaining %}
-    <p>{% trans %}Your session is valid for {{ remaining }} seconds from now.{% endtrans %}</p>
+    <p>{% trans with {
+        '%remaining%': remaining
+    } %}Your session is valid for %remaining% seconds from now.{% endtrans %}</p>
 {% endif %}
 
 
diff --git a/templates/base.twig b/templates/base.twig
index 8c8f6c012..5c115e9fe 100644
--- a/templates/base.twig
+++ b/templates/base.twig
@@ -1,4 +1,4 @@
-{% spaceless %}
+{% apply spaceless %}
 <!DOCTYPE html>
 <html lang="{{ currentLanguage }}" xml:lang="{{ currentLanguage }}">
   <head>
@@ -31,4 +31,4 @@
     {% block postload %}{% endblock %}
   </body>
 </html>
-{% endspaceless %}
+{% endapply %}
diff --git a/templates/sandbox.twig b/templates/sandbox.twig
index 1ff8134fd..f8a10a9de 100644
--- a/templates/sandbox.twig
+++ b/templates/sandbox.twig
@@ -21,10 +21,10 @@
     <p>Twig allows you to translate strings in your templates. There are several ways to do that. If you want
         to translate the following text: <em>Hello, Untranslated World!</em>, you can do it with:</p>
     <ul>
-        <li><em>Inline trans tags</em>: using <code>{{ '{%' }} trans 'Hello, Untranslated World! %}</code> you would get
-            "{% trans 'Hello, Untranslated World!' %}".</li>
-        <li><em>Expanded trans tags</em>: using <code>{{ '{%' }} trans %}Hello, Untranslated World!{{ '{%' }} endtrans
-                %}</code> you would get "{% trans %}Hello, Untranslated World!{% endtrans %}".</li>
+        <li><em>Inline trans tags</em>: using <code>{{ '{{' }} 'Hello, Untranslated World!'|trans {{ '}}' }}</code> you would get
+            "{{ 'Hello, Untranslated World!'|trans  }}".</li>
+        <li><em>Expanded trans tags</em>: using <code>{{ '{%' }} trans {{ '%}' }}Hello, Untranslated World!{{ '{%' }} endtrans
+                {{ '%}' }}</code> you would get "{% trans %}Hello, Untranslated World!{% endtrans %}".</li>
         <li><em>Filters</em>: using <code>{{ '{{' }} variable|trans }}</code> you would get "{{ variable|trans }}".</li>
     </ul>
     <p>Translations support arguments too, so that you can replace parts of the translated string with the contents of
diff --git a/tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php b/tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php
new file mode 100644
index 000000000..d4c2db9e2
--- /dev/null
+++ b/tests/lib/SimpleSAML/XHTML/TemplateTranslationTest.php
@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\XHTML;
+
+use Exception;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Configuration;
+use SimpleSAML\Locale\TwigTranslator;
+use SimpleSAML\Locale\Translate;
+use SimpleSAML\XHTML\Template;
+use Symfony\Bridge\Twig\Extension\TranslationExtension;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Finder\SplFileInfo;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+
+/**
+ * @covers \SimpleSAML\XHTML\Template
+ */
+class TemplateTranslationTest extends TestCase
+{
+    public function testCoreCardinalityErrorTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'core:cardinality_error.twig');
+
+        $t->data['cardinalityErrorAttributes'] = [
+            'test 1' => [0, 1],
+            'test 2' => [1, 2],
+        ];
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('got 0 values, want 1', $html);
+        $this->assertStringContainsString('got 1 values, want 2', $html);
+
+    }
+
+    public function testCoreLoginUserPassTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'core:loginuserpass.twig');
+
+        $t->data['isProduction'] = false;
+        $t->data['errorcode'] = false;
+        $t->data['forceUsername'] = false;
+        $t->data['username'] = 'h.c oersted';
+        $t->data['rememberUsernameEnabled'] = false;
+        $t->data['rememberMeEnabled'] = false;
+        $t->data['stateparams'] = [];
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('value="h.c oersted"', $html);
+    }
+
+    public function testCoreLogoutIframeTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'core:logout-iframe.twig');
+
+        $t->data['auth_state'] = 'logout-test';
+        $t->data['type'] = 'test';
+        $t->data['terminated_service'] = [
+            'name' => [
+                'en' => 'ze testing service',
+            ],
+        ];
+        $t->data['remaining_services'] = [
+            'test' => [
+                'entityID' => 1234,
+                'metadata' => [
+                    'name' => [
+                        'en' => 'ze missing service',
+                    ],
+                ],
+                'status' => 'onhold',
+                'logoutURL' => 'https://xxx.yyy/',
+            ],
+        ];
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('You are now successfully logged out from ze testing service.', $html);
+        $this->assertStringContainsString('ze missing service', $html);
+    }
+
+    public function testAuthStatusTemplate(): void {
+        $c = Configuration::loadFromArray([], '', 'simplesaml');
+        $t = new Template($c, 'auth_status.twig');
+
+        $t->data['remaining'] = 2;
+        $t->data['attributes'] = [];
+        $t->data['nameid'] = false;
+        $t->data['trackid'] = '';
+        $t->data['authData'] = false;
+
+        $getContent = function() {
+            /** @var Template $this */
+            return $this->getContents();
+        };
+        $html = $getContent->call($t);
+
+        $this->assertStringContainsString('Your session is valid for ' . $t->data['remaining'] . ' seconds from now.', $html);
+    }
+
+    public function testValidateTwigFiles()
+    {
+        $root = dirname(dirname((dirname(dirname(__DIR__)))));
+
+        // Setup basic twig environment
+        $loader = new \Twig\Loader\FilesystemLoader(['templates', 'modules'], $root);
+        $twig = new \Twig\Environment($loader, ['cache' => false]);
+
+        $twigTranslator = new TwigTranslator([Translate::class, 'translateSingularGettext']);
+        $twig->addExtension(new TranslationExtension($twigTranslator));
+        $twig->addExtension(new \Twig\Extra\Intl\IntlExtension());
+
+        // Fake functions
+        $twig->addFunction(new TwigFunction('asset', function() { return ''; }));
+        $twig->addFunction(new TwigFunction('moduleURL', function() { return ''; }));
+
+        // Fake filters
+        $twig->addFilter(new TwigFilter('translateFromArray', function() { return ''; }, ['needs_context' => true]));
+        $twig->addFilter(new TwigFilter('entityDisplayName', function() { return ''; }));
+
+        $files = Finder::create()
+            ->name('*.twig')
+            ->in([
+                $root . '/templates',
+                $root . '/modules'
+            ]);
+
+        foreach ($files as $file) {
+            /** @var SplFileInfo $file */
+            $twig->load($file->getRelativePathname());
+        }
+
+        $this->assertTrue(true, 'All *.twig files parsed load test.');
+    }
+}
-- 
GitLab