Module Assets and Logo

3 views Markdown

Ship stylesheets, scripts and images inside your module, link them without hard-coding a path, and give it a panel icon.

Overview

A module's static files live in the module directory and travel with it. Nothing is copied or registered. The directory is reachable over the web, and the base class hands you its address.

Two properties do all the work and are easy to mix up: a filesystem path and a public URL. The logo is separate, with its own resolution order, and the one asset the platform finds by itself.

Prerequisites

  • A module extending one of the base classes, so the directory and URL properties are populated. Plain-class types build them themselves.
  • A hook file, if the asset must reach a page the module does not own. See Registering Hooks from a Module.
  • No build step, no manifest, no asset pipeline.

Structure

The Assets Directory

layout
coremio/modules/{Type}/{Name}/
├── logo.svg            # the panel icon. Module ROOT, not assets/
└── assets/
    ├── style/          # css
    ├── js/             # javascript
    └── images/         # images used INSIDE your interface, never the logo

Do not repeat the module name in file names. The directory already says which module a file belongs to, so assets/js/app.js is the convention. A relative address such as url(../images/icon.svg) resolves in a stylesheet, because the file is served from its real location.

The Two Paths

$this->dir Absolute filesystem path to the module directory, ending in a separator. Read files, check existence, or take a modification time for cache busting.
$this->url Absolute public URL of the same directory, ending in a slash. Everything in markup is built from it: $this->url . 'assets/style/app.css'.
Never a literal path Both are set by the base constructor, before your first method runs. A literal path works on your machine only.

Walkthrough

Add a Stylesheet

  1. Create assets/style/app.css in the module directory.
  2. Prefix class names with something specific to the module. The panel's stylesheet is on the same page, and a generic name collides silently.
  3. Reference images with a relative address, and put them in assets/images.

Load It on the Right Page

  1. Decide where it belongs. A page your module builds returns its own styles and scripts; anything else goes through a head hook.
  2. In the hook body, return an empty string unless the page needs the asset.
  3. Build the address from the URL property, with a cache-busting value from the file's modification time.
  4. Confirm it appears in the network panel on that page, and not on an unrelated one.
  1. Put an image called logo in the module root, with an svg, webp, png, jpg, jpeg or gif extension. It is found by name.
  2. For another file name, or one in a subdirectory, name it in the configuration under meta.logo.
  3. Reload the module list; the icon appears next to the name.

Reference

Logo Resolution

signatures
// On the instance: resolves for this module's own name and type.
public function logo(): string;

// Statically, when you have no instance. Note the type DEFAULT: a call that omits it
// looks the module up as a server module.
public static function logo(string $name = '', $type = 'Servers'): string;
OrderSourceHow it is turned into an address
1meta.logo in the configuration, or a top level logo entryUsed unchanged when it starts with a protocol. Otherwise resolved against the module directory, so images/logo.png works
2A file called logo in the module root with a supported extensionFound by pattern, resolved against the module directory. Keep one file
3A file named after the module, lowercased, in the shared admin logo directoryThe last resort. A module with no image still shows a brand icon
4Nothing matchedAn empty string; the caller shows a placeholder

Asset Injection Points

ui:admin.head.css Return a complete stylesheet tag, or an empty string. It appears in the panel's head, once per listener.
ui:admin.head.js The same, for scripts. Several tags in one string is normal: a configuration object and the script that reads it.
ui:client.head.css The customer-facing equivalent. Keep them apart: a panel stylesheet on a public page leaks your interface into the theme.
ui:client.head.js Scripts for the customer side. Anything on a public page must survive a signed-out visitor.
page_styles · page_scripts Keys on the array a module admin page returns. Preferred over a hook for a page your module owns: the gating is implicit.
Cache busting Append the file's modification time as a query value, falling back to meta.version in the configuration. The instance has no version property, so read it from $this->config. Without it an operator gets the old file after an update and reports an unreproducible bug.

Example

The whole pattern in one hook file. A page gate keeps the asset off the rest of the panel. The configuration is handed to the script, not scraped from the markup.

hooks.php
// This file runs on EVERY request, for this module, enabled or not. Only register here.
$acme_on_page = fn () => in_array(Controllers::$cname ?? '', ['tickets', 'services'], true);

Hook::add('ui:admin.head.css', 1, function () use ($acme_on_page) {
    if (!$acme_on_page()) return '';

    $m = Modules::getInstance('Addons', 'Acme');

    // dir for the file on disk, url for the address in the markup.
    $v = @filemtime($m->dir . 'assets' . DS . 'style' . DS . 'app.css') ?: ($m->config['meta']['version'] ?? '1.0');

    return '<link rel="stylesheet" href="' . $m->url . 'assets/style/app.css?v=' . $v . '">';
});

Hook::add('ui:admin.head.js', 1, function () use ($acme_on_page) {
    if (!$acme_on_page()) return '';

    $m    = Modules::getInstance('Addons', 'Acme');
    $lang = $m->lang;

    // Everything the script needs, encoded once. Escaping flags matter: this string
    // is printed inside a script tag in an HTML document.
    $config = Utility::jencode([
        'endpoint' => Controllers::$init->ControllerURI(),
        'i18n'     => [
            'run'    => $lang['btn-run'] ?? 'Run',
            'failed' => $lang['err-failed'] ?? 'Request failed',
        ],
    ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);

    $v = @filemtime($m->dir . 'assets' . DS . 'js' . DS . 'app.js') ?: ($m->config['meta']['version'] ?? '1.0');

    return '<script>window.Acme = ' . $config . ';</script>'
         . '<script src="' . $m->url . 'assets/js/app.js?v=' . $v . '"></script>';
});

The reading side, which never guesses an address and never re-derives a label:

assets/js/app.js
(function () {
    var cfg = window.Acme;
    if (!cfg) return;                     // asset loaded on a page it was not meant for

    document.querySelectorAll('[data-acme-run]').forEach(function (btn) {
        btn.textContent = cfg.i18n.run;
        btn.addEventListener('click', () => WcpRequest(cfg.endpoint, {
            data: { operation: 'use_addon_method', method: 'run', id: btn.dataset.acmeRun },
        }));
    });
})();

The logo, declared only when the file is not called logo at the module root:

config.php
return [
    'meta' => [
        'name'    => 'Acme',
        'version' => '1.0',

        // Resolved against the module directory. A subpath is allowed, and an
        // address that starts with a protocol is used exactly as written.
        'logo'    => 'assets/images/brand.svg',
    ],
];

Pitfalls

The logo cache is keyed by module name alone, not by type

Two modules of different types that share a name share one resolved logo per request. The winner is whichever was asked for first. Give a module a name no other type uses.

The logo helper does not load the module

It reads the configuration from the cache, so a configured logo name counts only when something already loaded that module. Called cold it falls through to a file called logo — which is why that name is safe. From an instance it is always safe.

A hard-coded modules path works only on your machine

The same file has a different address on every installation. The application may live in a subdirectory, on another host, or behind another protocol. Both properties account for that; a literal string does not, and it fails as a missing stylesheet elsewhere.

An ungated asset is loaded on every screen

Hook files run for every module directory on every request, enabled or not. A head listener with no page condition adds them to every panel page. Gate on the controller, and have the script return early when its configuration is absent.

The logo is not an interface image

The panel icon stays at the module root, where the resolver looks for it. Images used inside your screens belong in the assets directory. Apart, re-branding is one file, not a hunt through the interface.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.