JS Architecture
Why design/.../web/ts exists, and how the core/template seam keeps Nebula stable without locking templates out of the familiar Alpine.js ergonomics.
The thesis
Templates are the part of an admin that changes most — colors, copy, the order of fields, the wrapping div, the Alpine click handler. They want to be easy to edit, no build step, familiar syntax.
But the contract underneath — "register a model, validate it, serialize it into the form on submit" — is the part that needs to stay stable, typed, and tested. If a template breaks, the page looks wrong. If the contract breaks, every form across every admin page breaks at once.
Nebula splits the difference: a TypeScript core ships the contract — model store, validator, factory APIs — bundled and versioned. Templates use plain Alpine x-data on top of that core, with the same @click / x-show / x-model they already know.
What lives where
In web/ts (TypeScript core)
- Model store + serialization contract
- Validator (required, min, max, email, pattern, custom)
- Toast, modal mixin, focus trap
- Field factories (
nebulaField_text,nebulaField_select, …) - Section factories (
nebulaForm,nebulaGrid,nebulaRuleEditor, …) - System config wiring (depends, test connection, image preview)
- Vendor compat shims (
setLocation,$for legacy onclick handlers) - Type definitions exported for module authors
In view/adminhtml/templates/*.phtml
- HTML structure (the part that defines the page)
x-data="factory(config)"bindings to instantiate factories- Inline directives (
@click,x-show,x-model,x-text) - Per-template state (
x-data="{ open: false }"for ad-hoc UI) - Conditional rendering, loops (
x-for) - Tailwind classes, inline SVG, escape calls
web/ts compiles to a versioned bundle; phtml templates can't accidentally import from it. They communicate only via the registered factory names, like nebulaField_text. Rename a factory in TS and you must touch every template that uses it — but that's a deliberate, grep-able break, not a silent contract drift.The factory pattern
A factory is a TypeScript function registered as an Alpine.data component. It takes a typed config object and returns a state object with methods. Templates call it from x-data.
The TS factory
import { createBaseField } from './base';
import type { NebulaFieldConfig } from '../types';
export function registerTextField(): void {
document.addEventListener('alpine:init', () => {
window.Alpine.data('nebulaField_text', (config: NebulaFieldConfig = {}) =>
createBaseField(config),
);
});
}The base behavior — auto-register
export function createBaseField(config: NebulaFieldConfig): BaseFieldState {
return {
value: config.value ?? '',
fieldName: config.fieldName ?? '',
error: null,
init() {
if (this.fieldName) {
const store = window.Alpine.store('nebulaModels') as NebulaModelStore;
store.register('field:' + this.fieldName, this);
}
},
serialize() {
return [{ name: this.fieldName, value: this.value }];
},
validate() {
this.error = window.Nebula.validateField(this.$el);
return this.error === null;
},
};
}The template — Alpine binding
<div x-data="nebulaField_text({
fieldName: '<?= $block->escapeHtmlAttr($fieldName) ?>',
value: <?= json_encode($fieldValue) ?>,
required: <?= $isRequired ? 'true' : 'false' ?>
})">
<input type="text"
x-model="value"
@blur="validate()"
class="nebula-input">
<p x-show="error" x-text="error" class="error"></p>
</div>Save: store collects, serializes, submits
async saveWithBack(form: HTMLFormElement) {
const models = window.Alpine.store('nebulaModels');
if (!models.validateAll()) return;
if (!Nebula.validateForm(form)) return;
Nebula.showSaveLoader();
models.serializeAll(form); // injects <input type="hidden"> for every model
form.submit(); // Magento save controller receives standard POST
}Bundle layout
esbuild builds 11 IIFE bundles in parallel. Page templates load only what they need; the core boots once and stays cached.
| Bundle | Loaded on | Contains |
|---|---|---|
| nebula-core.js | every admin page | Alpine + plugins, model store, validator, toast, modal, all field factories, system-config bridges |
| nebula-form.js | form pages | nebulaForm factory: save orchestration, Ctrl+S, save loader |
| nebula-grid.js | grid pages | nebulaGrid factory: row selection, mass actions, filters |
| nebula-rule-editor.js | price-rule pages | Standalone rule conditions/actions tree |
| nebula-catalog-product.js | catalog product edit | 12 product-edit specific factories |
| nebula-bundle-options.js | bundle product edit | Bundle option builder |
| nebula-custom-options.js | product custom options | Custom option builder |
| nebula-customer-addresses.js | customer edit | Address book |
| nebula-pagebuilder.js | page builder fields | Page Builder shim |
| nebula-vendor-chart.js | dashboard | window.Chart (chart.js v4.4) |
| nebula-vendor-sortable.js | pages with reorder UI | window.Sortable (sortablejs v1.15) |
Boot order
<head>
<script src="nebula-core.js"></script> ← Alpine attached, plugins installed,
all factories register listeners on
alpine:init, vendor compat installed,
system-config bridges scheduled.
Alpine.start() DEFERRED to DOMContentLoaded
so sibling bundles can hook in first.
</head>
<body>
...
<script src="nebula-form.js"></script> ← nebulaForm factory registers listener
<script src="nebula-grid.js"></script> ← nebulaGrid factory registers listener
<!-- DOMContentLoaded -->
<!-- bootSystemConfig() runs: depends + test-connection + image-preview -->
<!-- Alpine.start() fires alpine:init: every registered factory wires up -->
<!-- Each x-data="..." component instantiates and calls init() -->
</body>The deferred Alpine.start() matters: bundles loaded after nebula-core need a chance to register their factories before init fires. Without the defer, late bundles would miss the bus and their components would log "factory undefined".
Author your own factory
Concrete: a vendor module ships a custom pricing widget that needs to participate in the form save flow.
Write the factory in TypeScript
import type { NebulaFieldConfig, NebulaModel, SerializedPair } from '@qoliber/nebula/types';
interface PricingConfig extends NebulaFieldConfig {
pricingRules?: Record<string, number>;
}
export function createPricingWidget(config: PricingConfig = {}): NebulaModel {
return {
fieldName: config.fieldName ?? '',
value: config.value ?? 0,
rules: config.pricingRules ?? {},
init() {
if (this.fieldName) {
const store = window.Alpine?.store('nebulaModels');
if (store) store.register('widget:' + this.fieldName, this);
}
},
serialize(): SerializedPair[] {
if (!this.fieldName) return [];
return [{ name: this.fieldName, value: this.value }];
},
};
}
export function registerPricingWidget(): void {
document.addEventListener('alpine:init', () => {
window.Alpine.data('acmePricingWidget', (config?: PricingConfig) =>
createPricingWidget(config),
);
});
}Bundle entry point
import { registerPricingWidget } from './pricing-widget';
registerPricingWidget();Build it with esbuild however you like — your module owns its own build script. Output toview/adminhtml/web/js/acme-pricing.js.
Use it from a template
<div x-data="acmePricingWidget({
fieldName: 'product[pricing]',
pricingRules: <?= json_encode($rules) ?>
})">
<input x-model="value" type="number" step="0.01">
</div>
<!-- Loaded as a regular admin script via layout XML or scripts.phtml -->
<script src="<?= $block->getViewFileUrl('Acme_Module::js/acme-pricing.js') ?>"></script>Done
Your widget is now part of the standard save flow. The form's nebulaForm factory will call store.serializeAll() at submit time, your widget's serialize() will return its { name, value }, and Magento receives it in the POST body. Zero changes to the form save controller.
window.Alpine.data('name', fn) works just as well — TS is a recommendation for type safety, not a hard dependency.What gets tested
vitest unit tests (web/ts/__tests__/)
- Model store: register, serialize, validate, destroy
- Validator: every built-in rule + custom rules
- Toast: create, dismiss, pause/resume
- Grid: row selection, filter manipulation
- Rule editor: tree build + serialization
- Field factories where state is non-trivial
npm test # run once
npm run test:watch # vitest watch modeManual / browser-side
- End-to-end form save: fill fields, click save, check Magento receives correct POST
- Validation UX: invalid input, blur, see error message
- Toast appearance and timing
- DOM-level Alpine bindings (these are Alpine's job, not ours)
- Cross-browser select rendering, dark-mode contrast
Open the console and call Alpine.store('nebulaModels').debug() to see every registered model on the current page.
Build process
import * as esbuild from 'esbuild';
const bundles = [
{ entry: 'ts/nebula-core.ts', out: 'js/dist/nebula-core.js' },
{ entry: 'ts/nebula-form.ts', out: 'js/dist/nebula-form.js' },
{ entry: 'ts/nebula-grid.ts', out: 'js/dist/nebula-grid.js' },
// ... 8 more
];
await Promise.all(bundles.map(b => esbuild.build({
entryPoints: [b.entry],
outfile: b.out,
bundle: true,
format: 'iife',
target: 'es2022',
minify: true,
sourcemap: process.env.SOURCEMAPS !== 'false',
})));npm run build compiles all 11 bundles in parallel (~50ms each, all done in < 500ms).npm run watch:js rebuilds on change. SOURCEMAPS=false npm run build for production-grade output (smaller bundles, no .map files).
Why this matters
Stable contracts
The model store API is typed and tested. Any 3rd-party widget that implements the interface plugs in.
Familiar templates
Templates are still phtml + Alpine. No JSX, no React, no module loader. Edit a class and reload.
Versioned bundles
No more loose .min.js files in the repo. Vendors (Chart.js, Sortable) live in package.json and ship through esbuild like everything else.
Reference paths
- design/adminhtml/Qoliber/Nebula/web/ts/nebula-core.ts (entry)
- design/adminhtml/Qoliber/Nebula/web/ts/types.ts (public types)
- design/adminhtml/Qoliber/Nebula/web/ts/models.ts (store + serialize contract)
- design/adminhtml/Qoliber/Nebula/web/ts/validate.ts (validation rules)
- design/adminhtml/Qoliber/Nebula/web/ts/fields/* (every field factory)
- design/adminhtml/Qoliber/Nebula/web/ts/components/* (system-config bridges, modals)
- design/adminhtml/Qoliber/Nebula/web/build.mjs (esbuild config)
- design/adminhtml/Qoliber/Nebula/web/__tests__/* (vitest)