@dinoreic/fez 0.4.0 → 0.5.2

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.
@@ -1,212 +1,444 @@
1
- const compileToClass = (html) => {
2
- const result = { script: '', style: '', html: '', head: '' }
3
- const lines = html.split('\n')
4
-
5
- let currentBlock = []
6
- let currentType = ''
7
-
8
- for (var line of lines) {
9
- line = line.trim()
10
- if (line.startsWith('<script') && !result.script && currentType != 'head') {
11
- currentType = 'script';
12
- } else if (line.startsWith('<head') && !result.script) { // you must use XMP tag if you want to define <head> tag, and it has to be first
13
- currentType = 'head';
14
- } else if (line.startsWith('<style')) {
15
- currentType = 'style';
16
- } else if (line.endsWith('</script>') && currentType === 'script' && !result.script) {
17
- result.script = currentBlock.join('\n');
18
- currentBlock = [];
19
- currentType = null;
20
- } else if (line.endsWith('</style>') && currentType === 'style') {
21
- result.style = currentBlock.join('\n');
22
- currentBlock = [];
23
- currentType = null;
24
- } else if ((line.endsWith('</head>') || line.endsWith('</header>')) && currentType === 'head') {
25
- result.head = currentBlock.join('\n');
26
- currentBlock = [];
27
- currentType = null;
28
- } else if (currentType) {
29
- // if (currentType == 'script' && line.startsWith('//')) {
30
- // continue
31
- // }
32
- currentBlock.push(line);
33
- } else {
34
- result.html += line + '\n';
35
- }
36
- }
1
+ /**
2
+ * Fez Component Compiler
3
+ *
4
+ * Compiles component definitions from various sources:
5
+ * - <template fez="name">...</template>
6
+ * - <xmp fez="name">...</xmp>
7
+ * - <script fez="name">...</script>
8
+ * - Remote URLs
9
+ *
10
+ * Flow:
11
+ * 1. Source (template/xmp/url) -> compile()
12
+ * 2. Extract parts (script/style/html/demo) -> compileToClass()
13
+ * 3. Generate class string -> Fez('name', class { ... })
14
+ */
37
15
 
38
- if (result.head) {
39
- const container = Fez.domRoot(result.head)
40
-
41
- // Process all children of the container
42
- Array.from(container.children).forEach(node => {
43
- if (node.tagName === 'SCRIPT') {
44
- const script = document.createElement('script')
45
- // Copy all attributes
46
- Array.from(node.attributes).forEach(attr => {
47
- script.setAttribute(attr.name, attr.value)
48
- })
49
- script.type ||= 'text/javascript'
50
-
51
- if (node.src) {
52
- // External script - will load automatically
53
- document.head.appendChild(script)
54
- } else if (script.type.includes('javascript') || script.type == 'module') {
55
- // Inline script - set content and execute
56
- script.textContent = node.textContent
57
- document.head.appendChild(script)
58
- }
59
- } else {
60
- // For other elements (link, meta, etc.), just append them
61
- document.head.appendChild(node.cloneNode(true))
62
- }
63
- })
64
- }
16
+ // Note: Uses Fez.index directly (set up in root.js)
65
17
 
66
- let klass = result.script
18
+ // =============================================================================
19
+ // HELPERS
20
+ // =============================================================================
67
21
 
68
- if (!/class\s+\{/.test(klass)) {
69
- klass = `class {\n${klass}\n}`
22
+ /**
23
+ * Remove common leading whitespace from all lines
24
+ */
25
+ function dedent(text) {
26
+ const lines = text.split("\n");
27
+ // Find minimum indentation (ignoring empty lines)
28
+ const nonEmptyLines = lines.filter((l) => l.trim());
29
+ if (!nonEmptyLines.length) return text;
30
+
31
+ const minIndent = Math.min(
32
+ ...nonEmptyLines.map((l) => l.match(/^(\s*)/)[1].length),
33
+ );
34
+ if (minIndent === 0) return text;
35
+
36
+ // Remove common indentation
37
+ return lines.map((l) => l.slice(minIndent)).join("\n");
38
+ }
39
+
40
+ // =============================================================================
41
+ // MAIN COMPILE FUNCTION
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Check if HTML has top-level <xmp fez> or <template fez> elements
46
+ * (not ones inside <demo> blocks)
47
+ */
48
+ function hasTopLevelFezElements(html) {
49
+ if (!html) return false;
50
+
51
+ // Remove content inside <demo>...</demo> to avoid false positives
52
+ const withoutDemo = html.replace(/<demo>[\s\S]*?<\/demo>/gi, "");
53
+
54
+ // Check for <xmp fez= or <template fez= at top level
55
+ return /<(xmp|template)\s+fez\s*=/i.test(withoutDemo);
56
+ }
57
+
58
+ /**
59
+ * Compile a Fez component
60
+ *
61
+ * @example
62
+ * Fez.compile() // Compile all templates in document
63
+ * Fez.compile(templateNode) // Compile a template node
64
+ * Fez.compile('ui-foo', htmlString) // Compile from string
65
+ *
66
+ * @param {string|Node} tagName - Component name or template node
67
+ * @param {string} [html] - Component HTML source
68
+ */
69
+ export default function compile(tagName, html) {
70
+ // Single argument: compile node or all templates
71
+ if (arguments.length === 1) {
72
+ return compileBulk(tagName);
70
73
  }
71
74
 
72
- if (String(result.style).includes(':')) {
73
- result.style = Fez.cssMixin(result.style)
74
- result.style = result.style.includes(':fez') || /(?:^|\s)body\s*\{/.test(result.style) ? result.style : `:fez {\n${result.style}\n}`
75
- klass = klass.replace(/\}\s*$/, `\n CSS = \`${result.style}\`\n}`)
75
+ // Multiple xmp/template tags in html? Process them
76
+ // Check for top-level fez definitions (not ones inside <demo> blocks)
77
+ if (hasTopLevelFezElements(html)) {
78
+ // Extract top-level demo/info before processing inner components
79
+ if (tagName) {
80
+ const parts = compileToClass(html);
81
+ if (parts.info?.trim()) {
82
+ Fez.index.ensure(tagName).info = parts.info;
83
+ }
84
+ if (parts.demo?.trim()) {
85
+ Fez.index.ensure(tagName).demo = parts.demo;
86
+ }
87
+ }
88
+ return compileBulk(html);
76
89
  }
77
90
 
78
- if (/\w/.test(String(result.html))) {
79
- // escape backticks in whole template block
80
- result.html = result.html.replaceAll('`', '&#x60;')
81
- result.html = result.html.replaceAll('$', '\\$')
82
- klass = klass.replace(/\}\s*$/, `\n HTML = \`${result.html}\`\n}`)
91
+ // Validate component name
92
+ if (
93
+ tagName &&
94
+ !tagName.includes("-") &&
95
+ !tagName.includes(".") &&
96
+ !tagName.includes("/")
97
+ ) {
98
+ console.error(
99
+ `Fez: Invalid name "${tagName}". Must contain a dash (e.g., 'my-element').`,
100
+ );
101
+ return;
83
102
  }
84
103
 
85
- return klass
104
+ // Store original source
105
+ Fez.index.ensure(tagName).source = html;
106
+
107
+ // Extract and compile
108
+ const classCode = generateClassCode(tagName, compileToClass(html));
109
+
110
+ // Hide custom element until compiled
111
+ hideCustomElement(tagName);
112
+
113
+ // Execute the class code
114
+ executeClassCode(tagName, classCode);
86
115
  }
87
116
 
88
- // Handle single argument cases - compile all, compile node, or compile from URL
89
- function compile_bulk(data) {
117
+ // =============================================================================
118
+ // COMPILE FROM VARIOUS SOURCES
119
+ // =============================================================================
120
+
121
+ /**
122
+ * Compile from node or HTML string containing templates
123
+ */
124
+ function compileBulk(data) {
125
+ // Single template node
90
126
  if (data instanceof Node) {
91
- const node = data
92
- node.remove()
127
+ const node = data;
128
+ node.remove();
93
129
 
94
- const fezName = node.getAttribute('fez')
130
+ const fezName = node.getAttribute("fez");
95
131
 
96
- // Check if fezName contains dot or slash (indicates URL)
97
- if (fezName && (fezName.includes('.') || fezName.includes('/'))) {
98
- compile_from_url(fezName)
99
- return
100
- } else {
101
- // Validate fezName format for non-URL names
102
- if (fezName && !fezName.includes('-')) {
103
- console.error(`Fez: Invalid custom element name "${fezName}". Custom element names must contain a dash (e.g., 'my-element', 'ui-button').`)
104
- }
105
- compile(fezName, node.innerHTML)
106
- return
132
+ // URL reference
133
+ if (fezName?.includes(".") || fezName?.includes("/")) {
134
+ return compileFromUrl(fezName);
107
135
  }
108
- }
109
- else {
110
- let root = data ? Fez.domRoot(data) : document.body
111
136
 
112
- root.querySelectorAll('template[fez], xmp[fez]').forEach((n) => {
113
- compile_bulk(n)
114
- })
137
+ // Validate name
138
+ if (fezName && !fezName.includes("-")) {
139
+ console.error(`Fez: Invalid name "${fezName}". Must contain a dash.`);
140
+ return;
141
+ }
115
142
 
116
- return
143
+ return compile(fezName, node.innerHTML);
117
144
  }
145
+
146
+ // HTML string or document
147
+ const root = data ? Fez.domRoot(data) : document.body;
148
+ root
149
+ .querySelectorAll("template[fez], xmp[fez]")
150
+ .forEach((n) => compileBulk(n));
118
151
  }
119
152
 
120
- function compile_from_url(url) {
121
- Fez.log(`Loading from ${url}`)
153
+ /**
154
+ * Compile component(s) from remote URL
155
+ * Supports .fez files and .txt files (component lists)
156
+ */
157
+ function compileFromUrl(url) {
158
+ Fez.consoleLog(`Loading from ${url}`);
159
+
160
+ // Handle .txt files as component lists
161
+ if (url.endsWith(".txt")) {
162
+ Fez.head({ fez: url });
163
+ return;
164
+ }
122
165
 
123
- // Load HTML content via AJAX from URL
124
166
  Fez.fetch(url)
125
- .then(htmlContent => {
126
- // Check if remote HTML has template/xmp tags with fez attribute
127
- const parser = new DOMParser()
128
- const doc = parser.parseFromString(htmlContent, 'text/html')
129
- const fezElements = doc.querySelectorAll('template[fez], xmp[fez]')
167
+ .then((content) => {
168
+ const doc = new DOMParser().parseFromString(content, "text/html");
169
+ const fezElements = doc.querySelectorAll("template[fez], xmp[fez]");
130
170
 
131
171
  if (fezElements.length > 0) {
132
- // Compile each found fez element
133
- fezElements.forEach(el => {
134
- const name = el.getAttribute('fez')
135
- if (name && !name.includes('-') && !name.includes('.') && !name.includes('/')) {
136
- console.error(`Fez: Invalid custom element name "${name}". Custom element names must contain a dash (e.g., 'my-element', 'ui-button').`)
172
+ // Extract top-level info/demo before the xmp elements (for multi-component files)
173
+ const fileName = url.split("/").pop().split(".")[0];
174
+ const parts = compileToClass(content);
175
+ if (parts.info?.trim()) {
176
+ Fez.index.ensure(fileName).info = parts.info;
177
+ }
178
+ if (parts.demo?.trim()) {
179
+ Fez.index.ensure(fileName).demo = parts.demo;
180
+ }
181
+
182
+ // Multiple components in file
183
+ fezElements.forEach((el) => {
184
+ const name = el.getAttribute("fez");
185
+ if (
186
+ name &&
187
+ !name.includes("-") &&
188
+ !name.includes(".") &&
189
+ !name.includes("/")
190
+ ) {
191
+ console.error(`Fez: Invalid name "${name}". Must contain a dash.`);
192
+ return;
137
193
  }
138
- const content = el.innerHTML
139
- compile(name, content)
140
- })
194
+ compile(name, el.innerHTML);
195
+ });
141
196
  } else {
142
- // No fez elements found, use extracted name from URL
143
- const name = url.split('/').pop().split('.')[0]
144
- compile(name, htmlContent)
197
+ // Single component, derive name from URL
198
+ const name = url.split("/").pop().split(".")[0];
199
+ compile(name, content);
145
200
  }
146
201
  })
147
- .catch(error => {
148
- console.error(`FEZ template load error for "${url}": ${error.message}`)
149
- })
202
+ .catch((error) => {
203
+ Fez.onError("compile", `Load error for "${url}": ${error.message}`);
204
+ });
150
205
  }
151
206
 
152
- // <template fez="ui-form">
153
- // <script>
154
- // ...
155
- // Fez.compile() # compile all
156
- // Fez.compile(templateNode) # compile template node or string with template or xmp tags
157
- // Fez.compile('ui-form', templateNode.innerHTML) # compile string
158
- function compile(tagName, html) {
159
- // Handle single argument cases
160
- if (arguments.length === 1) {
161
- return compile_bulk(tagName)
162
- }
207
+ export { compileFromUrl as compile_from_url };
208
+
209
+ // =============================================================================
210
+ // PARSE COMPONENT SOURCE
211
+ // =============================================================================
212
+
213
+ /**
214
+ * Parse component HTML into { script, style, html, head, demo, info }
215
+ */
216
+ function compileToClass(html) {
217
+ const result = {
218
+ script: "",
219
+ style: "",
220
+ html: "",
221
+ head: "",
222
+ demo: "",
223
+ info: "",
224
+ };
225
+ const lines = html.split("\n");
226
+
227
+ let block = [];
228
+ let type = "";
163
229
 
164
- // If html contains </xmp>, send to compile_bulk for processing
165
- if (html && html.includes('</xmp>')) {
166
- return compile_bulk(html)
230
+ for (let line of lines) {
231
+ const trimmedLine = line.trim();
232
+
233
+ // Start blocks - demo/info can contain other tags, so skip nested detection
234
+ if (trimmedLine.startsWith("<demo") && !result.demo && !type) {
235
+ type = "demo";
236
+ } else if (trimmedLine.startsWith("<info") && !result.info && !type) {
237
+ type = "info";
238
+ } else if (
239
+ trimmedLine.startsWith("<script") &&
240
+ !result.script &&
241
+ type !== "head" &&
242
+ type !== "demo" &&
243
+ type !== "info"
244
+ ) {
245
+ type = "script";
246
+ } else if (
247
+ trimmedLine.startsWith("<head") &&
248
+ !result.script &&
249
+ type !== "demo" &&
250
+ type !== "info"
251
+ ) {
252
+ type = "head";
253
+ } else if (
254
+ trimmedLine.startsWith("<style") &&
255
+ type !== "demo" &&
256
+ type !== "info"
257
+ ) {
258
+ type = "style";
259
+
260
+ // End blocks
261
+ } else if (trimmedLine.endsWith("</demo>") && type === "demo") {
262
+ result.demo = dedent(block.join("\n"));
263
+ block = [];
264
+ type = "";
265
+ } else if (trimmedLine.endsWith("</info>") && type === "info") {
266
+ result.info = dedent(block.join("\n"));
267
+ block = [];
268
+ type = "";
269
+ } else if (
270
+ trimmedLine.endsWith("</script>") &&
271
+ type === "script" &&
272
+ !result.script
273
+ ) {
274
+ result.script = block.join("\n");
275
+ block = [];
276
+ type = "";
277
+ } else if (trimmedLine.endsWith("</style>") && type === "style") {
278
+ result.style = block.join("\n");
279
+ block = [];
280
+ type = "";
281
+ } else if (
282
+ (trimmedLine.endsWith("</head>") || trimmedLine.endsWith("</header>")) &&
283
+ type === "head"
284
+ ) {
285
+ result.head = block.join("\n");
286
+ block = [];
287
+ type = "";
288
+
289
+ // Collect content - preserve original indentation for demo and info
290
+ } else if (type) {
291
+ block.push(type === "demo" || type === "info" ? line : trimmedLine);
292
+ } else {
293
+ result.html += trimmedLine + "\n";
294
+ }
167
295
  }
168
296
 
169
- // Validate element name if it's not a URL
170
- if (tagName && !tagName.includes('-') && !tagName.includes('.') && !tagName.includes('/')) {
171
- console.error(`Fez: Invalid custom element name "${tagName}". Custom element names must contain a dash (e.g., 'my-element', 'ui-button').`)
297
+ // Process head elements (scripts, links, etc.)
298
+ if (result.head) {
299
+ processHeadElements(result.head);
172
300
  }
173
301
 
174
- let klass = compileToClass(html)
175
- let parts = klass.split(/class\s+\{/, 2)
302
+ return result;
303
+ }
304
+
305
+ /**
306
+ * Process <head> elements from component
307
+ */
308
+ function processHeadElements(headHtml) {
309
+ const container = Fez.domRoot(headHtml);
176
310
 
177
- klass = `${parts[0]};\n\nwindow.Fez('${tagName}', class {\n${parts[1]})`
311
+ Array.from(container.children).forEach((node) => {
312
+ if (node.tagName === "SCRIPT") {
313
+ const script = document.createElement("script");
314
+ Array.from(node.attributes).forEach((attr) => {
315
+ script.setAttribute(attr.name, attr.value);
316
+ });
317
+ script.type ||= "text/javascript";
178
318
 
179
- // Add tag to global hidden styles container
180
- if (tagName) {
181
- let styleContainer = document.getElementById('fez-hidden-styles')
182
- if (!styleContainer) {
183
- styleContainer = document.createElement('style')
184
- styleContainer.id = 'fez-hidden-styles'
185
- document.head.appendChild(styleContainer)
319
+ if (node.src) {
320
+ document.head.appendChild(script);
321
+ } else if (
322
+ script.type.includes("javascript") ||
323
+ script.type === "module"
324
+ ) {
325
+ script.textContent = node.textContent;
326
+ document.head.appendChild(script);
327
+ }
328
+ } else {
329
+ document.head.appendChild(node.cloneNode(true));
186
330
  }
187
- const allTags = [...Object.keys(Fez.classes), tagName].sort().join(', ')
188
- styleContainer.textContent = `${allTags} { display: none; }\n`
331
+ });
332
+ }
333
+
334
+ // =============================================================================
335
+ // GENERATE CLASS CODE
336
+ // =============================================================================
337
+
338
+ /**
339
+ * Generate executable class code from parsed parts
340
+ */
341
+ function generateClassCode(tagName, parts) {
342
+ let klass = parts.script;
343
+
344
+ // Wrap in class if needed
345
+ if (!/class\s+\{/.test(klass)) {
346
+ klass = `class {\n${klass}\n}`;
347
+ }
348
+
349
+ // Add CSS
350
+ if (String(parts.style).includes(":")) {
351
+ let css = Fez.cssMixin(parts.style);
352
+ css =
353
+ css.includes(":fez") || /(?:^|\s)body\s*\{/.test(css)
354
+ ? css
355
+ : `:fez {\n${css}\n}`;
356
+ klass = klass.replace(/\}\s*$/, `\n CSS = \`${css}\`\n}`);
357
+ }
358
+
359
+ // Add HTML
360
+ if (/\w/.test(String(parts.html))) {
361
+ const html = parts.html.replaceAll("`", "&#x60;").replaceAll("$", "\\$");
362
+ klass = klass.replace(/\}\s*$/, `\n HTML = \`${html}\`\n}`);
189
363
  }
190
364
 
191
- // we cant try/catch javascript modules (they use imports)
192
- if (klass.includes('import ')) {
193
- Fez.head({script: klass})
365
+ // Store demo content in index
366
+ if (parts.demo?.trim()) {
367
+ Fez.index.ensure(tagName).demo = parts.demo;
368
+ }
369
+
370
+ // Store info content in index
371
+ if (parts.info?.trim()) {
372
+ Fez.index.ensure(tagName).info = parts.info;
373
+ }
374
+
375
+ // Wrap in Fez call
376
+ const [before, after] = klass.split(/class\s+\{/, 2);
377
+ return `${before};\n\nwindow.Fez('${tagName}', class {\n${after})`;
378
+ }
194
379
 
195
- // best we can do it inform that node did not compile, so we assume there is an error
196
- setTimeout(()=>{
197
- if (!Fez.classes[tagName]) {
198
- Fez.error(`Template "${tagName}" possible compile error. (can be a false positive, it imports are not loaded)`)
380
+ /**
381
+ * Execute generated class code
382
+ */
383
+ function executeClassCode(tagName, code) {
384
+ // Module imports require script tag
385
+ if (code.includes("import ")) {
386
+ // Extract importmap and rewrite bare import specifiers to full URLs
387
+ const importmapRe =
388
+ /Fez\.head\(\s*\{\s*importmap\s*:\s*(\{[\s\S]*?\})\s*\}\s*\)\s*;?/g;
389
+ let match;
390
+ while ((match = importmapRe.exec(code)) !== null) {
391
+ try {
392
+ const imports = new Function(`return ${match[1]}`)();
393
+ // Sort by length descending so "three/addons/" matches before "three"
394
+ const sorted = Object.entries(imports).sort(
395
+ (a, b) => b[0].length - a[0].length,
396
+ );
397
+ for (const [specifier, url] of sorted) {
398
+ const escaped = specifier.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
399
+ code = code.replace(
400
+ new RegExp(`(from\\s+['"])${escaped}`, "g"),
401
+ `$1${url}`,
402
+ );
403
+ }
404
+ } catch (e) {
405
+ Fez.consoleError(`importmap parse error: ${e.message}`);
406
+ }
407
+ }
408
+ // Remove the Fez.head({importmap:...}) calls
409
+ code = code.replace(importmapRe, "");
410
+
411
+ Fez.head({ script: code });
412
+
413
+ // Check for compile errors after delay
414
+ setTimeout(() => {
415
+ if (!Fez.index[tagName]?.class) {
416
+ Fez.consoleError(`Template "${tagName}" possible compile error.`);
199
417
  }
200
- }, 2000)
418
+ }, 2000);
201
419
  } else {
202
420
  try {
203
- new Function(klass)()
204
- } catch(e) {
205
- Fez.error(`Template "${tagName}" compile error: ${e.message}`)
206
- console.log(klass)
421
+ new Function(code)();
422
+ } catch (e) {
423
+ Fez.consoleError(`Template "${tagName}" compile error: ${e.message}`);
424
+ console.log(code);
207
425
  }
208
426
  }
209
427
  }
210
428
 
211
- export { compile_from_url }
212
- export default compile
429
+ /**
430
+ * Add CSS to hide custom element until compiled
431
+ */
432
+ function hideCustomElement(tagName) {
433
+ if (!tagName) return;
434
+
435
+ let styleEl = document.getElementById("fez-hidden-styles");
436
+ if (!styleEl) {
437
+ styleEl = document.createElement("style");
438
+ styleEl.id = "fez-hidden-styles";
439
+ document.head.appendChild(styleEl);
440
+ }
441
+
442
+ const allTags = [...Fez.index.names(), tagName].sort().join(", ");
443
+ styleEl.textContent = `${allTags} { display: none; }\n`;
444
+ }