Skip to main content

The asset registry

A host does not list its extensions. Every jar that brings a node type — the calendar, the chart, a plugin of your own — declares what the browser needs from it: a stylesheet, an ES module, or an extension whose install(renderer, { bus }) registers its node types. mc-semantic-ui-core collects those declarations into a SuiAssetRegistry and serves them as one module, /sui/assets.js. Put a jar on the classpath and its node types render on the next page load; take it off and they are gone.

In the host​

// app.js
import { SuiRenderer, installDefaultHandlers } from "/sui/renderer.js";
import { SuiEventBus } from "/sui/eventbus.js";
import { installAll } from "/sui/assets.js";

const root = document.getElementById("sui-root");
const renderer = installDefaultHandlers(new SuiRenderer(root));
const bus = new SuiEventBus(renderer, root);
await installAll(renderer, bus); // before the first render
bus.start("/");

installAll links every stylesheet the page does not carry yet, starts all imports at once, installs the extensions one after another in the registry's order, and resolves once the stylesheets have loaded — so the first render has every node type and every style. A module that fails to load, an extension without install, an install that throws: each is logged and skipped, and the others go on. It resolves to a report, [{ id, ok, error? }].

The page head. With a hand-written index.html there is nothing to add — installAll links the stylesheets. A host that renders its own head can link them there, before the first paint:

@Autowired SuiAssetRegistry assets;
// …
String head = assets.headTags(request.getContextPath());
// <link rel="stylesheet" href="/sui-ext/calendar/calendar.css" data-sui-asset="calendar.css">…

installAll recognises those links by data-sui-asset and does not add them twice. Pages the core renders server-side (UiPage with SSR switched on) get the links in their head automatically.

Declaring assets​

In a jar, as META-INF/sui/assets.json — the way every extension of this project does it:

[
{ "id": "calendar.css", "kind": "css", "href": "/sui-ext/calendar/calendar.css" },
{ "id": "calendar", "kind": "extension", "href": "/sui-ext/calendar/extension.js" }
]

In code, as a SuiAssetContribution bean — for a list that depends on configuration:

@Bean
SuiAssetContribution officeAssets() {
return () -> List.of(
SuiAsset.css("office.css", "/office/office.css"),
SuiAsset.extension("office", "/office/extension.js"));
}
FieldMeaning
idUnique name: letters, digits, ., _, -. By convention <name> for the module and <name>.css for its stylesheet.
kindcss — linked in the head; module — imported, nothing more; extension — imported, then install(renderer, { bus }); icons — an SVG sprite for the icon tokens starting with prefix (below).
hrefWhere the browser loads it: a path on this server, starting with a single /. The servlet context path is added when the registry serves it.
orderLoad order, and the rank among declarations of the same id. Default 0.
disabledtrue takes the asset off the page — see below.
prefixFor icons only, and required there: the token prefix the sprite serves, lowercase-kebab ending in - (brand-).

The files themselves are ordinary static resources: put them under META-INF/resources/… in the jar and Spring Boot serves them.

Icon sets​

A plugin that brings its own icons — provider logos such as brand-microsoft and brand-google — declares its sprite as an icon set:

[
{ "id": "brand-icons", "kind": "icons", "prefix": "brand-", "href": "/sui-ext/brand/brand-icons.svg" }
]
SuiAsset.icons("brand-icons", "brand-", "/sui-ext/brand/brand-icons.svg")

Every icon token that starts with the prefix comes from that sprite, in the browser and on the server alike: installAll(renderer, bus) adds the sets to the renderer before it installs anything else, and server-rendered pages resolve the same tokens to the same markup. Any other token keeps coming from the standard sprite, /sui/icons.svg.

  • Several plugins, several sets. Each prefix is served by its own sprite; a token takes the longest prefix it starts with, so acme-logo- can sit inside acme-.
  • Two sets for one prefix compete like two declarations of one id: the higher order wins; on a tie the id that sorts last, with a warning in the log. disabled takes a set off the page, and its tokens go back to the standard sprite.
  • The sprite is a plain SVG of <symbol id="…"> elements, the id being the full token (brand-microsoft). An icon from a set carries the class sui-icon--set and is drawn filled, not stroked: a shape with a fixed fill keeps its colour (a logo), a shape with fill="currentColor" — or with no fill — follows the text colour. The standard icons stay as they are.
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="brand-microsoft" viewBox="0 0 24 24">
<rect x="1" y="1" width="10" height="10" fill="#F25022"/>
<rect x="13" y="1" width="10" height="10" fill="#7FBA00"/>
<rect x="1" y="13" width="10" height="10" fill="#00A4EF"/>
<rect x="13" y="13" width="10" height="10" fill="#FFB900"/>
</symbol>
<symbol id="brand-github" viewBox="0 0 24 24">
<path fill="currentColor" d="…"/>
</symbol>
</svg>

A plugin no longer needs to replace the icon resolver for this — which only ever worked for one plugin, since the next one replaced it again. A plugin that must do more than a sprite can wrap the resolver instead of replacing it; see the icon library.

A node type without Java​

An extension that registers its own node type, renderer.register("chat-widget", fn) in install, needs no Java class for it. The server sends a UiCustom, which is written flat with the type you give it:

UiCustom.of("chat-widget").id("draft-chat")
.prop("session", sid)
.prop("api", "/chat-api/sessions");
// {"type":"chat-widget","id":"draft-chat","session":"…","api":"/chat-api/sessions"}
export function install(renderer) {
renderer.register("chat-widget", node => `<div id="${node.id}" data-api="${node.api}"></div>`);
// How the node describes itself when the screen is read back — see
// "Reading the screen". Without it, a snapshot names the node and says
// nothing about what it holds.
renderer.registerOutline("chat-widget", node => ({ title: node.title, text: node.draft }));
}
  • The standard fields (id, title, cssClass, onClick and the other triggers, display) work as on any node, and a property cannot overwrite one. The type is lowercase-kebab and not a core type such as list.
  • Without a registered renderer the node renders as <div class="sui-custom-missing" data-type="chat-widget">, a small dashed box naming the type. The console names it once, and the rest of the page is unaffected. The server writes the same element unless a jar ships templates/sui/chat-widget.hbs, which renders the node with its properties ({{session}}).
  • installAll redraws those placeholders once the extensions are installed, and the event bus does so after every change, so a server-rendered page picks the real widget up as soon as the plugin is in.
  • Reading JSON back, for the editor, JavaFX or a test, a type Jackson does not know becomes a UiCustom with the other fields as its properties.

Overriding and switching off​

Several declarations may share an id. The highest order wins. A plugin replaces the calendar's stylesheet with its own by declaring the same id with a higher order:

[{ "id": "calendar.css", "kind": "css", "href": "/acme/calendar.css", "order": 10 }]
/* /acme/calendar.css — keep the original, change what you want */
@import url("/sui-ext/calendar/calendar.css");
.sui-calendar { --sui-calendar-hour-h: 40px; }

A winner that is disabled takes the id off the page — a host that does not want the kanban's browser side:

[{ "id": "kanban", "disabled": true, "order": 1 }]

A tie — the same id and the same order from two places — is resolved the same way on every start (a run-time registration beats a bean, a bean beats a jar, and between two of a kind the one whose source sorts last) and logged as a warning, because it is almost always an accident. Give one of them a higher order to decide.

The resolved assets load in order, then by id.

Registering at run time​

For a marketplace, the registry changes while the app runs:

assets.register(SuiAsset.extension("gantt", "/market/gantt/extension.js"));
assets.register(SuiAsset.css("gantt.css", "/market/gantt/gantt.css"));
assets.unregister("gantt"); // only run-time declarations
assets.register(SuiAsset.disabled("chart", 100)); // switch off a shipped one

The registry does not check who is asking — put register behind your own admin endpoint and its permissions. It checks the asset: the id must be well-formed and the href a path on this server — no scheme, no host, no //, no backslash, quote or whitespace — since a file the page loads runs code. A refused asset throws IllegalArgumentException and changes nothing.

Every change moves the registry's generation on, and with it the ETag of /sui/assets.js: the next page load fetches the new module.

The endpoints​

GET /sui/assetsThe resolved assets as JSON: id, kind, href, url (under the context path), order.
GET /sui/assets.jsThe module: installAll(renderer, bus), linkStyles(), assets.

Both answer with an ETag and Cache-Control: no-cache — the browser keeps its copy and revalidates, getting a 304 until something changes. Switch the registry off with mindconnect.sui.assets.enabled=false; define a SuiAssetRegistry bean of your own to replace it.

A page without a server​

A static page — a demo, a docs site — has no /sui/assets.js to ask for. SuiAssetsExport writes the same module as a file at build time, from the assets.json of every jar on the build's classpath:

<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<executions>
<execution>
<id>export-sui-assets</id>
<phase>process-resources</phase>
<goals><goal>java</goal></goals>
<configuration>
<mainClass>ai.mindconnect.ui.assets.SuiAssetsExport</mainClass>
<classpathScope>compile</classpathScope>
<arguments>
<argument>${project.build.directory}/dist/sui/assets.js</argument>
<argument>..</argument> <!-- base in front of every href -->
</arguments>
</configuration>
</execution>
</executions>
</plugin>

The module resolves every url against its own location, so a relative base works wherever the files are hosted: .. for a module in sui/ beside sui-ext/. The page imports it as it would the served one — import { installAll } from "./sui/assets.js". The widget demo (demo/mc-sui-widget-demo) is built this way: its extensions are Maven dependencies, and demo.js lists none of them.

An example​

With a server: the file-explorer demo (demo/mc-sui-file-explorer-demo) depends on the calendar and kanban extensions and wires neither: /agenda shows both, its bootstrap calls installAll, and DemoAssets contributes the demo's own stylesheet and overrides calendar.css with order 10.