Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/OutputPlugins extension: how a {tag} in a template is resolved

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
KeyMust beNotes
prePHP statementsOptional. Defaulted to '' by core. Emitted verbatim before the assignment. May legally open a block (IPS\Output\Plugin\Template emits if ( ... ): here).
returnA PHP expressionNot defaulted by core. Concatenated as $return .= <expr>;. Emitted only if truthy, so returning '' is the supported way to make a tag produce nothing.
postPHP statementsOptional, 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 computes IPS::mb_ucfirst( $plugin ), which only uppercases the first character (init.php line 1393). The result is used as a case-sensitive array key into extensions.json. A class called MyTag registers the key MyTag, the lookup asks for Mytag, and PHP array keys are case-sensitive, so it never matches. This is not hypothetical: core's own applications/core/extensions/core/OutputPlugins/GraphQl.php registers the key GraphQl and is therefore unreachable from any template tag in 5.0.19.
  • data/extensions.json must 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() throws OutOfRangeException if Application::appIsEnabled() is false, and separately if RECOVERY_MODE is on and the app is not one of IPS::$ipsApps. appIsEnabled() additionally returns FALSE for 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\...\Widget and IPS\core\...\Backgroundimage both ship one.
  • The declared return type is string|array. Returning an int would work at the source-splicing level but violates the abstract's return type and throws a TypeError. (IPS\Output\Plugin\Theme returns the integer 100 for logo_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 then FALSE and 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:

  1. IPS\Output\Plugin\<Ucfirst> is tried first via class_exists(). Any of the 23 built-in plugins in system/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.
  2. Otherwise every application is walked in Application::applications() order, which is core_applications sorted by app_position, and the loop breaks 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/.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.