Writing the tranc module
We hit Content and interface translation don’t clearly separate. I set out to fix it for ourselves and then released it back. It’s possible the solution is not correct or not useful for anyone else, nonetheless some of the coding challenges worth talking about.
You need the tranc module at hand to make any sense of this post.
Of decorators and testing
It’d be tempting to write TrancTranslationManager extends TranslationManager
and narrow down the class to change the langcode in getStringTranslation
and be done with it. This would, however, introduce a strong coupling between our module and TranslationManager
and a future core upgrade might just break it, perhaps in some subtle fashion. Instead we use the decorate pattern, implement the relevant interfaces with each method changing the langcode as necessary and delegating to the original string_translation
service. Another advantage besides possible future bugs: the webprofiler replaces the class on the string_translation service — if it were decoraring, tranc and webprofiler could be run at the same time without a problem. Good thing we do not use the webprofiler. Someone should file a patch against it to decorate…
Yet another advantage of this decorator class is the closedness of it. We know every nook and cranny of it. We can reason about it. Even without a test, we can confidently say this is doing what it’s supposed to do. It is very easy to see the external dependencies: there is exactly one call to anything not the translationManager. Of course, bugs might still happen: maybe some of the arguments of the proxy calls have the wrong order, maybe we left out a return
. On the other hand, the IDE would not let us leave out a return or introduce one where one is not needed. I would actually argue against writing a unit test against a class like this: it will be the expression of the same logic in a different format and just an unncessary maintanance headache. It will definitely not find bugs. In fact, the first version of this class had a very unexpected bug — one that neither a unit nor a kernel test would find!
The simplest test is to enable the module and visit a page. This blows up. W.T.F. As the doxygen notes LanguageRequestSubscriber
class calls a public method on the TranslationManager
class which is not on the interface. This happens to be a core bug so that’s great: we discovered a core bug which should be easy to be fixed. Adding methods to interfaces are not considered a BC break. This is the fundamental problem with many testing and indeed object oriented programming itself: you imagine a world and fit your test or class to it. But what happens when the world does not adhere to the mental model of a puny programmer?? Sucks to be you, that’s what happens.
Speaking of doxygen, that doxygen is absolutely necessary and useful. Putting phpcs enforced doxygen on protected $languageManager
saying “The language manager”, however, is just clutter. Unless forced, don’t do this either.
Of Twig and documentation
Another part of the module is changing the default theme to print in the content language. I know enough of Twig that changing a template from code requires a visitor but it’s been a very, very long time since I wrote one. So before I wrote a single line of code, I read https://twig.symfony.com/doc/2.x/api.html and https://twig.symfony.com/doc/2.x/internals.html Well, I only read the Basics section and then came Rendering and I stopped there because it didn’t look relevant. The internals page looks much more relevant and it’s short enough. I also explored the core Twig integration: TwigEnvironment
, TwigExtension
(only down to getName
the rest is very clearly not relevant to us, it’s implementations of various Drupal specific Twig functionanlity) and TwigNodeVisitor
.
TwigNodeVisitor
makes us very happy because it changes a filter to another which is exactly what we need to change the t
filter to tc
. But how will we know we are in the default theme? Well, on the Drupal half we can fish out the default theme from somewhere and on the Twig half, I dunno, surely a Twig node carries its filename. Well, Node::getFilename
has this most helpful message:
@trigger_error('The '.__METHOD__.' method is deprecated since version 1.27 and will be removed in 2.0. Use getTemplateName() instead.', E_USER_DEPRECATED);
This really is very helpful because I would have never guessed getTemplateName
is the filename! It is certainly not documented anywhere I can find. Once you have it, of course it’s easy to verify, for example Compiler:
$this->filename = $node->getTemplateName();
As for finding the default theme, I Googled drupal 8 get default theme, the first non-drupal StackExchange answer is ThemeHandler::getDefault
. This returns a string but there’s also a getTheme
method on the theme handler, it returns an Extension
object which has the getPath
method we need. So that’s a done. (While none of the Drupal SE answers are a direct answer, this answer can be used to deduce the correct method despite it is only mentioning the deprecated setDefault
method — surely there’s a getDefault
).
It’s worth implementing the visitor this far.
For the trans
tag, I decided I wanted to change the langcode in its options as that seeemed much easier than introducing a transc
tag. First, I wanted to write a little exploratory script to see what {% trans %}
parses into. If you look at the internals page it shows how to get to the nodes. The whole page has three lines of code, let’s try to make them work. The first line of code uses three variables: $twig
, $source
, $identifier
. The explanation mentions $twig
is an environment and while it’s not crosslinked, the API page mentions environments and also our core read tells us that Drupal::service('twig')
returns just that. That was our first variable, the second is $source
is just the Twig template we want to parse. Now what’s $identifier
? Mystery! Neither the API nor this page ever mentions it. I left it empty, and the tokenizer and the parser ran fine
but the compiler have complained it can’t find the template. Ah ha! Where did we read about defining templates on the fly? Right, we just read the core TwigEnvironment
class which in renderInline
reminded us Drupal has inline templates. I have tried putting {# inline_template_start #}
in front of my little Twig template, that didn’t work. I searched the Drupal codebase for this curious string and there are not many results, StringLoader::exists
looks interesting and highly relevant: it looks at the template name and if it starts with this string, it declares it exists. How do we set the template name…? Well our chain started with Source
, peeking into the Twig Source
class confirms our suspicions: what the internals page calls $identifier
is just the template name (which above already turned out to be the filename normally… what a mess). So:
$twig = \Drupal::service('twig');
$string = '{% trans %}x{% endtrans %}';
$stream = $twig->tokenize(new \Twig\Source($string, '{# inline_template_start #}'));
$nodes = $twig->parse($stream);
$twig->compile($nodes);
drush scr test.php
works. We can print $nodes
to see the nodes and we can print the compiled code to see. Phew! We can go bolder and do the same for:
$string = "{% trans with {'context': 'foo', 'langcode': 'bar'} %}x{% endtrans %}";
And print $nodes
now tells us everything we needed: the node is of class TwigNodeTrans
, the options are an ArrayExpression
, the strings are wrapped in ConstantExpression
, our visitor pretty much writes itself from this point.
Now we want to test this… If you followed the aforementioned renderInline
call chain you would have seen
$loader = new ChainLoader([
new ArrayLoader([$name => $template]),
$current = $this->getLoader(),
]);
which tells us the way this template gets registered is via new ArrayLoader([$name => $template])
. We learned on the API page that an environment needs a loader and we have one. So, using this info, the TrancNodeVisitorTest::testTrancNodeVisitor
method almost writes itself, it’s just a little bit more than the exploratory script above. It needs the core twig extension so that the trans
tag can get registered and the tranc twig extension as well but since those doesn’t depend on the actual test case, they are created in setUp
. Making a core extension is stolen from the core TwigExtensionTest
, just modernized slightly. Our extension needs a theme handler mock, not too hard either.
We can summarize our journey by saying Twig is extremely powerful and even worse documented than Drupal. The source code, however, is very well structured, the classes are small and almost all method names are self explanatory. Once you know how to get to the Twig nodes (which now you do! especially the test case is very generic), simply printing them out tells you everything. Who needs documentation when you have such wonderful debug features? Imagine if printing a Drupal content entity similarly printed the name of the fields, the field item list classes, the field item classes, the properties and their values. Sci-fi. On the other hand, I love spending each weekend on some interesting project. Hmmmmm…