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/Build extension in Invision Community 5

A core/Build extension is a hook into the developer's "build application" step in the AdminCP. Nobody browsing the community ever triggers it. What they experience is its output: files that are generated at build time and shipped inside the application tar, rather than committed to the repository. Core's only implementation is the reason the ACP code editor has syntax highlighting at all — the CodeMirror sources live under applications/core/dev/codemirror/, the build filter deliberately excludes every dev directory from the tar, so a core/Build extension concatenates and minifies them into applications/core/interface/static/codemirror/, which is shipped. The extension also lets you veto individual files as the tar is assembled. The ACP describes it in one line: "Run custom code when building the application." (applications/core/dev/lang.php:1278, key ext__Build).

The contract

IPS\Extensions\BuildAbstract (system/Extensions/BuildAbstract.php) is one of the smallest extension abstracts in the suite. Two abstract methods, one optional method with a default implementation:

namespace IPS\Extensions;

use IPS\Application\BuilderFilter;
use RuntimeException;

abstract class BuildAbstract
{
    /**
     * Build
     *
     * @return	void
     * @throws	RuntimeException
     */
    abstract public function build() : void;

    /**
     * Should the current file/directory be included in the tar
     *
     * @return bool
     */
    public function accept( BuilderFilter $current ): bool
    {
        return true;
    }

    /**
     * Finish Build
     *
     * @return	void
     */
    abstract public function finish() : void;
}

build() is where you do the work. It returns nothing, so core has no way to know whether it succeeded; the only channel you have back to core is an exception. The abstract's docblock declares @throws RuntimeException, and that is a meaningful contract — see the failure section below for exactly what core does with it.

finish() is called after the whole build has completed, including after core has written setup/upg_<version>/data.json. It also returns nothing. Nothing in core distinguishes it from build() other than position in the sequence — but the two loops do not run over the same objects. Application::extensions() memoises only the resolved class names (static::$_loadedExtensions, assigned at line 957); every call with $construct=TRUE re-enters constructExtensionClass() and does a fresh new $class( … ) (lines 963–969, and Application.php:479). So the object that runs finish() at line 3801 is a different instance from the one that ran build() at line 3713, and state you set on $this in build() is gone. Pass anything you need through a file, a static property, or the datastore.

accept() is optional — the abstract supplies a body returning true, so you may omit it entirely. It is a vote on whether one filesystem entry is included in the downloadable tar. Returning false for a file omits that file; returning false for a directory prunes the whole subtree, because the caller is a RecursiveFilterIterator. Note that it is not called during a plain build — only when a tar is actually produced.

The argument is the IPS\Application\BuilderFilter itself, positioned on the current entry, not an SplFileInfo. Core's own filter calls $this->isDir() and $this->getFilename() on it (system/Application/BuilderFilter.php:45), which reach the inner RecursiveDirectoryIterator through IteratorIterator's method forwarding; the rest of the SplFileInfo surface is reachable the same way, though core itself only uses those two.

There are no properties on the abstract, no constructor, and no return value anywhere that core inspects.

Who calls it

Three call sites, and only three, in the whole of 5.0.19.

IPS\Application::build() (system/Application/Application.php:3494) calls build() and, much later, finish():

/* line 3713 - after languages, themes, JS compilation and static theme compilation */
foreach($this->extensions('core', 'Build') as $builder )
{
    $builder->build();
}

/* ... version data written to setup/upg_<version>/data.json ... */

/* line 3801 */
foreach($this->extensions('core', 'Build') as $builder )
{
    $builder->finish();
}

unset( Data\Store::i()->buildingApp );

Both loops use $this->extensions(), the instance method — not Application::allExtensions(). That means only the Build extensions belonging to the application currently being built are run. Core's codemirror extension therefore only executes when core itself is built; your extension only executes when your app is built. This is the opposite of most extension points and it is the single most useful thing to know about this one.

The third call site inverts that. IPS\Application\BuilderFilter::accept() (system/Application/BuilderFilter.php:36) uses the static:

public function accept(): bool
{
    foreach( Application::allExtensions( 'core', 'Build' ) as $builder )
    {
        if( !$builder->accept( $this ) )
        {
            return false;
        }
    }
    return !( $this->isDir() && in_array( $this->getFilename(), $this->getDirectoriesToIgnore() ) );
}

getDirectoriesToIgnore() returns array( '.git', '.svn', 'dev' ) (lines 53–60).

Above that sit the ACP screens in applications/core/modules/admin/applications/applications.php, all three of which refuse to run outside developer mode:

  • build() at line 825 — the per-application "Build" form. Calls $application->build() at line 851. Errors with not_in_dev / 2C133/N if IN_DEV is off.
  • buildAll() at line 778 — builds every application in a loop, $application->build() at line 799. Guard code 2C133/M.
  • download() at line 870 — optionally builds (line 897), then produces the tar at line 909 with $download->buildFromIterator( new BuilderIterator( $application ), … ). Guard code 2C133/10. This is the only path that ever reaches accept().

IPS\Application\BuilderIterator (system/Application/BuilderIterator.php:46) wraps a RecursiveDirectoryIterator over applications/<dir>/ in a BuilderFilter. Application::build() itself contains no IN_DEV check — the guards are entirely in the controller.

Registering it

As with every IC5 extension, the class must be listed in applications/<app>/data/extensions.json. Application::extensions() (line 911) reads only that file; it never scans the extensions/ directory at runtime. Core's own entry is at applications/core/data/extensions.json:36:

"Build": {
    "codemirror": "IPS\\core\\extensions\\core\\Build\\codemirror"
}

The JSON is regenerated by Application::buildExtensionsJson() (line 4931), which walks extensions/<app>/<type>/*.php and derives the class name from the file name. It is called from the developer centre when you add or remove an extension (applications/core/modules/admin/developer/extensions.php:308 and :337), and both of those also unset( Store::i()->extensions ). It is not called during a build. Note that core's file is codemirror.php while the class inside it is declared class Codemirror; that works only because PHP class names are case-insensitive, and the registered string matches the file name, not the declaration. Match your file name to your class name and the question never arises.

The developer centre generates new files from applications/core/data/defaults/extensions/Build.txt, which stubs out all three methods including the optional accept().

My build() never runs and nothing is logged

Four things to check, all of them silent. Three of them stop build(); the third is included because it is the one people expect to be the cause and is not.

First, you built the wrong application. Because core uses $this->extensions( 'core', 'Build' ), building core does not run your app's Build extension. Use ACP → System → Applications → your app → Build, or "Build All".

Second, the class is not in data/extensions.json, or class_exists() on the listed name returns false. extensions() does continue with no message in that case — the comment in core reads "Switching between branches confuses extensions" (lines 926–930).

Third — and this one applies to accept() rather than to build(), so read the distinction carefully — the resolved class list is cached. extensions(), the instance method that drives build() and finish(), caches only in the static Application::$_loadedExtensions, which lives for one request; it re-reads data/extensions.json from disk on the next request, so a hand-edited extensions.json does take effect on the next build with no cache clearing. allExtensions(), which drives accept(), is the one that caches in Store::i()->extensions across requests and only rebuilds when the Build key is absent (line 367, written back at line 442). So a hand-added extension can be running its build() while still being invisible to the tar filter until the datastore is cleared. The developer centre clears it for you (developer/extensions.php:313 and :342).

Fourth, your constructor threw. Extensions are instantiated by Application::constructExtensionClass() (line 469), which ends:

catch( RuntimeException | OutOfRangeException $e ){}

return null;

A RuntimeException or OutOfRangeException from your constructor makes the object vanish with no log line anywhere. BuildAbstract declares no constructor, so the simplest fix is not to declare one either. If you must, note that extensions() is called with $checkAccess = FALSE by default, so you are constructed as new $class( NULL ) from Application::build(), whereas BuilderFilter calls allExtensions() with the default $checkAccess = TRUE, so you are constructed as new $class( Member::loggedIn() ). Declare it __construct( ?Member $member = null ) or not at all.

A missing method is a fatal error, not a skipped extension

This extension point is unusual in that its two required methods are genuinely abstract. Omit build() or finish() and PHP refuses to declare the class — "Class … contains 1 abstract method and must therefore be declared abstract or implement the remaining methods". That fatal fires the moment the file is autoloaded, which is inside class_exists() in Application::extensions(). The symptom is not "my build extension did nothing"; it is a white screen. extensions() only autoloads the classes registered for the app and type it was asked for, so most pages will not touch your Build class — but Application::buildExtensionsJson() autoloads every extension class in your application (class_exists() at line 4952), and it runs whenever you add or remove any extension in the developer centre. So the fatal typically surfaces on a developer-centre page that has nothing obviously to do with building. The same applies to changing a signature: build() : bool or accept( SplFileInfo $current ) are declaration-compatibility fatals, raised at autoload time.

Be careful with what you return from accept(). No IPS file declares strict_types, so PHP's weak return-type coercion applies: returning 0, 1, '' or 'no' is silently coerced to a boolean, and 'no' coerces to true. Only values with no scalar coercion — an array, an object, or null — raise a TypeError at the return statement, thrown mid-tar. Falling off the end of a method declared : bool without returning is also a TypeError.

An exception in build() leaves the application half-built

The most important thing to understand is where in the sequence build() sits. By the time it runs, core has already: written data/application.json, bumped core_applications.app_version to the new version, created setup/upg_<version>/, written the language, theme, custom-template, editor-plugin and JavaScript change manifests, installed languages and JavaScript, and — for first-party apps — compiled JS and static theme CSS. Neither build() nor finish() is wrapped in a try/catch by Application::build().

So if you throw, the version number has already been assigned and the manifests already written, but setup/upg_<version>/data.json — the file that tells the upgrader which steps exist — is never written, data/cmsTemplates.xml is never regenerated, the modules / tasks / settings / widgets / acpSearchKeywords / themeeditor change files are never written, finish() never runs for any extension, and Data\Store::i()->buildingApp is never unset (line 3806). Re-running the build usually recovers, because _combineChanges() merges manifests across repeated builds of the same version — but you must re-run it, and nothing tells you to.

What the developer sees depends on which button was pressed. From the per-app Build screen the exception is caught and rendered with a stack trace:

catch ( Exception $e )
{
    Output::i()->error( $e->getMessage() . "\n" . $e->getTraceAsString(), '' );
}

That is applications.php:853–856. From "Build All" (catch at line 801, error at line 803) and from the build step of "Download" (catch at line 899, error at line 901) only $e->getMessage() is shown, with no trace. All three catch Exception, which does not catch \Error — a TypeError, a call to an undefined method, or a failed type coercion inside your build() produces an unhandled fatal instead, and on a production-configured PHP that is a blank page.

"Build All" has a further consequence: Output::i()->error() ends the request, so applications later in the Application::applications() loop are never built at all. One broken third-party Build extension stops the whole suite build, and the error message names your exception, not your app.

The lingering buildingApp flag is the subtlest part. Application::areWeBuilding() (line 3814) returns true for five minutes after the flag was set, and its only consumer is IPS\Output\Plugin\Resource::runPlugin() (system/Output/Plugin/Resource.php:56), which switches first-party CSS resource URLs from absolute Theme::i()->resource() calls to relative paths. Templates compiled during that five-minute window on a developer install get relative resource paths baked in. It expires on its own, so the practical advice is simply not to recompile themes immediately after a failed build.

finish() ran twice

Core's own implementation calls $this->finish() as the last statement of build() (applications/core/extensions/core/Build/codemirror.php:77), and then Application::build() calls finish() again at line 3801. It is harmless there because Codemirror::finish() is empty. Do not copy the pattern unless your finish() is idempotent; core does not deduplicate. Note also that the two calls are made on two different objects — the in-build() call is on $this, the core call is on a freshly constructed instance — so a "have I already run?" flag stored on the instance will not suppress the second call.

Also note the ordering guarantee you do not have. extensions() preserves the key order of your extensions.json, but nothing sorts Build extensions relative to each other, and all of one app's build() calls complete before any finish() call. If two of your Build extensions depend on each other, put them in one class.

My accept() filtered files out of somebody else's application

This is the trap that catches everybody, and it is a genuine cross-application hazard.

BuilderFilter::accept() uses Application::allExtensions( 'core', 'Build' )every enabled application's Build extensions — and it does so for every file of whichever application is being downloaded. Your accept() is therefore consulted when IPS staff, or another developer on the same install, downloads Forums, Pages, or a competitor's app. If you return false for, say, any file named tests, you strip that directory out of everyone's tar on that install.

BuilderFilter is not given the application being built, so accept() receives no direct indication of whose files it is looking at. The only way to scope your filter is to inspect the path: the iterator is rooted at \IPS\ROOT_PATH . "/applications/" . $application->directory, so the pathname of the current entry contains your application directory if and only if it is your app being packaged. Check for /applications/myapp/ in the pathname before returning false for anything, and return true otherwise.

Two related notes. First, the vote is one-way: the loop returns false on the first extension that objects, and core's own .git/.svn/dev exclusion runs afterwards (line 45), so returning true cannot force those directories back into a tar. Second, allExtensions() skips every non-IPS application when \IPS\RECOVERY_MODE is on (line 396) and skips applications the logged-in member cannot canAccess() (lines 406–412), so your filter can be silently absent from a build depending on the state of the install.

One performance point: accept() is called once per filesystem entry, and allExtensions() constructs a fresh object for every extension on every call (line 451). For a large application that is thousands of constructions. Keep the constructor absent or trivial, and do not cache anything on $this inside accept() — the instance does not survive to the next file.

My generated files are missing from the tar

Write your output somewhere that survives the filter. Anything under applications/<app>/dev/ is excluded from the tar unconditionally — that is the entire reason core's CodeMirror extension exists. interface/ is the conventional destination for generated static assets. Also remember that the tar is built from the live filesystem at download time, so a build that wrote nothing simply ships the previous run's files with no warning.

file_put_contents() to a directory that does not exist raises a PHP warning and returns false; core's implementation does not check the return value, and neither build() nor the ACP will report it. If your target directory is not already in your repository, create it in build().

A complete example

This is core's only shipped Build extension, from applications/core/extensions/core/Build/codemirror.php — the code is unaltered, but the IPS copyright header and a @note about JAVA_PATH have been trimmed from the docblocks. It is the whole of the reference implementation in 5.0.19 — no other bundled application (forums, cms, nexus, downloads, gallery, blog, calendar) ships a core/Build extension.

<?php
/**
 * @brief		Build CodeMirror for release
 * @package		Invision Community
 * @since		21 Nov 2016
 */

namespace IPS\core\extensions\core\Build;

use Garfix\JsMinify\Minifier;
use IPS\Extensions\BuildAbstract;
use IPS\Theme;
use RuntimeException;
use function defined;
use function file_put_contents;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class Codemirror extends BuildAbstract
{
    /**
     * Build
     *
     * @return	void
     * @throws	RuntimeException
     */
    public function build() : void
    {
        /* Copy the CSS file */
        $css = file_get_contents( \IPS\ROOT_PATH . '/applications/core/dev/codemirror/lib/codemirror.css' );

        /* Copy the JS files */
        $js = ";";
        $js .= file_get_contents( \IPS\ROOT_PATH . '/applications/core/dev/codemirror/lib/codemirror.js' );
        foreach ( array( 'clike', 'css', 'htmlmixed', 'javascript', 'lua', 'perl', 'php', 'python', 'ruby', 'sql', 'stex', 'swift', 'xml' ) as $mode )
        {
            $js .= ';' . file_get_contents( \IPS\ROOT_PATH . "/applications/core/dev/codemirror/mode/{$mode}/{$mode}.js" );
        }

        /* Add our addons */
        foreach ( array( 'merge/merge', 'search/search', 'search/searchcursor' ) as $addon )
        {
            if ( file_exists( \IPS\ROOT_PATH . "/applications/core/dev/codemirror/addon/{$addon}.js" ) )
            {
                $js .= ';' . file_get_contents( \IPS\ROOT_PATH . "/applications/core/dev/codemirror/addon/{$addon}.js" );
            }
            if ( file_exists( \IPS\ROOT_PATH . "/applications/core/dev/codemirror/addon/{$addon}.css" ) )
            {
                $css .= file_get_contents( \IPS\ROOT_PATH . "/applications/core/dev/codemirror/addon/{$addon}.css" );
            }
        }

        /* Minify and write */
        require_once( \IPS\ROOT_PATH . '/system/3rd_party/JsMinify/Minifier.php' );
        require_once( \IPS\ROOT_PATH . '/system/3rd_party/JsMinify/MinifierError.php' );
        require_once( \IPS\ROOT_PATH . '/system/3rd_party/JsMinify/MinifierExpressions.php' );

        $css = Theme::minifyCss( $css );
        file_put_contents( \IPS\ROOT_PATH . '/applications/core/interface/static/codemirror/codemirror.css', $css );
        $js = Minifier::minify( $js, array( 'flaggedComments' => false ) );
        file_put_contents( \IPS\ROOT_PATH . '/applications/core/interface/static/codemirror/codemirror.js', $js );

        /* Finish */
        $this->finish();
    }

    /**
     * Finish Build
     *
     * @return	void
     */
    public function finish() : void
    {

    }
}

Note what it does not do: no accept(), no constructor, no file_exists() guard on the two lib/ files (a missing one produces a PHP warning and false, which is then concatenated as an empty string rather than failing the build), and no check that interface/static/codemirror/ exists. Theme::minifyCss() is at system/Theme/Theme.php:4972.

A third-party skeleton with a correctly scoped filter looks like this. Register it under {"core": {"Build": {"Assets": "IPS\\myapp\\extensions\\core\\Build\\Assets"}}} in applications/myapp/data/extensions.json.

<?php
/* applications/myapp/extensions/core/Build/Assets.php */

namespace IPS\myapp\extensions\core\Build;

use IPS\Application\BuilderFilter;
use IPS\Extensions\BuildAbstract;
use RuntimeException;
use Throwable;
use function defined;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class Assets extends BuildAbstract
{
    /**
     * Build
     *
     * @return	void
     * @throws	RuntimeException
     */
    public function build() : void
    {
        $source = \IPS\ROOT_PATH . '/applications/myapp/dev/assets';
        $target = \IPS\ROOT_PATH . '/applications/myapp/interface/static';

        if ( !is_dir( $target ) and !mkdir( $target, \IPS\IPS_FOLDER_PERMISSION, true ) )
        {
            /* The abstract documents RuntimeException; the ACP shows getMessage(). */
            throw new RuntimeException( 'myapp: could not create ' . $target );
        }

        try
        {
            $contents = '';
            foreach ( glob( $source . '/*.js' ) ?: array() as $file )
            {
                $contents .= ";\n" . file_get_contents( $file );
            }

            if ( file_put_contents( $target . '/bundle.js', $contents ) === false )
            {
                throw new RuntimeException( 'myapp: could not write bundle.js' );
            }
        }
        catch ( RuntimeException $e )
        {
            throw $e;
        }
        catch ( Throwable $e )
        {
            /* Never let an \Error escape - the ACP only catches Exception. */
            throw new RuntimeException( 'myapp build failed: ' . $e->getMessage(), 0, $e );
        }
    }

    /**
     * Should the current file/directory be included in the tar
     *
     * @return bool
     */
    public function accept( BuilderFilter $current ): bool
    {
        /* This is called for EVERY application's download, not just ours.
           Scope by path or you will filter other people's tars. */
        $path = str_replace( '\\', '/', (string) $current->getPathname() );

        if ( !str_contains( $path, '/applications/myapp/' ) )
        {
            return true;
        }

        return $current->getFilename() !== '.DS_Store';
    }

    /**
     * Finish Build
     *
     * @return	void
     */
    public function finish() : void
    {
    }
}

Unverified

Two points in the above could not be pinned down from source alone and are stated on general PHP behaviour rather than on a line of IPS code: that getPathname() is reachable on the BuilderFilter through IteratorIterator's method forwarding (core demonstrably reaches isDir() and getFilename() that way at BuilderFilter.php:45, but no core code calls getPathname() on it), and the exact wording of PHP's fatal errors for unimplemented abstract methods and incompatible signatures. Verify the pathname call on your own install before relying on it.

Verified against

Read from Invision Community 5.0.19 source: system/Extensions/BuildAbstract.php, system/Application/Application.php (build() at 3494, the two extension loops at 3713 and 3801, areWeBuilding() at 3814, extensions() at 911, allExtensions() at 355, constructExtensionClass() at 469, buildExtensionsJson() at 4931, canAccess() at 5299), system/Application/BuilderFilter.php, system/Application/BuilderIterator.php, applications/core/modules/admin/applications/applications.php (buildAll() 778, build() 825, download() 870), applications/core/modules/admin/developer/extensions.php (addExtension(), removeExtension()), system/Output/Plugin/Resource.php, applications/core/data/defaults/extensions/Build.txt, applications/core/data/extensions.json and applications/core/dev/lang.php. The sole shipped implementation is applications/core/extensions/core/Build/codemirror.php; a directory search of applications/*/extensions/core/Build found no others.


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.