Module Assets and Logo
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
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->url . 'assets/style/app.css'.
Walkthrough
Add a Stylesheet
- Create
assets/style/app.cssin the module directory. - Prefix class names with something specific to the module. The panel's stylesheet is on the same page, and a generic name collides silently.
- Reference images with a relative address, and put them in
assets/images.
Load It on the Right Page
- Decide where it belongs. A page your module builds returns its own styles and scripts; anything else goes through a head hook.
- In the hook body, return an empty string unless the page needs the asset.
- Build the address from the URL property, with a cache-busting value from the file's modification time.
- Confirm it appears in the network panel on that page, and not on an unrelated one.
Add a Logo
- Put an image called
logoin the module root, with ansvg,webp,png,jpg,jpegorgifextension. It is found by name. - For another file name, or one in a subdirectory, name it in the configuration under
meta.logo. - Reload the module list; the icon appears next to the name.
Reference
Logo Resolution
// 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;
| Order | Source | How it is turned into an address |
|---|---|---|
| 1 | meta.logo in the configuration, or a top level logo entry | Used unchanged when it starts with a protocol. Otherwise resolved against the module directory, so images/logo.png works |
| 2 | A file called logo in the module root with a supported extension | Found by pattern, resolved against the module directory. Keep one file |
| 3 | A file named after the module, lowercased, in the shared admin logo directory | The last resort. A module with no image still shows a brand icon |
| 4 | Nothing matched | An empty string; the caller shows a placeholder |
Asset Injection Points
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.
// 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:
(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:
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
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.
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.
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.
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 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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.