mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-11 04:49:57 +00:00
Body nodes (containing Alpine x-data directives) were appended to the DOM immediately during the import loop, while <script type="module"> imports were awaited afterward. Alpine's MutationObserver would evaluate x-data expressions before the module had registered its store, causing "Cannot convert undefined or null to object" errors. Collect body nodes into a buffer and append them only after Promise.all(loadPromises) resolves, ensuring stores are registered before Alpine processes the DOM.
267 lines
No EOL
8.4 KiB
JavaScript
267 lines
No EOL
8.4 KiB
JavaScript
// Import a component into a target element
|
|
// Import a component and recursively load its nested components
|
|
// Returns the parsed document for additional processing
|
|
|
|
// cache object to store loaded components
|
|
const componentCache = {};
|
|
|
|
// Lock map to prevent multiple simultaneous imports of the same component
|
|
const importLocks = new Map();
|
|
|
|
export async function importComponent(path, targetElement) {
|
|
// Create a unique key for this import based on the target element
|
|
const lockKey = targetElement.id || targetElement.getAttribute('data-component-id') || targetElement;
|
|
|
|
// If this component is already being loaded, return early
|
|
if (importLocks.get(lockKey)) {
|
|
console.log(`Component ${path} is already being loaded for target`, targetElement);
|
|
return;
|
|
}
|
|
|
|
// Set the lock
|
|
importLocks.set(lockKey, true);
|
|
|
|
try {
|
|
if (!targetElement) {
|
|
throw new Error("Target element is required");
|
|
}
|
|
|
|
// Show loading indicator
|
|
targetElement.innerHTML = '<div class="loading"></div>';
|
|
|
|
// full component url
|
|
const componentUrl = path.startsWith("/") ? path : (path.startsWith("components/") ? path : "components/" + path);
|
|
|
|
// get html from cache or fetch it
|
|
let html;
|
|
if (componentCache[componentUrl]) {
|
|
html = componentCache[componentUrl];
|
|
} else {
|
|
const response = await fetch(componentUrl);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Error loading component ${path}: ${response.statusText}`
|
|
);
|
|
}
|
|
html = await response.text();
|
|
// store in cache
|
|
componentCache[componentUrl] = html;
|
|
}
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
|
|
const allNodes = [
|
|
...doc.querySelectorAll("style"),
|
|
...doc.querySelectorAll("script"),
|
|
...doc.body.childNodes,
|
|
];
|
|
|
|
const loadPromises = [];
|
|
const deferredNodes = [];
|
|
let blobCounter = 0;
|
|
|
|
for (const node of allNodes) {
|
|
if (node.nodeName === "SCRIPT") {
|
|
const isModule =
|
|
node.type === "module" || node.getAttribute("type") === "module";
|
|
|
|
if (isModule) {
|
|
if (node.src) {
|
|
// For <script type="module" src="..." use dynamic import
|
|
const resolvedUrl = new URL(
|
|
node.src,
|
|
globalThis.location.origin
|
|
).toString();
|
|
|
|
// Check if module is already in cache
|
|
if (!componentCache[resolvedUrl]) {
|
|
const modulePromise = import(resolvedUrl);
|
|
componentCache[resolvedUrl] = modulePromise;
|
|
loadPromises.push(modulePromise);
|
|
}
|
|
} else {
|
|
const virtualUrl = `${componentUrl.replaceAll(
|
|
"/",
|
|
"_"
|
|
)}.${++blobCounter}.js`;
|
|
|
|
// For inline module scripts, use cache or create blob
|
|
if (!componentCache[virtualUrl]) {
|
|
// Transform relative import paths to absolute URLs
|
|
let content = node.textContent.replace(
|
|
/import\s+([^'"]+)\s+from\s+["']([^"']+)["']/g,
|
|
(match, bindings, importPath) => {
|
|
// Convert relative OR root-based (e.g. /src/...) to absolute URLs
|
|
if (!/^https?:\/\//.test(importPath)) {
|
|
const absoluteUrl = new URL(
|
|
importPath,
|
|
globalThis.location.origin
|
|
).href;
|
|
return `import ${bindings} from "${absoluteUrl}"`;
|
|
}
|
|
return match;
|
|
}
|
|
);
|
|
|
|
// Add sourceURL to the content
|
|
content += `\n//# sourceURL=${virtualUrl}`;
|
|
|
|
// Create a Blob from the rewritten content
|
|
const blob = new Blob([content], {
|
|
type: "text/javascript",
|
|
});
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
const modulePromise = import(blobUrl)
|
|
.catch((err) => {
|
|
console.error(`Failed to load inline module ${virtualUrl}:`, err);
|
|
throw err;
|
|
})
|
|
.finally(() => URL.revokeObjectURL(blobUrl));
|
|
|
|
componentCache[virtualUrl] = modulePromise;
|
|
loadPromises.push(modulePromise);
|
|
}
|
|
}
|
|
} else {
|
|
// Non-module script
|
|
const script = document.createElement("script");
|
|
Array.from(node.attributes || []).forEach((attr) => {
|
|
script.setAttribute(attr.name, attr.value);
|
|
});
|
|
script.textContent = node.textContent;
|
|
|
|
if (script.src) {
|
|
const promise = new Promise((resolve, reject) => {
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
});
|
|
loadPromises.push(promise);
|
|
}
|
|
|
|
targetElement.appendChild(script);
|
|
}
|
|
} else if (
|
|
node.nodeName === "STYLE" ||
|
|
(node.nodeName === "LINK" && node.rel === "stylesheet")
|
|
) {
|
|
const clone = node.cloneNode(true);
|
|
|
|
if (clone.tagName === "LINK" && clone.rel === "stylesheet") {
|
|
const promise = new Promise((resolve, reject) => {
|
|
clone.onload = resolve;
|
|
clone.onerror = reject;
|
|
});
|
|
loadPromises.push(promise);
|
|
}
|
|
|
|
targetElement.appendChild(clone);
|
|
} else {
|
|
deferredNodes.push(node.cloneNode(true));
|
|
}
|
|
}
|
|
|
|
// Wait for all tracked external scripts/styles to finish loading
|
|
await Promise.all(loadPromises);
|
|
|
|
for (const deferred of deferredNodes) {
|
|
targetElement.appendChild(deferred);
|
|
}
|
|
|
|
// Remove loading indicator
|
|
const loadingEl = targetElement.querySelector(':scope > .loading');
|
|
if (loadingEl) {
|
|
targetElement.removeChild(loadingEl);
|
|
}
|
|
|
|
// // Load any nested components
|
|
// await loadComponents([targetElement]);
|
|
|
|
// Return parsed document
|
|
return doc;
|
|
} catch (error) {
|
|
console.error("Error importing component:", error);
|
|
throw error;
|
|
} finally {
|
|
// Release the lock when done, regardless of success or failure
|
|
importLocks.delete(lockKey);
|
|
}
|
|
}
|
|
|
|
// Load all x-component tags starting from root elements
|
|
export async function loadComponents(roots = [document.documentElement]) {
|
|
try {
|
|
// Convert single root to array if needed
|
|
const rootElements = Array.isArray(roots) ? roots : [roots];
|
|
|
|
// Find all top-level components and load them in parallel
|
|
const components = rootElements.flatMap((root) =>
|
|
Array.from(root.querySelectorAll("x-component"))
|
|
);
|
|
|
|
if (components.length === 0) return;
|
|
|
|
await Promise.all(
|
|
components.map(async (component) => {
|
|
const path = component.getAttribute("path");
|
|
if (!path) {
|
|
console.error("x-component missing path attribute:", component);
|
|
return;
|
|
}
|
|
await importComponent(path, component);
|
|
})
|
|
);
|
|
} catch (error) {
|
|
console.error("Error loading components:", error);
|
|
}
|
|
}
|
|
|
|
// Function to traverse parents and collect x-component attributes
|
|
export function getParentAttributes(el) {
|
|
let element = el;
|
|
let attrs = {};
|
|
|
|
while (element) {
|
|
if (element.tagName.toLowerCase() === 'x-component') {
|
|
// Get all attributes
|
|
for (let attr of element.attributes) {
|
|
try {
|
|
// Try to parse as JSON first
|
|
attrs[attr.name] = JSON.parse(attr.value);
|
|
} catch(_e) {
|
|
// If not JSON, use raw value
|
|
attrs[attr.name] = attr.value;
|
|
}
|
|
}
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
return attrs;
|
|
}
|
|
// expose as global for x-components in Alpine
|
|
globalThis.xAttrs = getParentAttributes;
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => loadComponents());
|
|
} else {
|
|
loadComponents();
|
|
}
|
|
|
|
// Watch for DOM changes to dynamically load x-components
|
|
const observer = new MutationObserver((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-component(s)
|
|
if (node.matches?.("x-component")) {
|
|
importComponent(node.getAttribute("path"), node);
|
|
} else if (node.querySelectorAll) {
|
|
loadComponents([node]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true }); |