main
js 269 lines 8.51 KB
Raw
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 });