| 1 | // Import a component into a target element |
| 2 | // Import a component and recursively load its nested components |
| 3 | // Returns the parsed document for additional processing |
| 4 | |
| 5 | // cache object to store loaded components |
| 6 | const componentCache = {}; |
| 7 | |
| 8 | // Lock map to prevent multiple simultaneous imports of the same component |
| 9 | const importLocks = new Map(); |
| 10 | |
| 11 | export async function importComponent(path, targetElement) { |
| 12 | // Create a unique key for this import based on the target element |
| 13 | const lockKey = targetElement.id || targetElement.getAttribute('data-component-id') || targetElement; |
| 14 | |
| 15 | // If this component is already being loaded, return early |
| 16 | if (importLocks.get(lockKey)) { |
| 17 | console.log(`Component ${path} is already being loaded for target`, targetElement); |
| 18 | return; |
| 19 | } |
| 20 | |
| 21 | // Set the lock |
| 22 | importLocks.set(lockKey, true); |
| 23 | |
| 24 | try { |
| 25 | if (!targetElement) { |
| 26 | throw new Error("Target element is required"); |
| 27 | } |
| 28 | |
| 29 | // Show loading indicator |
| 30 | targetElement.innerHTML = '<div class="loading"></div>'; |
| 31 | |
| 32 | // full component url |
| 33 | const componentUrl = path.startsWith("/") ? path : (path.startsWith("components/") ? path : "components/" + path); |
| 34 | |
| 35 | // get html from cache or fetch it |
| 36 | let html; |
| 37 | if (componentCache[componentUrl]) { |
| 38 | html = componentCache[componentUrl]; |
| 39 | } else { |
| 40 | const response = await fetch(componentUrl); |
| 41 | if (!response.ok) { |
| 42 | throw new Error( |
| 43 | `Error loading component ${path}: ${response.statusText}` |
| 44 | ); |
| 45 | } |
| 46 | html = await response.text(); |
| 47 | // store in cache |
| 48 | componentCache[componentUrl] = html; |
| 49 | } |
| 50 | const parser = new DOMParser(); |
| 51 | const doc = parser.parseFromString(html, "text/html"); |
| 52 | |
| 53 | const componentAssetSelector = "style, script, link[rel='stylesheet']"; |
| 54 | const allNodes = [ |
| 55 | ...doc.querySelectorAll(componentAssetSelector), |
| 56 | ...Array.from(doc.body.childNodes).filter( |
| 57 | (node) => !node.matches?.(componentAssetSelector) |
| 58 | ), |
| 59 | ]; |
| 60 | |
| 61 | const loadPromises = []; |
| 62 | const deferredNodes = []; |
| 63 | let blobCounter = 0; |
| 64 | |
| 65 | for (const node of allNodes) { |
| 66 | if (node.nodeName === "SCRIPT") { |
| 67 | const isModule = |
| 68 | node.type === "module" || node.getAttribute("type") === "module"; |
| 69 | |
| 70 | if (isModule) { |
| 71 | if (node.src) { |
| 72 | // For <script type="module" src="..." use dynamic import |
| 73 | const resolvedUrl = new URL( |
| 74 | node.src, |
| 75 | globalThis.location.origin |
| 76 | ).toString(); |
| 77 | |
| 78 | // Check if module is already in cache |
| 79 | if (!componentCache[resolvedUrl]) { |
| 80 | const modulePromise = import(resolvedUrl); |
| 81 | componentCache[resolvedUrl] = modulePromise; |
| 82 | } |
| 83 | loadPromises.push(componentCache[resolvedUrl]); |
| 84 | } else { |
| 85 | const virtualUrl = `${componentUrl.replaceAll( |
| 86 | "/", |
| 87 | "_" |
| 88 | )}.${++blobCounter}.js`; |
| 89 | |
| 90 | // For inline module scripts, use cache or create blob |
| 91 | if (!componentCache[virtualUrl]) { |
| 92 | // Transform relative import paths to absolute URLs |
| 93 | let content = node.textContent.replace( |
| 94 | /import\s+([^'"]+)\s+from\s+["']([^"']+)["']/g, |
| 95 | (match, bindings, importPath) => { |
| 96 | // Convert relative OR root-based (e.g. /src/...) to absolute URLs |
| 97 | if (!/^https?:\/\//.test(importPath)) { |
| 98 | const absoluteUrl = new URL( |
| 99 | importPath, |
| 100 | globalThis.location.origin |
| 101 | ).href; |
| 102 | return `import ${bindings} from "${absoluteUrl}"`; |
| 103 | } |
| 104 | return match; |
| 105 | } |
| 106 | ); |
| 107 | |
| 108 | // Add sourceURL to the content |
| 109 | content += `\n//# sourceURL=${virtualUrl}`; |
| 110 | |
| 111 | // Create a Blob from the rewritten content |
| 112 | const blob = new Blob([content], { |
| 113 | type: "text/javascript", |
| 114 | }); |
| 115 | const blobUrl = URL.createObjectURL(blob); |
| 116 | |
| 117 | const modulePromise = import(blobUrl) |
| 118 | .catch((err) => { |
| 119 | console.error(`Failed to load inline module ${virtualUrl}:`, err); |
| 120 | throw err; |
| 121 | }) |
| 122 | .finally(() => URL.revokeObjectURL(blobUrl)); |
| 123 | |
| 124 | componentCache[virtualUrl] = modulePromise; |
| 125 | } |
| 126 | loadPromises.push(componentCache[virtualUrl]); |
| 127 | } |
| 128 | } else { |
| 129 | // Non-module script |
| 130 | const script = document.createElement("script"); |
| 131 | Array.from(node.attributes || []).forEach((attr) => { |
| 132 | script.setAttribute(attr.name, attr.value); |
| 133 | }); |
| 134 | script.textContent = node.textContent; |
| 135 | |
| 136 | if (script.src) { |
| 137 | const promise = new Promise((resolve, reject) => { |
| 138 | script.onload = resolve; |
| 139 | script.onerror = reject; |
| 140 | }); |
| 141 | loadPromises.push(promise); |
| 142 | } |
| 143 | |
| 144 | targetElement.appendChild(script); |
| 145 | } |
| 146 | } else if ( |
| 147 | node.nodeName === "STYLE" || |
| 148 | (node.nodeName === "LINK" && node.rel === "stylesheet") |
| 149 | ) { |
| 150 | const clone = node.cloneNode(true); |
| 151 | |
| 152 | if (clone.tagName === "LINK" && clone.rel === "stylesheet") { |
| 153 | const promise = new Promise((resolve, reject) => { |
| 154 | clone.onload = resolve; |
| 155 | clone.onerror = reject; |
| 156 | }); |
| 157 | loadPromises.push(promise); |
| 158 | } |
| 159 | |
| 160 | targetElement.appendChild(clone); |
| 161 | } else { |
| 162 | deferredNodes.push(node.cloneNode(true)); |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // Wait for all tracked external scripts/styles to finish loading |
| 167 | await Promise.all(loadPromises); |
| 168 | |
| 169 | for (const deferred of deferredNodes) { |
| 170 | targetElement.appendChild(deferred); |
| 171 | } |
| 172 | |
| 173 | // Remove loading indicator |
| 174 | const loadingEl = targetElement.querySelector(':scope > .loading'); |
| 175 | if (loadingEl) { |
| 176 | targetElement.removeChild(loadingEl); |
| 177 | } |
| 178 | |
| 179 | // // Load any nested components |
| 180 | // await loadComponents([targetElement]); |
| 181 | |
| 182 | // Return parsed document |
| 183 | return doc; |
| 184 | } catch (error) { |
| 185 | console.error("Error importing component:", error); |
| 186 | throw error; |
| 187 | } finally { |
| 188 | // Release the lock when done, regardless of success or failure |
| 189 | importLocks.delete(lockKey); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // Load all x-component tags starting from root elements |
| 194 | export async function loadComponents(roots = [document.documentElement]) { |
| 195 | try { |
| 196 | // Convert single root to array if needed |
| 197 | const rootElements = Array.isArray(roots) ? roots : [roots]; |
| 198 | |
| 199 | // Find all top-level components and load them in parallel |
| 200 | const components = rootElements.flatMap((root) => |
| 201 | Array.from(root.querySelectorAll("x-component")) |
| 202 | ); |
| 203 | |
| 204 | if (components.length === 0) return; |
| 205 | |
| 206 | await Promise.all( |
| 207 | components.map(async (component) => { |
| 208 | const path = component.getAttribute("path"); |
| 209 | if (!path) { |
| 210 | console.error("x-component missing path attribute:", component); |
| 211 | return; |
| 212 | } |
| 213 | await importComponent(path, component); |
| 214 | }) |
| 215 | ); |
| 216 | } catch (error) { |
| 217 | console.error("Error loading components:", error); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // Function to traverse parents and collect x-component attributes |
| 222 | export function getParentAttributes(el) { |
| 223 | let element = el; |
| 224 | let attrs = {}; |
| 225 | |
| 226 | while (element) { |
| 227 | if (element.tagName.toLowerCase() === 'x-component') { |
| 228 | // Get all attributes |
| 229 | for (let attr of element.attributes) { |
| 230 | try { |
| 231 | // Try to parse as JSON first |
| 232 | attrs[attr.name] = JSON.parse(attr.value); |
| 233 | } catch(_e) { |
| 234 | // If not JSON, use raw value |
| 235 | attrs[attr.name] = attr.value; |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | element = element.parentElement; |
| 240 | } |
| 241 | return attrs; |
| 242 | } |
| 243 | // expose as global for x-components in Alpine |
| 244 | globalThis.xAttrs = getParentAttributes; |
| 245 | |
| 246 | // Initialize when DOM is ready |
| 247 | if (document.readyState === 'loading') { |
| 248 | document.addEventListener('DOMContentLoaded', () => loadComponents()); |
| 249 | } else { |
| 250 | loadComponents(); |
| 251 | } |
| 252 | |
| 253 | // Watch for DOM changes to dynamically load x-components |
| 254 | const observer = new MutationObserver((mutations) => { |
| 255 | for (const mutation of mutations) { |
| 256 | for (const node of mutation.addedNodes) { |
| 257 | if (node.nodeType === 1) { |
| 258 | // ELEMENT_NODE |
| 259 | // Check if this node or its descendants contain x-component(s) |
| 260 | if (node.matches?.("x-component")) { |
| 261 | importComponent(node.getAttribute("path"), node); |
| 262 | } else if (node.querySelectorAll) { |
| 263 | loadComponents([node]); |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | }); |
| 269 | observer.observe(document.body, { childList: true, subtree: true }); |