File size: 8,570 Bytes
6918275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
import * as api from "./api.js";
import * as cache from "./cache.js";

/**
 * @typedef {string} WebuiExtension
 */



/**
 * @typedef {Object} LoadWebuiExtensionsResponse
 * @property {WebuiExtension[]} extensions
 */

/**
 * @typedef {Object} JsExtensionImport
 * @property {string} path
 * @property {{ default: (...data: any[]) => (void|Promise<void>) }} module
 */

const JS_CACHE_AREA = "frontend_extensions_js(extensions)(plugins)";
const HTML_CACHE_AREA = "frontend_extensions_html(extensions)(plugins)";
let alpineInitialized = false;
const LOADING_SELECTOR = "x-component > .loading:empty, x-extension.loading";

export let initialHtmlExtensionsLoaded = false;

function checkInitialLoadComplete() {
  if (initialHtmlExtensionsLoaded || !alpineInitialized || document.querySelector(LOADING_SELECTOR)) return;
  globalThis.Alpine.nextTick(() => {
    if (initialHtmlExtensionsLoaded || document.querySelector(LOADING_SELECTOR)) return;
    initialHtmlExtensionsLoaded = true;
    document.dispatchEvent(new Event("webui-extensions-loaded"));
  });
}

export const API_EXTENSION_EXCLUDED_ENDPOINTS = new Set([
  "/api/load_webui_extensions",
]);

export function clearCache() {
  cache.clear(JS_CACHE_AREA);
  cache.clear(HTML_CACHE_AREA);
}

function manifestExtensionPaths(assetType, extensionPoint) {
  const manifest = globalThis.runtimeInfo?.webuiExtensions;
  if (!manifest || typeof manifest !== "object") return null;
  const extensionsByPoint = manifest[assetType];
  if (!extensionsByPoint || typeof extensionsByPoint !== "object") return null;
  const extensions = extensionsByPoint[extensionPoint];
  return Array.isArray(extensions) ? extensions : [];
}

/**
 * Call all JS extensions for a given extension point.
 *
 * @param {string} extensionPoint
 * @param {...any} data
 * @returns {Promise<void>}
 */
export async function callJsExtensions(extensionPoint, ...data){
  const extensions = cache.get(JS_CACHE_AREA, extensionPoint, null) || await loadJsExtensions(extensionPoint);
  for(const extension of extensions){
    try{
      await extension.module.default(...data);
    }catch(error){
      console.error(`Error calling extension: ${extension.path}`, error);
    }
  }
}

/**
 * Load JS extension modules for an extension point.
 *
 * @param {string} extensionPoint
 * @returns {Promise<JsExtensionImport[]>}
 */
export async function loadJsExtensions(extensionPoint) {
  try {
    const cached = cache.get(JS_CACHE_AREA, extensionPoint, null);
    if (cached != null) return cached;

    const manifestExtensions = manifestExtensionPaths("js", extensionPoint);
    /** @type {WebuiExtension[]} */
    let extensionPaths = manifestExtensions;
    if (extensionPaths == null) {
      /** @type {LoadWebuiExtensionsResponse} */
      const response = await api.callJsonApi(`/api/load_webui_extensions`, {
        extension_point: extensionPoint,
        filters: ["*.js", "*.mjs"],
      });
      extensionPaths = response.extensions;
    }
    /** @type {JsExtensionImport[]} */
    const imports = await Promise.all(
      extensionPaths.map(async (path) => ({
        path,
        module: await import(normalizePath(path))
      }))
    );
    cache.add(JS_CACHE_AREA, extensionPoint, imports);
    return imports;
  } catch (error) {
    console.error("Error loading JS extensions:", error);
    return [];
  }
}

// Load all x-component tags starting from root elements
/**
 * Load and render all HTML extensions in the given DOM roots.
 *
 * @param {Element | Document | Array<Element | Document>} [roots]
 * @returns {Promise<void>}
 */
export async function loadHtmlExtensions(roots = [document.documentElement]) {
  try {
    // Convert single root to array if needed
    /** @type {Array<Element | Document>} */
    const rootElements = Array.isArray(roots) ? roots : [roots];

    // Find all top-level components and load them in parallel
    /** @type {Element[]} */
    const extensions = rootElements.flatMap((root) =>
      Array.from(root.querySelectorAll("x-extension")),
    );

    if (extensions.length === 0) return;

    await Promise.all(
      extensions.map(async (extension) => {
        const path = extension.getAttribute("id");
        if (!path) {
          console.error("x-extension missing id attribute:", extension);
          return;
        }
        await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
      }),
    );
  } catch (error) {
    console.error("Error loading HTML extensions:", error);
  }
}

/**
 * Reload and re-render all HTML extensions in the given DOM roots.
 *
 * @param {Element | Document | Array<Element | Document>} [roots]
 * @returns {Promise<void>}
 */
export async function reloadHtmlExtensions(roots = [document.documentElement]) {
  try {
    /** @type {Array<Element | Document>} */
    const rootElements = Array.isArray(roots) ? roots : [roots];

    /** @type {Element[]} */
    const extensions = rootElements.flatMap((root) =>
      Array.from(root.querySelectorAll("x-extension")),
    );

    if (extensions.length === 0) return;

    await Promise.all(
      extensions.map(async (extension) => {
        const path = extension.getAttribute("id");
        if (!path) {
          console.error("x-extension missing id attribute:", extension);
          return;
        }

        extension.innerHTML = "";
        await importHtmlExtensions(path, /** @type {HTMLElement} */ (extension));
      }),
    );
  } catch (error) {
    console.error("Error reloading HTML extensions:", error);
  }
}

// import all extensions for extension point via backend api
/**
 * Import all HTML extensions for an extension point and inject them as `<x-component>` tags.
 *
 * @param {string} extensionPoint
 * @param {HTMLElement} targetElement
 * @returns {Promise<void>}
 */
export async function importHtmlExtensions(extensionPoint, targetElement) {
  targetElement.classList.add("loading");
  try {
    const cachedHtml = cache.get(HTML_CACHE_AREA, extensionPoint, null);
    if (cachedHtml != null) {
      targetElement.innerHTML = cachedHtml;
      return;
    }

    const manifestExtensions = manifestExtensionPaths("html", extensionPoint);
    /** @type {WebuiExtension[]} */
    let extensionPaths = manifestExtensions;
    if (extensionPaths == null) {
      /** @type {LoadWebuiExtensionsResponse} */
      const response = await api.callJsonApi(`/api/load_webui_extensions`, {
        extension_point: extensionPoint,
        filters: ["*.html", "*.htm", "*.xhtml"],
      });
      extensionPaths = response.extensions;
    }
    let combinedHTML = "";
    for (const extension of extensionPaths) {
      const path = normalizePath(extension);
      combinedHTML += `<x-component path="${path}"></x-component>`;
    }
    cache.add(HTML_CACHE_AREA, extensionPoint, combinedHTML);
    targetElement.innerHTML = combinedHTML;
  } catch (error) {
    console.error("Error importing HTML extensions:", error);
    return;
  } finally {
    targetElement.classList.remove("loading");
    checkInitialLoadComplete();
  }
}

/**
 * @param {string} path
 * @returns {string}
 */
function normalizePath(path) {
  return path.startsWith("/") ? path : "/" + path;
}

// Watch for DOM changes to dynamically load x-extensions
/** @type {MutationCallback} */
const extensionObserverCallback = (mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType === 1) {
        // ELEMENT_NODE
        // Check if this node or its descendants contain x-extension(s)
        const el = /** @type {Element} */ (node);
        if (el.matches?.("x-extension")) {
          const id = el.getAttribute("id");
          if (id) importHtmlExtensions(id, /** @type {HTMLElement} */ (el));
        } else if (/** @type {any} */ (el)["querySelectorAll"]) {
          loadHtmlExtensions([el]);
        }
      }
    }
  }
  checkInitialLoadComplete();
};

/** @type {MutationObserver} */
const extensionObserver = new MutationObserver(extensionObserverCallback);
extensionObserver.observe(document.body, { childList: true, subtree: true });

document.addEventListener("alpine:initialized", () => {
  alpineInitialized = true;
  checkInitialLoadComplete();
}, { once: true });

// Do an initial scan for static x-extension tags
// that already exist in the DOM (index.html), then rely on
// the observer for dynamically inserted nodes coming from components.
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", () => loadHtmlExtensions());
} else {
  loadHtmlExtensions();
}