demarches-normaliennes/app/javascript/shared/stimulus-loader.ts

120 lines
3.6 KiB
TypeScript
Raw Normal View History

import { Application } from '@hotwired/stimulus';
import invariant from 'tiny-invariant';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Loader = () => Promise<{ [key: string]: any }>;
const controllerAttribute = 'data-controller';
const controllers = import.meta.globEager('../controllers/*.{ts,tsx}');
const lazyControllers = import.meta.glob('../controllers/lazy/*.{ts,tsx}');
const controllerLoaders = new Map<string, Loader>();
export function registerControllers(application: Application) {
for (const [path, module] of Object.entries(controllers)) {
registerController(controllerName(path), module, application);
}
for (const [path, loader] of Object.entries(lazyControllers)) {
registerControllerLoader(controllerName(path), loader);
}
lazyLoadExistingControllers(application);
lazyLoadNewControllers(application);
}
function lazyLoadExistingControllers(
application: Application,
element?: Element
) {
queryControllerNamesWithin(element ?? application.element).forEach(
(controllerName) => loadController(controllerName, application)
);
}
function lazyLoadNewControllers(application: Application) {
new MutationObserver((mutationsList) => {
for (const { attributeName, target, type } of mutationsList) {
const element = target as Element;
switch (type) {
case 'attributes': {
if (
attributeName == controllerAttribute &&
element.getAttribute(controllerAttribute)
) {
extractControllerNamesFrom(element).forEach((controllerName) =>
loadController(controllerName, application)
);
}
break;
}
case 'childList':
lazyLoadExistingControllers(application, element);
break;
}
}
}).observe(application.element, {
attributes: true,
attributeFilter: [controllerAttribute],
subtree: true,
childList: true
});
}
function queryControllerNamesWithin(element: Element) {
return Array.from(
element.querySelectorAll(`[${controllerAttribute}]`)
).flatMap(extractControllerNamesFrom);
}
function extractControllerNamesFrom(element: Element) {
return (
element
.getAttribute(controllerAttribute)
?.split(/\s+/)
.filter((content) => content.length) ?? []
);
}
function loadController(name: string, application: Application) {
const loader = controllerLoaders.get(name);
controllerLoaders.delete(name);
if (loader) {
loader()
.then((module) => registerController(name, module, application))
.catch((error) =>
console.error(`Failed to autoload controller: ${name}`, error)
);
}
}
function controllerName(path: string) {
const [filename] = path.split('/').reverse();
return filename.replace(/_/g, '-').replace(/-controller\.(ts|tsx)$/, '');
}
function registerController(
name: string,
module: Awaited<ReturnType<Loader>>,
application: Application
) {
if (module.default) {
console.debug(`Registered default export for "${name}" controller`);
application.register(name, module.default);
} else {
const exports = Object.entries(module);
invariant(
exports.length == 1,
`Expected a single export but ${exports.length} exports were found for "${name}" controller`
);
const [exportName, exportModule] = exports[0];
console.debug(
`Registered named export "${exportName}" for "${name}" controller`
);
application.register(name, exportModule);
}
}
function registerControllerLoader(name: string, loader: Loader) {
console.debug(`Registered loader for "${name}" controller`);
controllerLoaders.set(name, loader);
}