@druid-ui/host 1.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts ADDED
@@ -0,0 +1,290 @@
1
+ import { HttpFileLoader } from "./file-loader";
2
+ import { patch } from "./setup-snabbdom";
3
+ import { type VNode } from "snabbdom";
4
+ import {
5
+ HistoryRoutingStrategy,
6
+ type RoutingStrategy,
7
+ } from "./routing-strategy";
8
+ import { loadTranspile } from "./transpile";
9
+ import { createDomFromIdRec, dfunc, logfunc, setHook } from "./host-functions";
10
+ import { Event } from "./types";
11
+ import { setCb } from "./utils";
12
+
13
+ export interface Props {
14
+ prop: { key: string; value: any }[];
15
+ on: string[]; // [eventType, fnid]
16
+ }
17
+
18
+ // Dev-time log function exposed for components importing from "druid:ui/ui".
19
+ export function log(msg: string) {
20
+ // Reuse internal logfunc for consistent labeling.
21
+ logfunc(msg);
22
+ }
23
+
24
+ export class DruidUI extends HTMLElement {
25
+ private shadow: ShadowRoot;
26
+ private wrapperEl: HTMLElement;
27
+ private mountEl: HTMLElement;
28
+ private profile: boolean = false;
29
+ private currentVNode: VNode | null = null;
30
+ private _routeStrategy: RoutingStrategy = new HistoryRoutingStrategy();
31
+ private loader = new HttpFileLoader();
32
+ private _sandbox: boolean = true;
33
+ private _extensionObject: object = {};
34
+ private _entrypoint?: string;
35
+ private rootComponent: any;
36
+ private _connected: boolean = false;
37
+
38
+ public connectedCallback() {
39
+ this._connected = true;
40
+ if (this.rootComponent) {
41
+ this.rerender();
42
+ return;
43
+ }
44
+ this.reloadComponent();
45
+ }
46
+
47
+ public disconnectedCallback() {
48
+ this._connected = false;
49
+ }
50
+
51
+ public reloadComponent() {
52
+ if (!this._connected) {
53
+ console.warn("Component not connected, skipping reload.");
54
+ return;
55
+ }
56
+ const entrypoint = this._entrypoint;
57
+ if (!entrypoint) {
58
+ console.warn("No entrypoint attribute set.");
59
+ return;
60
+ }
61
+ if (!this.loader) {
62
+ console.warn("No file loader set.");
63
+ return;
64
+ }
65
+ if (this._sandbox) {
66
+ loadTranspile(entrypoint, this.loader).then(([moduleUrl, compile]) => {
67
+ this.loadEntrypointFromWasmUrl(moduleUrl, compile);
68
+ });
69
+ } else {
70
+ this.loadEntrypointFromJavaScriptUrl(entrypoint);
71
+ }
72
+ }
73
+
74
+ public getWrapper(): HTMLElement {
75
+ return this.wrapperEl;
76
+ }
77
+ set fileloader(loader: HttpFileLoader) {
78
+ this.loader = loader;
79
+ this.reloadComponent();
80
+ }
81
+
82
+ set extensionObject(obj: object) {
83
+ this._extensionObject = obj;
84
+ }
85
+ set entrypoint(entrypoint: string) {
86
+ this._entrypoint = entrypoint;
87
+ this.reloadComponent();
88
+ }
89
+
90
+ set sandbox(sandbox: boolean) {
91
+ this._sandbox = sandbox;
92
+ this.reloadComponent();
93
+ }
94
+
95
+ set routeStrategy(strategy: RoutingStrategy) {
96
+ this._routeStrategy = strategy;
97
+ this.rerender();
98
+ }
99
+
100
+ static get observedAttributes() {
101
+ return ["entrypoint", "path", "profile", "css", "style", "no-sandbox"];
102
+ }
103
+
104
+ attributeChangedCallback(
105
+ name: string,
106
+ oldValue: string | null,
107
+ newValue: string
108
+ ) {
109
+ switch (name) {
110
+ case "no-sandbox":
111
+ this._sandbox = newValue !== "true";
112
+ break;
113
+ case "entrypoint":
114
+ this.entrypoint = newValue;
115
+ break;
116
+ case "path":
117
+ if (oldValue) {
118
+ this.rerender();
119
+ }
120
+ break;
121
+ case "profile":
122
+ this.profile = newValue === "true";
123
+ break;
124
+ case "style":
125
+ const htmlString = newValue;
126
+ const styleEl = document.createElement("style");
127
+ styleEl.textContent = htmlString.trim();
128
+
129
+ // Insert style after all link elements
130
+ const lastLink = Array.from(
131
+ this.shadow.querySelectorAll('link[rel="stylesheet"]')
132
+ ).pop();
133
+ //clear previous style elements
134
+ const existingStyles = this.shadow.querySelectorAll("style");
135
+ existingStyles.forEach((style) => style.remove());
136
+
137
+ if (lastLink) {
138
+ this.shadow.insertBefore(styleEl, lastLink.nextSibling);
139
+ } else {
140
+ this.shadow.insertBefore(
141
+ styleEl,
142
+ this.shadowRoot?.firstChild || null
143
+ );
144
+ }
145
+ break;
146
+ case "css":
147
+ const css = newValue.split(",");
148
+ //clear previous css links
149
+ const existingLinks = this.shadow.querySelectorAll(
150
+ 'link[rel="stylesheet"]'
151
+ );
152
+ existingLinks.forEach((link) => link.remove());
153
+
154
+ for (const comp of css) {
155
+ const link = document.createElement("link");
156
+ link.rel = "stylesheet";
157
+ link.href = comp;
158
+ this.shadow.insertBefore(link, this.shadowRoot?.firstChild || null);
159
+ }
160
+ break;
161
+ }
162
+ }
163
+
164
+ constructor() {
165
+ super();
166
+ this.shadow = this.attachShadow({ mode: "open" });
167
+
168
+ this.wrapperEl = document.createElement("div");
169
+ this.wrapperEl.classList.add("druid-wrapper");
170
+ this.mountEl = document.createElement("div");
171
+ this.mountEl.classList.add("druid-mount");
172
+ this.mountEl.innerText = "Transpiling...";
173
+
174
+ this.wrapperEl.appendChild(this.mountEl);
175
+ this.shadow.appendChild(this.wrapperEl);
176
+ }
177
+
178
+ private getExtensionObject() {
179
+ return {
180
+ "druid:ui/ui": {
181
+ d: (element: string, props: Props, children: string[]) => {
182
+ return dfunc(element, props, children);
183
+ },
184
+ log: (msg: string) => {
185
+ logfunc(msg);
186
+ },
187
+ rerender: () => {
188
+ setTimeout(() => this.rerender(), 0);
189
+ },
190
+ setHook: setHook,
191
+ },
192
+ "druid:ui/utils": {
193
+ Event: Event,
194
+ },
195
+ ...this._extensionObject,
196
+ };
197
+ }
198
+
199
+ async loadEntrypointFromJavaScriptUrl(entrypoint: string) {
200
+ window["druid-ui"] = {
201
+ d: dfunc,
202
+ };
203
+
204
+ window["druid-extension"] = this.getExtensionObject();
205
+
206
+ const response = await this.loader.load(entrypoint);
207
+
208
+ const bundleContent = response.buffer;
209
+
210
+ //load bundleContent as a module
211
+ const blob = new Blob([bundleContent], { type: "application/javascript" });
212
+ const moduleUrl = URL.createObjectURL(blob);
213
+ const t = await import(/* @vite-ignore */ moduleUrl);
214
+
215
+ setCb(t.component.asyncComplete);
216
+ this.rootComponent = t;
217
+ this.rerender();
218
+ URL.revokeObjectURL(moduleUrl);
219
+ }
220
+
221
+ async loadEntrypointFromWasmUrl(
222
+ entrypoint: string,
223
+ loadCompile?: (file: string) => Promise<WebAssembly.Module>
224
+ ) {
225
+ const t = await import(/* @vite-ignore */ entrypoint!);
226
+
227
+ URL.revokeObjectURL(entrypoint);
228
+
229
+ const i = await t.instantiate(loadCompile, this.getExtensionObject());
230
+ setCb(i.component.asyncComplete);
231
+
232
+ this.rootComponent = i;
233
+ this.rerender();
234
+ }
235
+
236
+ rerender() {
237
+ if (!this.rootComponent) {
238
+ console.warn("Root component not initialized yet.");
239
+ return;
240
+ }
241
+ let renderStart;
242
+ if (this.profile) {
243
+ // Start profiling
244
+ renderStart = performance.now();
245
+ }
246
+
247
+ const rootId = this.rootComponent.component.init({
248
+ path: this._routeStrategy.getCurrentPath(),
249
+ });
250
+
251
+ if (this.profile) {
252
+ const initEnd = performance.now();
253
+ console.debug(
254
+ `Init completed in ${(initEnd - renderStart!).toFixed(2)} ms`
255
+ );
256
+ }
257
+
258
+ this.mountEl.innerHTML = "";
259
+ const dom = createDomFromIdRec(
260
+ rootId,
261
+ this.rerender.bind(this),
262
+ (nodeId, eventType, e) => {
263
+ this.rootComponent.component.emit(nodeId, eventType, e);
264
+ },
265
+ (href: string) => {
266
+ this._routeStrategy.navigateTo(href);
267
+ this.rerender();
268
+ }
269
+ );
270
+
271
+ if (dom instanceof String) {
272
+ console.warn("Root DOM is a string, cannot render:", dom);
273
+ return;
274
+ }
275
+ if (this.currentVNode) {
276
+ patch(this.currentVNode, dom);
277
+ } else {
278
+ patch(this.mountEl, dom);
279
+ }
280
+ this.currentVNode = dom;
281
+ if (this.profile) {
282
+ const renderEnd = performance.now();
283
+ console.debug(
284
+ `Render completed in ${(renderEnd - renderStart!).toFixed(2)} ms`
285
+ );
286
+ }
287
+ }
288
+ }
289
+
290
+ customElements.define("druid-ui", DruidUI);
package/src/utils.ts ADDED
@@ -0,0 +1,53 @@
1
+ import hyperid from "hyperid";
2
+
3
+ type Callback = (id: string, result: { tag: "ok" | "err"; val: any }) => void;
4
+
5
+ let cb: Callback | undefined;
6
+
7
+ const pending: Array<{ id: string; result: Parameters<Callback>[1] }> = [];
8
+
9
+ const dispatch = (id: string, result: Parameters<Callback>[1]) => {
10
+ if (cb) {
11
+ cb(id, result);
12
+ return;
13
+ }
14
+
15
+ pending.push({ id, result });
16
+ };
17
+ export const setCb = (
18
+ callback: (id: string, result: { tag: "ok" | "err"; val: any }) => void
19
+ ) => {
20
+ cb = callback;
21
+
22
+ if (pending.length === 0) {
23
+ return;
24
+ }
25
+
26
+ // Flush any results that arrived before the callback was registered.
27
+ while (pending.length > 0) {
28
+ const { id, result } = pending.shift()!;
29
+ cb(id, result);
30
+ }
31
+ };
32
+
33
+ export const PromiseToResult = <T>(
34
+ promiseFn: (...args: any[]) => Promise<T>
35
+ ) => {
36
+ return (...args: any[]) => {
37
+ const id = hyperid().uuid;
38
+ promiseFn(...args)
39
+ .then((result) => {
40
+ dispatch(id, {
41
+ tag: "ok",
42
+ val: result,
43
+ });
44
+ })
45
+ .catch((error) => {
46
+ dispatch(id, {
47
+ tag: "err",
48
+ val: error instanceof Error ? error.message : String(error),
49
+ });
50
+ });
51
+ return id;
52
+ };
53
+ };
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
package/src/window.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Augment the Window interface to include 'druid'.
2
+ // We define a lightweight shape for better intellisense; extend as needed.
3
+
4
+ type DruidAPI = Record<string, any>;
5
+
6
+ declare global {
7
+ interface Window {
8
+ "druid-ui": {
9
+ d: (...args: any[]) => any;
10
+ };
11
+ "druid-extension"?: DruidAPI;
12
+ }
13
+ }
14
+
15
+ // Mark this file as a module so global augmentation is always applied reliably.
16
+ export {};