Skip to content

Smarty - Convert {ts} block to compile-time plugin#36121

Open
colemanw wants to merge 1 commit into
civicrm:masterfrom
colemanw:smartyCompilerTs
Open

Smarty - Convert {ts} block to compile-time plugin#36121
colemanw wants to merge 1 commit into
civicrm:masterfrom
colemanw:smartyCompilerTs

Conversation

@colemanw

@colemanw colemanw commented Jul 1, 2026

Copy link
Copy Markdown
Member

Overview

Toward moving dev/core#1328 forward, this adjusts our Smarty {ts} block to respect the template-wide escape_html setting and any enclosing {setfilter} block, just like {$variable} does.

Technical Details

On the opening {ts ...} tag, the compiler emits ob_start() so that the literal text between the tags is captured at runtime. On the closing {/ts} tag, the compiler emits the call to _ts(ob_get_clean(), $params) and then wraps the result in exactly the same modifier / escape_html logic that PrintExpressionCompiler applies to {$variable} output.

Because all of this is resolved at compile time, {setfilter escape} and $smarty->escape_html both work transparently with no changes to existing .tpl files. Since we don't (yet) use either of those settings, there should be no functional change to any templates, but it makes way for us to start using those settings with consistent results.

The explicit {ts escape='html'} attribute is still honored: if it is present, the plugin forces htmlspecialchars() regardless of the filter state, and sets the "raw output" flag so that escape_html auto-escaping does not double-escape the result (matching Smarty's own behaviour for {$var|escape:'html'}).

@civibot

civibot Bot commented Jul 1, 2026

Copy link
Copy Markdown

🤖 Thank you for contributing to CiviCRM! ❤️ We will need to test and review this PR. 👷

Introduction for new contributors...
  • If this is your first PR, an admin will greenlight automated testing with the command ok to test or add to whitelist.
  • A series of tests will automatically run. You can see the results at the bottom of this page (if there are any problems, it will include a link to see what went wrong).
  • A demo site will be built where anyone can try out a version of CiviCRM that includes your changes.
  • If this process needs to be repeated, an admin will issue the command test this please to rerun tests and build a new demo site.
  • Before this PR can be merged, it needs to be reviewed. Please keep in mind that reviewers are volunteers, and their response time can vary from a few hours to a few weeks depending on their availability and their knowledge of this particular part of CiviCRM.
  • A great way to speed up this process is to "trade reviews" with someone - find an open PR that you feel able to review, and leave a comment like "I'm reviewing this now, could you please review mine?" (include a link to yours). You don't have to wait for a response to get started (and you don't have to stop at one!) the more you review, the faster this process goes for everyone 😄
  • To ensure that you are credited properly in the final release notes, please add yourself to contributor-key.yml
  • For more information about contributing, see CONTRIBUTING.md.
PR commands & links...
  • /rebase <branch-name> will rebase your branch and change the base of the PR.
  • /squash will combine all commits (keeping only the first commit messsage).
  • /port <branch-name> will create a copy of this PR against a different branch.
  • /lintroll will automatically fix linting errors, amending commits as needed.
  • retest this please will rerun the tests and rebuild the demo site.
  • 📖 Review standards
  • 🗒️ Review template (brief or verbose)

➡️ Online demo of this PR 🔗

@civibot civibot Bot added the master label Jul 1, 2026
@colemanw
colemanw force-pushed the smartyCompilerTs branch from c10d442 to 2adb0b2 Compare July 1, 2026 13:48
@totten

totten commented Jul 2, 2026

Copy link
Copy Markdown
Member

Really nice how you tracked through the compiler process.

This example feels quite surprising:

INPUT:        '{setfilter escape:"html"}{ts}Hello <b>World</b>{/ts}{/setfilter}',
OUTPUT:       'Hello &lt;b&gt;World&lt;/b&gt;',

In the simplest terms... I only expect {setfilter} to influence variables/data (as inHello {$name}). Neither {setfilter} nor {ts} would normally cause the literal-text to be escaped. And yet, when they are combined, the literal-text is now escaped. (unescaped+unescaped=escaped 🤯)

IMHO, Smarty's design for escaping is... probably more mature than ts()'s built-in escape option.

  • IIRC, ts(...escape=>...) originated as a short-cut for internationalizing blurbs in a SQL-template (eg civicrm_data.mysql.tpl and civicrm_navigation.mysql.tpl) with older versions of Civi+Smarty. It works well for that kind of conversion.

  • OTOH, Smarty's design comes from handling a wide range of strings/languages/encodings/use-cases. It allows you express different filtering for different variables.

    echo translate('Please fix the missing widget-flag for <a href="$1" $2>Membership Type "$3"</a>.');

    To realize this, you should be allowed to have different rules for $1 vs $2 vs $3.

    This design probably isn't the best... maybe it's a bit dated... but it's a known-quanitty

I wonder if it might make more sense to imitate Smarty behavior -- apply filtering to the variable-data used by ts? e.g.

{* ONE VARIABLE, ONE KIND OF DATA *}
{* Use default escaping *}
{ts 1=$name}Hello %1{/ts}

{* Use explicit escaping *}
{ts 1=$name|escape:"html"}Hello %1{/ts}

{* Use explicit escaping *}
{setfilter escape:"html"}
  {ts 1=$name}Hello %1{/ts}
{/setfilter}
{* MULTIPLE VARIABLES, MULTIPLE KIND OF DATA*}

{ts 1=$name|escape:"html" 2="target=_blank url='https://example.com'"|nofilter}<A %2>Hello %1</a>{/ts}

{setfilter escape:"html"}
  {ts 1=$name 2="target=_blank url='https://example.com'"|nofilter}<A %2>Hello %1</a>{/ts}
{/setfilter}

@colemanw

colemanw commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

@totten This example feels quite surprising:

Yea, that's a tough one... that example text Hello <b>World</b> clearly looks like html so our brains can read that and intuitively know that the < and > characters are intended to be unescaped... but what about a different example, say

INPUT:        '{setfilter escape:"html"}{ts}Is Foo < or > than bar & why?{/ts}{/setfilter}',
OUTPUT:       'Is Foo &lt; or &gt; than bar &amp; why?',

In that case our brains have a different intuition and the output looks correct. Note that the <, > and & might not even be in the original markup, but contained in the translated string so we don't see it as developers but it's there nontheless. This can be particularly breaky inside html attributes where " characters contained in the translation but not the original cause html breakages in non-english languages.

I do like your idea of applying filters directly to each variable e.g. ts 1=$name|escape:"html" but I'm not convinced the template's default escaping shouldn't also apply to the translated string itself. What would you think about applying the template/filter-block escaping rule by default but also allowing opt-out?

{setfilter escape:"html"}
  {* nofilter counteracts setfilter so variables must be escaped individually *}
  {ts nofilter 1=$world|escape:"html"}Hello <b>%1</b>{/ts}
{/setfilter}

@colemanw
colemanw force-pushed the smartyCompilerTs branch from 2adb0b2 to ceb83e1 Compare July 2, 2026 21:40
@colemanw

colemanw commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

@totten actually that already works. Added the new test case for it.

@colemanw

colemanw commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

@totten so the test failure in CI is:

E2E\Core\SmartyConsistencyTest::testBlockParamFilters
Test Smarty template: {ts 1=$x|escape}hello %1{/ts}
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
     '5_plain' => 'hello &amp;'
-    '5_auto' => 'hello &amp;'
+    '5_auto' => 'hello &amp;amp;'
 )

/home/homer/buildkit/build/build-1/web/sites/all/modules/civicrm/tests/phpunit/E2E/Core/SmartyConsistencyTest.php:408

What this is saying is that if you globally turn on escaping and you also escape the individual variable in {ts} then it will cause double-escaping of the variable.

My answer to this test is that should be the expected behavior and the scenario being tested is mistaken. If you ask it to escape the entire string AND the variables within it, then the variables will come out double-escaped, so you shouldn't do that.

I understand this is a contractual change... however, none of our templates yet use autoescaping, so we still have time to change the contract. And IMO it's a good change that will make Civi more secure in the long-run. We want auto-escaping to apply to entire {ts} blocks automatically, that's the safest default, and it can still be bypassed with {ts nofilter}.

This replaces the old runtime block plugin (block.ts.php) with a pair of
compile-time plugins so that {ts} respects the template-wide escape_html
setting and any enclosing {setfilter} block, just like {$variable} does.

How it works
------------
On the opening {ts ...} tag, the compiler emits ob_start() so that the
literal text between the tags is captured at runtime.  On the closing
{/ts} tag, the compiler emits the call to _ts(ob_get_clean(), $params)
and then wraps the result in exactly the same modifier / escape_html
logic that PrintExpressionCompiler applies to {$variable} output.

Because all of this is resolved at compile time, {setfilter escape}
and $smarty->escape_html both work transparently with no changes to
existing .tpl files.

The explicit {ts escape='html'} attribute is still honored: if it is
present, the plugin forces htmlspecialchars() regardless of the filter
state, and sets the "raw output" flag so that escape_html auto-escaping
does not double-escape the result (matching Smarty's own behaviour for
{$var|escape:'html'}).
@totten

totten commented Jul 22, 2026

Copy link
Copy Markdown
Member

Long timeframe on this PR; I guess because there's a lot to feedback on....

I do like your idea of applying filters directly to each variable e.g. ts 1=$name|escape:"html"...
Actually, that already works...

Yep, I didn't make it up. :) Just reporting on how Smarty already addressed that.

what about a different example, say

INPUT:        '{setfilter escape:"html"}{ts}Is Foo < or > than bar & why?{/ts}{/setfilter}',
OUTPUT:       'Is Foo &lt; or &gt; than bar &amp; why?',

In that case our brains have a different intuition and the output looks correct.

To my brain, that example INPUT looks malformed. The template/**.tpl files are HTML templates. You shouldn't have stray < or stray > or stray & in an HTML template. It's just a bad HTML. (Of course, Webkit and Gecko show forbearance for that kind of thing; but it's still malformed.)

N.B. The {setfilter} directive only controls variable-output. Here is the first sentence of the docs for setfilter:

The {setfilter}...{/setfilter} block tag allows the definition of template instance's variable filters. (emphasis added)

It sets the default filter for variables. It has no impact on literal text.

Your intuition might be more pleased by a different tag. Hypothetically, you could implement a tag {filter}...{/filter} that applies over the transcluded content:

{filter escape:html}Hello {$first_name} {$last_name}. All of this will be escaped at once.{/filter}

{filter escape:html}This & will be escaped.{/filter}

But note the distinction... {setfilter} literally sets the default filter for variables; it does not invoke the filter. By contrast, {filter} would invoke the filter.

My answer to this test is that should be the expected behavior and the scenario being tested is mistaken. ... I understand this is a contractual change... however, none of our templates yet use autoescaping...

So there's a design/aesthetic question about what behavior is desirable, and then a question about what behavior is being exhibited.

  • (Design) Making {ts} apply the variable-filter to literal text would (IMHO) make it more difficult to phase-in {setfilter}. Whenever you update a TPL to phase-in the {setfilter}, you will need to look at more things. (You need to review/revise the {$variable} expressions, and you also need to review/revise the {ts}...{/ts} expressions.)
    • (Whatsmore, the extra conversion on {ts}...{/ts} will push you to remove all markup from {ts} strings. But I actually think that's a bad thing to do. Simple markup -- like <strong> and <em> and <code> and <a> -- is good+proper. Typographic hints make it easier to read prose; that's why they exist.)
  • (What's Happening) I think you're saying that it's OK to change the behavior around {setfilter}+{ts} because nobody uses {setfilter}+{ts}. OK. But let's take an example. SmartyConsistencyTest does not use {setfilter}+{ts}. And yet... the test regresses. It starts to double-encode. Somehow, the patch produces double-encoding even if your TPL doesn't call setfilter.

@colemanw

Copy link
Copy Markdown
Member Author

Thanks for your feedback @totten

First, a clarification:

(What's Happening) I think you're saying that it's OK to change the behavior around {setfilter}+{ts} because nobody uses {setfilter}+{ts}. OK. But let's take an example. SmartyConsistencyTest does not use {setfilter}+{ts}. And yet... the test regresses. It starts to double-encode. Somehow, the patch produces double-encoding even if your TPL doesn't call setfilter.

The test shows that this PR will cause double-escaping with global escaping ON and variables escaped inside a {ts} tag. My point was we don't currently do that, so it's not too late to change our contract. You've correctly pointed out that the TPL in the test doesn't call setfilter; that's true, but it does enables global escaping which we also don't use. My point applies to both: we neither use setfilter nor global escaping in production currently, so we can change the contract for either before implementing them...

... now to the question of whether we should:

You've raised 2 procedural objections

  1. Making {ts} apply the variable-filter to literal text would (IMHO) make it more difficult to phase-in {setfilter}... you will need to look at more things.

  2. The extra conversion on {ts}...{/ts} will push you to remove all markup from {ts} strings.

And a design objection

{setfilter} sets the default filter for variables. It has no impact on literal text.

I see what you mean about {ts} being a block of text so we'd be going against Smarty conventions by applying filters to block contents instead of variables. I think that's your strongest objection and I'm inclined to agree with you just on those grounds... but just to think it through, here's why it might still be a good idea to break with Smarty conventions in the (somewhat special) case of {ts}:

  1. It's safer by default.
  2. It's easier to keep safe going forward.
  3. It prevents gotchas where the English string is safe but the translation is not.

Unsafe HTML Examples

These may seem obvious but imagine the translated string contains these special characters instead of the source string, making the gotcha invisible to developers:

<button title="{ts}Clicking "delete" cannot be undone.{/ts}">Delete</button>
<button onclick="alert('{ts}Don't click me!{/ts}')">Delete</button>

Unsafe JS Examples

<script>
  let greeting = '{ts}It's nice to meet you.{/ts}';
</script>
<div data-options='{"label": "{ts}I'm "very" safe, trust me bro.{/ts}"}'></div>

Unsafe SQL Examples

INSERT INTO civicrm_note (note) 
VALUES ('{ts}User's note{/ts}');

To address your procedural objections:

Making {ts} apply the variable-filter to literal text would (IMHO) make it more difficult to phase-in {setfilter}... you will need to look at more things.

IMHO, the opposite is true. If we make escaping to {ts} blocks a thing, a script that adds "nofilter" to every {ts} block containing a < character would be super easy to write. OTOH if we don't, we'd need a script that inspects the context of every {ts} block to ensure it is safely being used in title, alt, onclick etc. attributes which would be quite a bit trickier.

The extra conversion on {ts}...{/ts} will push you to remove all markup from {ts} strings.

I disagree. I think it will push you to be intentional about designating a string as markup. By our status-quo in Civi templates, {ts}Hello world{/ts} is markup. IMO, that's wrong and it's time to fix it. Make developers take one extra second to add nofilter to their {ts} tag as an intentional choice that they are indeed writing markup inside that block. Otherwise, it's plain text.

@totten

totten commented Jul 23, 2026

Copy link
Copy Markdown
Member

The test shows that this PR will cause double-escaping with global escaping ON

Arg, right. CIVICRM_SMARTY_DEFAULT_ESCAPE still exists. In my head, it was already deleted.

Maybe we can get rid of it...


@colemanw - OK, so your list of examples persuades me that there is a problem -- where ts-total-escape is responsive; and where basic Smarty isn't responsive. I just want to restate the problem with an even more pointed example (which helps me).

Consider just one string, Bill & Ted's >est Adventure. The string can appear in several contexts:

  • HTML Text: <button>{ts}Bill & Ted's >est Adventure{/ts}{/button}
  • HTML Attribute: <a title="{ts}Bill & Ted's >est Adventure{/ts}">
  • Client JS : window.alert(ts("Bill & Ted's >est Adventure"))
  • Server JS: window.alert("{ts}Bill & Ted's >est Adventure{/ts}")
  • SQL: INSERT INTO civicrm_option_value (...) VALUES ('{ts}Bill & Ted's >est Adventure{/ts}', ...)

The translator should only see this string once in Transifex. Even if the translator is knowledgable+patient about escaping, it's not possible for one translation to work in all those contexts. And it's a good thing that we only have one copy of that string. (It would be silly to translate the same string 5 times because each one has slightly different encoding.)

So thanks, I agree that's also an important angle. (Still need to figure out a way that reconciles the competing angles here.)

@colemanw

Copy link
Copy Markdown
Member Author

@totten yes exactly. This feels like yet another one of those times when CIvi has been at a crossroads and we're faced with a design choice. Do we make it:

A. Unsafe by default but easier in the short-run
B. Safe by default

Maybe I'm over-reacting because of all the other times we've chosen A and lived to regret it, but something inside me is screaming FOR GOD'S SAKE CHOOSE B THIS TIME!

As your 5 examples show, the safe choice is for {ts} to assume by default that it wraps plain-text unless explicitly told otherwise. That's option B.

If we don't do that and go with option A, then yes what's happening is that a specifically-encoded string (e.g. html or js or sql encoded) is being sent to transifex but with the encoding context stripped away and it's left for the translator to guess the encoding and craft their string appropriately.

In practice, that usually works out ok, and it usually doesn't bite us really hard and break the UI or crash the server or open up security vulnerabilities... 🙈🙈🙈

@colemanw

Copy link
Copy Markdown
Member Author

a script that adds "nofilter" to every {ts} block containing a < character would be super easy to write.

Here it is: https://gist.github.com/colemanw/2306860feaae1bf8502542f33488176f

@colemanw

Copy link
Copy Markdown
Member Author

@totten Maybe we can get rid of it...

Your PR has been merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants