Estructura del proyecto
El creador genera un proyecto pequeño con tres partes, sea cual sea el framework:
- El plugin (
plugin.ts): lo que se construye y se publica. Exportainitializeydispose. - Sus vistas: los componentes que el plugin inyecta en las regiones del shell.
- El sandbox (
src/sandbox.ts): arranca el shell de demostración con tu plugin. Solo se usa en desarrollo y no forma parte del plugin.
- React
- Lit
- Angular
my-plugin/
├── index.html ← development page
├── src/
│ ├── plugin.ts ← initialize and dispose
│ ├── sandbox.ts ← starts the shell (dev only)
│ └── views/
│ ├── main-view.tsx ← a React component
│ └── main-view.css
├── vite.config.ts ← npm run dev / npm run build
├── tsconfig.json
└── package.json
my-plugin/
├── index.html ← development page
├── src/
│ ├── plugin.ts ← initialize and dispose
│ ├── sandbox.ts ← starts the shell (dev only)
│ └── views/
│ ├── main-view.ts ← a Lit component
│ └── main-view.css
├── vite.config.ts ← npm run dev / npm run build
├── tsconfig.json
└── package.json
my-plugin/
├── src/ ← dev application
│ ├── index.html
│ └── sandbox.ts ← starts the shell
├── projects/my-plugin/ ← the plugin (a library)
│ └── src/
│ ├── plugin.ts ← initialize and dispose
│ └── views/
│ ├── angular-view.ts ← component → view
│ ├── main-view.component.ts
│ ├── main-view.component.html
│ └── main-view.component.css
├── angular.json ← app and library
├── vite.config.ts ← 2nd build step
├── tsconfig.json, tsconfig.app.json
└── package.json
La carpeta projects/my-plugin mantiene ese nombre se llame como se llame tu plugin: es un nombre interno del proyecto. El id del plugin se define en src/sandbox.ts y en package.json.
El plugin: plugin.ts
Es el punto de entrada del plugin, el único fichero que conoce el shell. El shell llama a initialize cuando carga el plugin y a dispose cuando lo descarga, cada vez con una API propia (consulta el shell de demostración).
- React
- Lit
- Angular
import { wrapReactViewFactory } from "@uxland/harmonix-adapters";
import type { DemoShellApi } from "@uxland/harmonix-demo-shell";
import { MainView } from "./views/main-view";
import styles from "./views/main-view.css?inline";
export const initialize = async (api: DemoShellApi) => {
// A view in the `main` region: a React component rendered inside a web component.
await api.regionManager.registerMainView({
id: "main",
factory: () => wrapReactViewFactory(MainView, styles, { fullHeight: true })({ api }),
});
// An item in the side menu that activates that view.
await api.regionManager.registerNavigationItem({ id: "menu", label: "My plugin", mainViewId: "main" });
};
export const dispose = async (api: DemoShellApi) => {
const { regions } = api.regionManager;
await api.regionManager.removeView(regions.sideMenu, "menu");
await api.regionManager.removeView(regions.main, "main");
};
import type { DemoShellApi } from "@uxland/harmonix-demo-shell";
import { MainView } from "./views/main-view";
export const initialize = async (api: DemoShellApi) => {
// A view in the `main` region: any HTML element, here a Lit component.
await api.regionManager.registerMainView({
id: "main",
factory: async () => Object.assign(new MainView(), { api }),
});
// An item in the side menu that activates that view.
await api.regionManager.registerNavigationItem({ id: "menu", label: "My plugin", mainViewId: "main" });
};
export const dispose = async (api: DemoShellApi) => {
const { regions } = api.regionManager;
await api.regionManager.removeView(regions.sideMenu, "menu");
await api.regionManager.removeView(regions.main, "main");
};
import { type ApplicationRef, provideZonelessChangeDetection } from "@angular/core";
import { createApplication } from "@angular/platform-browser";
import type { DemoShellApi } from "@uxland/harmonix-demo-shell";
import { angularView } from "./views/angular-view";
import { MainViewComponent } from "./views/main-view.component";
/** The Angular application that renders this plugin's views. */
let app: ApplicationRef | undefined;
export const initialize = async (api: DemoShellApi) => {
app = await createApplication({ providers: [provideZonelessChangeDetection()] });
// A view in the `main` region: an Angular component rendered as an HTML element.
await api.regionManager.registerMainView({ id: "main", factory: angularView(app, MainViewComponent, { api }) });
// An item in the side menu that activates that view.
await api.regionManager.registerNavigationItem({ id: "menu", label: "My plugin", mainViewId: "main" });
};
export const dispose = async (api: DemoShellApi) => {
const { regions } = api.regionManager;
await api.regionManager.removeView(regions.sideMenu, "menu");
await api.regionManager.removeView(regions.main, "main");
app?.destroy();
app = undefined;
};
Qué hace:
- Una vista es un objeto con un
idy unafactory: una función asíncrona que devuelve el elemento HTML que se debe mostrar. El shell llama a la factoría cuando tiene que pintar la vista. Sea cual sea el framework, el shell solo ve un elemento HTML. registerMainViewregistra la vista en la regiónmain, yregisterNavigationItemañade al menú lateral un elemento que la activa. Los dos son atajos del shell sobre el genéricoregisterView(region, view).- Las ids (
"main","menu") solo tienen que ser únicas dentro del plugin: el shell guarda las vistas comopluginId::viewId, así que dos plugins pueden usar las mismas ids. disposedeshace lo que ha hechoinitialize. El shell puede descargar y volver a cargar plugins sin recargar la página, así que un plugin no debe dejar nada atrás (consulta las buenas prácticas).
La vista
La vista principal recibe la API desde plugin.ts, como cualquier otra entrada, y la usa para hablar con otros plugins a través del broker: el botón publica un evento my-plugin:greeting y la vista, suscrita a él, cuenta los saludos que recibe.
- React
- Lit
- Angular
const GREETING = "my-plugin:greeting";
export const MainView = ({ api }: { api: DemoShellApi }) => {
const [greetings, setGreetings] = useState(0);
useEffect(() => {
const subscription = api.broker.subscribe(GREETING, () => setGreetings((count) => count + 1));
return () => subscription.dispose();
}, [api]);
return (
<section className="view">
{/* … */}
<button type="button" onClick={() => api.broker.publish(GREETING, { from: api.pluginInfo.pluginId })}>
Publish a greeting
</button>
<p className="result">Greetings received: {greetings}</p>
</section>
);
};
const GREETING = "my-plugin:greeting";
@customElement("my-plugin-main-view")
export class MainView extends LitElement {
@property({ attribute: false }) api!: DemoShellApi;
@state() private greetings = 0;
private subscription?: BrokerDisposableHandler;
connectedCallback() {
super.connectedCallback();
this.subscription = this.api.broker.subscribe(GREETING, () => {
this.greetings++;
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.subscription?.dispose();
}
render() {
return html`
<!-- … -->
<button type="button" @click=${() => this.api.broker.publish(GREETING, { from: this.api.pluginInfo.pluginId })}>
Publish a greeting
</button>
<p class="result">Greetings received: ${this.greetings}</p>
`;
}
}
const GREETING = "my-plugin:greeting";
@Component({
selector: "my-plugin-main-view",
templateUrl: "./main-view.component.html",
styleUrl: "./main-view.component.css",
encapsulation: ViewEncapsulation.ShadowDom,
})
export class MainViewComponent implements OnInit {
readonly api = input.required<DemoShellApi>();
readonly greetings = signal(0);
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
const subscription = this.api().broker.subscribe(GREETING, () => {
this.greetings.update((count) => count + 1);
});
this.destroyRef.onDestroy(() => subscription.dispose());
}
publishGreeting() {
this.api().broker.publish(GREETING, { from: this.api().pluginInfo.pluginId });
}
}
angular-view.ts convierte un componente en la factoría que espera una región: crea el componente en la aplicación Angular del plugin, le asigna sus entradas y devuelve su elemento.
export const angularView =
<C>(app: ApplicationRef, component: Type<C>, inputs: Record<string, unknown> = {}) =>
async (): Promise<HTMLElement> => {
const ref = createComponent(component, { environmentInjector: app.injector });
for (const [name, value] of Object.entries(inputs)) ref.setInput(name, value);
app.attachView(ref.hostView);
return ref.location.nativeElement;
};
Los estilos de la vista usan los colores del shell a través de variables CSS, como var(--hx-primary-text, #00756a), con un valor por defecto por si otro shell carga el plugin.
El sandbox: src/sandbox.ts
Arranca el shell de demostración y carga tu plugin desde su código fuente, así que cada cambio que guardas se recarga. Es el único lugar donde se define el id del plugin:
import { bootstrapPlugins, initializeShell } from "@uxland/harmonix-demo-shell";
initializeShell(document.body, { title: "my-plugin" });
await bootstrapPlugins([{ pluginId: "my-plugin", importer: () => import("./plugin") }]);
bootstrapPlugins recibe una lista: para probar tu plugin junto con otros, añádelos a ella. Una aplicación real hace lo mismo, pero obtiene la lista de plugins de la Plugin Store.
La configuración
| Fichero | Qué hace |
|---|---|
package.json | Los scripts (dev, build, typecheck) y las dependencias. name y version identifican el plugin cuando lo publicas, y module apunta al fichero construido. |
vite.config.ts | React y Lit: npm run dev sirve index.html, y npm run build construye solo src/plugin.ts, dejando el framework y el shell fuera del bundle. Angular: el segundo paso del build (consulta Build y publicación). |
angular.json | Solo Angular: la aplicación sandbox, que sirve npm run dev, y la librería my-plugin. |
tsconfig.json | TypeScript. Lit activa los decoradores que necesita. |