The core/OutputPlugins extension registers a new {tag="value"} that can be used in theme templates, CSS, email templates, Pages blocks and Pages content. It is unlike almost every other extension in Invision Community 5 in one crucial respect: it does not run when the page is rendered. It runs when the template is compiled, and what it returns is not output — it is a fragment of PHP source code that gets written into the compiled template function and later eval()'d.
The single consumer is IPS\Theme\Theme::_compileTemplate() in system/Theme/Theme.php (the callback starts at line 4263), inside a preg_replace_callback over the pattern /\{([a-z]+?=(['"]).+?\2 ?+)}/. Note that Application::allExtensions( 'core', 'OutputPlugins' ) is never called anywhere in the suite — OutputPlugins is one of the few extension types that is never enumerated. It is looked up by name, one class at a time, via Application::getExtensionClass( $app, 'OutputPlugins', IPS::mb_ucfirst( $plugin ) ). Consequently your extension class is never instantiated, never access-checked, and a generate() method on it will be ignored.
The contract
namespace IPS\Extensions;
abstract class OutputPluginsAbstract
{
/**
* @brief Can be used when compiling CSS
*/
public static bool $canBeUsedInCss = FALSE;
/**
* @param string $data The initial data from the tag
* @param array $options Array of options
* @return array|string string with code to eval, or
* array( 'pre' => ..., 'return' => ... )
*/
abstract public static function runPlugin( string $data, array $options ): string|array;
}
What core actually does with the result, verbatim from _compileTemplate():
$code = $pluginClass::runPlugin( $value, $options, $functionName, $calledClass );
if( !is_array( $code ) )
{
$code = array( 'return' => $code );
}
if( !isset( $code['pre'] ) )
{
$code['pre'] = '';
}
if( !isset( $code['post'] ) )
{
$code['post'] = '';
}
and then it emits, into the compiled function:
IPSCONTENT; // closes the heredoc holding the literal template text
{$code['pre']}
$return .= {$code['return']}; // only if $code['return'] is truthy
{$code['post']}
$return .= <<<IPSCONTENT // reopens the heredoc
| Key | Must be | Notes |
|---|---|---|
pre | PHP statements | Optional. Defaulted to '' by core. Emitted verbatim before the assignment. May legally open a block (IPS\Output\Plugin\Template emits if ( ... ): here). |
return | A PHP expression | Not defaulted by core. Concatenated as $return .= <expr>;. Emitted only if truthy, so returning '' is the supported way to make a tag produce nothing. |
post | PHP statements | Optional, defaulted to ''. Undocumented in the abstract's docblock but fully supported — Template uses it to emit endif;. |
$data is the value of the first key="value" pair in the tag; $options is every remaining pair, keyed by attribute name. Both are raw template text, never sanitised, and end up inside PHP source that you generate.
A minimal example
File: applications/myapp/extensions/core/OutputPlugins/Mytag.php. The filename determines the tag: Mytag.php → {mytag="..."}.
<?php
namespace IPS\myapp\extensions\core\OutputPlugins;
use IPS\Extensions\OutputPluginsAbstract;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Mytag extends OutputPluginsAbstract
{
/**
* @brief Can be used when compiling CSS
*/
public static bool $canBeUsedInCss = FALSE;
/**
* Run the plug-in
*
* Extra parameters MUST have defaults - core passes four arguments,
* but the abstract only declares two.
*/
public static function runPlugin( string $data, array $options, ?string $functionName=NULL, string $calledClass='IPS\Theme' ): string|array
{
/* Core's convention: if the value starts with $ or \ it is PHP,
otherwise it is a literal that must be quoted and escaped. */
if ( mb_substr( $data, 0, 1 ) === '$' or mb_substr( $data, 0, 1 ) === '\\' )
{
$argument = $data;
}
else
{
$argument = "'" . addcslashes( $data, "'\\" ) . "'";
}
$mode = isset( $options['mode'] )
? "'" . addcslashes( $options['mode'], "'\\" ) . "'"
: 'NULL';
/* Escape at RUNTIME, in the generated code - core will not escape for you */
return array(
'pre' => '',
'return' => "\\IPS\\Theme\\Template::htmlspecialchars( \\IPS\\myapp\\Formatter::forTemplate( {$argument}, {$mode} ), ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', FALSE )",
'post' => '',
);
}
}
Used in a template as {mytag="hello" mode="short"} or {mytag="$item->title"}. After creating the file you must regenerate applications/myapp/data/extensions.json (ACP → Developer Centre → Extensions writes it via Application::buildExtensionsJson()) and rebuild templates, or nothing will happen at all.
The tag renders as literal text on the page — no error, no log entry
You see {mytag="hello"} printed verbatim in the HTML. This is core's designed behaviour for an unresolvable plugin:
/* Still doesn't exist? */
if( ! class_exists( $pluginClass ) )
{
return $matches[0];
}
There is no exception, no warning and no log. Every failure mode of name resolution produces exactly this symptom, so check all of them:
- The tag name in the template must be all lowercase letters. The outer regex is
[a-z]+?— no digits, no underscores, no capitals.{myTag=...},{my_tag=...}and{tag2=...}are not recognised as tags at all and are left alone by the compiler. - The class name must be exactly
ucfirst()of that lowercase name. Core computesIPS::mb_ucfirst( $plugin ), which only uppercases the first character (init.phpline 1393). The result is used as a case-sensitive array key intoextensions.json. A class calledMyTagregisters the keyMyTag, the lookup asks forMytag, and PHP array keys are case-sensitive, so it never matches. This is not hypothetical: core's ownapplications/core/extensions/core/OutputPlugins/GraphQl.phpregisters the keyGraphQland is therefore unreachable from any template tag in 5.0.19. data/extensions.jsonmust contain the entry.getExtensionClass()reads nothing but that file; it does not scan the extensions directory. A newly added file that has not been through the Developer Centre does not exist as far as the template compiler is concerned.- Your application must be enabled.
getExtensionClass()throwsOutOfRangeExceptionifApplication::appIsEnabled()is false, and separately ifRECOVERY_MODEis on and the app is not one ofIPS::$ipsApps.appIsEnabled()additionally returnsFALSEfor every app while the setup dispatcher is running an install.
Because resolution happens at compile time, this bites asymmetrically: templates built while the app was enabled keep working after you disable it, and templates rebuilt while it is disabled bake the literal tag text into the stored template permanently — until they are rebuilt again.
The tag produces nothing at all, silently, when you return an array
You return array( 'pre' => '$x = something();' ) and nothing is output. Core defaults pre and post when they are missing but reads return without an isset():
if ( $code['return'] )
{
$return .= <<<PHP
$return .= {$code['return']};
PHP;
}
PHP 8 raises Warning: Undefined array key "return" — and IPS::errorHandler() in init.php explicitly discards it:
/* We don't care about these in production */
if ( in_array( $errno, array( E_WARNING, E_NOTICE, E_STRICT, E_DEPRECATED ) ) )
{
return;
}
So the warning never reaches the screen or the system log. The expression evaluates to NULL, the if is skipped, and your tag compiles to a pre block with no output. Always set 'return', using '' when you genuinely want no output (that is what IPS\core\extensions\core\OutputPlugins\GraphQl does when the query returns no data).
"syntax error, unexpected ..." in eval()'d code, in a template you did not touch
Your return value is spliced into generated PHP as $return .= <your string>;. It must be a valid PHP expression, not a statement, and it must be balanced. Anything you get wrong becomes a parse error inside Theme::runProcessFunction()'s eval(), which reports a file/line of .../system/Theme/Theme.php(3981) : eval()'d code — never your extension file, and usually while a completely different template is being built.
The most common cause is naive quoting of $data. Core does this too and it is genuinely fragile — IPS\cms\extensions\core\OutputPlugins\Media and Pageurl both end with:
return "'" . $url . "'";
A single apostrophe or backslash anywhere in that value terminates the generated string literal early and the template stops compiling. Always run literals through addcslashes( $value, "'\\" ) (or var_export( $value, TRUE )) before wrapping them in quotes.
Two further points on the emitted expression:
- A trailing semicolon in your returned string is harmless — you get
expr;;, which parses.IPS\cms\...\WidgetandIPS\core\...\Backgroundimageboth ship one. - The declared return type is
string|array. Returning anintwould work at the source-splicing level but violates the abstract's return type and throws aTypeError. (IPS\Output\Plugin\Themereturns the integer100forlogo_height, but that class does not extend the abstract, so it is not bound by it.)
"Declaration of ...::runPlugin() must be compatible with ...OutputPluginsAbstract::runPlugin()"
The abstract declares two parameters. Core calls the method with four:
$code = $pluginClass::runPlugin( $value, $options, $functionName, $calledClass );
$functionName is the compiled function's name and may be NULL (compileTemplate() types it ?string). $calledClass is get_called_class() from the theme class, normally IPS\Theme. If you want either value you must declare the extra parameters as optional, exactly as IPS\Output\Plugin\Template does:
public static function runPlugin( string $data, array $options, ?string $functionName=NULL, string $calledClass='IPS\Theme' ): array|string
Declaring them without defaults is an LSP violation and produces the fatal above. Declaring a required third parameter without extending the abstract at all is worse but legal — IPS\Output\Plugin\Resource::runPlugin( string $data, array $options, string $context ) does exactly that and will throw a TypeError if $functionName is NULL.
The fatal surfaces when PHP loads your class, which happens inside class_exists( $pluginClass ) during template compilation — i.e. on whichever page request or theme build first compiles a template containing your tag, not anywhere near your own code.
An option is missing from $options even though you wrote it in the tag
Option parsing is a second regex run over the inside of the tag:
preg_match_all( '/(.+?)='.$matches[2].'([^' . $matches[2] . ']*)'.$matches[2].'\s?/', $matches[1], $submatches );
where $matches[2] is the quote character used by the first pair. Three consequences that are easy to hit and impossible to diagnose from the symptom:
- Only one whitespace character is consumed between pairs (
\s?). Write{mytag="x" mode="short"}with two spaces and the captured key is" mode", with a leading space.isset( $options['mode'] )is thenFALSEand your option is silently ignored. - Every pair must use the same quote character as the first pair. Mixing
{mytag='x' mode="short"}breaks the split. - An option value cannot contain the quote character at all — the value class is
[^"]*. There is no escape mechanism; use the opposite quote character inside the value.
Also note the outer regex has no /s modifier, so . does not match a newline: a tag split across two lines in a template is not recognised as a tag and is emitted literally.
Finally, $options contains only what was written. Always guard with isset(); core does not always bother, which is why IPS\Output\Plugin\Truncate opens with an unguarded if( !$options['length'] ).
Your plugin's output is not HTML-escaped
Ordinary template variables are rewritten by the compiler into Template::htmlspecialchars( $var, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', FALSE ). Plugin output is not. Your expression is emitted outside the heredoc as a bare $return .= <expr>;, so whatever it evaluates to is concatenated raw.
This is intentional — it is how {template=...} and {lang=...} can emit markup — but it means any plugin that surfaces member-supplied data is an XSS hole unless you emit the escaping call. Follow IPS\Output\Plugin\Expression, which escapes by default and only skips it when the tag passes a truthy raw option, or IPS\Output\Plugin\Setting, which escapes only when the tag sets an escape option. Escape in the generated code, not in runPlugin(), because runPlugin() only sees the template's literal text, not the runtime value.
"InvalidArgumentException: invalid_plugin: ..." — and it takes the front end down
CSS is compiled through the same function with $isCSS = TRUE, and plugins must opt in:
if ( $isCSS AND $pluginClass::$canBeUsedInCss !== TRUE )
{
throw new InvalidArgumentException( 'invalid_plugin:' . $functionName . ' - ' . $plugin );
}
The default in OutputPluginsAbstract is FALSE, so a new extension is CSS-forbidden until you set public static bool $canBeUsedInCss = TRUE;. Note the comparison is !== TRUE, a strict identity check — a truthy non-boolean will not satisfy it.
This exception is not caught anywhere. Neither Theme::writeCss() nor Theme::compileCustomCss() wraps the call. And compileCustomCss() is reached from Theme::getCustomCssForOutput() during ordinary front-end rendering, so a forbidden tag typed into a theme's Custom CSS box throws on the front end, for every visitor, not in the ACP editor where it was typed. Worse, IPS::exceptionHandler() only shows the exception message when Dispatcher::i()->controllerLocation == 'admin', so front-end visitors get a generic error page and a string like invalid_plugin:css_core_front_custom_custom_css - money is only visible in ACP → System Logs under uncaught_exception.
The language string core_theme_invalid_plugin ("You cannot use the template plug-in '{%s}' here.") exists in applications/core/data/lang.xml but is not referenced by any PHP in 5.0.19 — the exception message is the raw invalid_plugin: string, not a language key, so do not go looking for the friendly wording.
One related fatal: nothing in the consumer checks instanceof OutputPluginsAbstract. If you write a plugin class that does not extend the abstract and does not declare $canBeUsedInCss yourself, the line above raises Error: Access to undeclared static property instead of the exception. IPS\Output\Plugin\Address is in exactly that state in core.
Editing runPlugin() changes nothing on the site
Compiled templates are cached, not recompiled per request. Theme::compileTemplates() writes a whole compiled class per template group into the datastore under a key like template_<themeId>_<hash>_<group>; Theme::writeCss() writes CSS files to disk; Pages blocks, Pages content, custom fields and email templates each cache their compiled function in Store::i()->$functionName. Theme::runProcessFunction() additionally short-circuits entirely if function_exists() already returns true for the function name. First-party application templates skip the datastore entirely outside IN_DEV and are require_once'd from /static/templates/<app>_<location>_<group>.php, which is only regenerated by Theme::compileStatic().
So a change to runPlugin() has no visible effect until the relevant caches are cleared and templates/CSS are rebuilt. It also means every value your plugin computes at compile time is frozen and shared by every visitor and every theme that reuses that compiled function. Never return a literal that depends on the current member, the current request, permissions or the current language: return an expression that computes it at runtime instead. Core is not consistent about this — cms\...\Media and cms\...\Pageurl bake a URL string at compile time, and core\...\GraphQl actually executes a GraphQL query during compilation and bakes the JSON result into the template.
A page that has nothing to do with your app throws a generic error
The call to your extension is completely unguarded:
$code = $pluginClass::runPlugin( $value, $options, $functionName, $calledClass );
No try/catch, no type check on what comes back beyond is_array(). Any exception, TypeError or fatal in your runPlugin() aborts whatever was compiling the template.
This is the failure that surfaces furthest from its cause, because template compilation is lazy and happens during ordinary page rendering. Theme::getTemplate() (system/Theme/Theme.php, line 871) checks whether the datastore key template_<themeId>_<hash>_<group> exists and, if it does not, calls $this->compileTemplates( $app, $location, $group ) inline, on that request. So the visitor who happens to be first to hit a template group containing your tag after a cache clear is the one who gets the error page — on someone else's app's page, with a stack trace that points at IPS\Theme\Theme and never mentions your extension.
The other compilation entry points that your plugin can break are: ACP → Customisation → Themes → build (Theme::compileTemplates(), compileCss()), the "rebuild static" tool (Theme::compileStatic(), first-party apps only), Application::build() in developer mode, and the per-item compiles for Pages blocks (IPS\cms\Blocks\Block), Pages content (IPS\cms\Pages\Page), custom field display templates (IPS\core\ProfileFields\Field, IPS\cms\Fields, IPS\downloads\Field) and email templates (IPS\Email).
Because IPS::exceptionHandler() only reveals the exception message when Dispatcher::i()->controllerLocation == 'admin', the actual message is visible only in ACP → System Logs under uncaught_exception. Wrap everything in runPlugin() defensively and return a safe expression (or '') on failure — this is exactly what cms\...\Media does when Media::load() throws OutOfRangeException.
Another application's plugin wins your tag name
Resolution order is fixed and there is no namespacing:
IPS\Output\Plugin\<Ucfirst>is tried first viaclass_exists(). Any of the 23 built-in plugins insystem/Output/Plugin/—address,advertisement,customtemplate,datetime,expression,file,filesize,fontsize,hextorgb,insert,lang,member,number,prefix,request,resource,setting,striptags,template,theme,truncate,url,wordbreak— always beats an application extension of the same name.- Otherwise every application is walked in
Application::applications()order, which iscore_applicationssorted byapp_position, and the loopbreaks on the first app that declares the key.
So two apps declaring the same tag name silently resolve to whichever is positioned first in the ACP application list, and a site administrator reordering applications changes which one wins on the next template rebuild. Because the tag namespace is global across every template, every CSS file and every Pages block in the suite, choose a distinctive name — and remember that any pre-existing literal text matching {yourname="..."} anywhere in the suite will now be handed to your plugin.
preSaveProcess() and postSaveProcess() are never called
If you copy applications/cms/extensions/core/OutputPlugins/Database.php as a starting point you will inherit two extra static methods, preSaveProcess( string $data, array $options, object $page ) and postSaveProcess( ... ), complete with docblocks describing them as running before and after a Pages page is saved. Grepping the entire 5.0.19 tree for either name returns hits in that one file and nowhere else. They are dead code; nothing invokes them, and the LogicException with cms_err_db_already_on_page that preSaveProcess() throws can never fire. Do not build behaviour on them.
Verified against
Read from an Invision Community 5.0.19 source tree (applications/core/data/versions.json long version 5001908). Primary files: system/Extensions/OutputPluginsAbstract.php; the consumer IPS\Theme\Theme::_compileTemplate() (line 4199) plus compileTemplate(), getTemplate(), makeProcessFunction(), runProcessFunction(), writeCss(), compileCustomCss(), compileStatic() and compileTemplates() in system/Theme/Theme.php; Application::getExtensionClass(), appIsEnabled(), applications(), build() and buildExtensionsJson() in system/Application/Application.php; IPS::mb_ucfirst(), IPS::errorHandler() and IPS::exceptionHandler() in init.php; all eight shipped extensions under applications/{cms,core,nexus}/extensions/core/OutputPlugins/; and the 23 built-in plugins in system/Output/Plugin/.
Recommended Comments