chocola 1.4.2 → 1.5.0

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,9 +1,26 @@
1
1
  import { JSDOM } from "jsdom";
2
2
  import { extractContextFromElement } from "./dom-processor.js";
3
- import { genRandomId, incrementAlfabet, throwError } from "./utils.js";
3
+ import { genRandomId, incrementAlfabet, throwError, protectCurlyBraces } from "./utils.js";
4
4
  import chalk from "chalk";
5
5
  import beautify from "js-beautify";
6
6
 
7
+ function hasCombinator(sel) {
8
+ let depth = 0;
9
+ for (const ch of sel) {
10
+ if (ch === '[') depth++;
11
+ else if (ch === ']') depth--;
12
+ else if (depth === 0 && (ch === ' ' || ch === '>' || ch === '+' || ch === '~')) return true;
13
+ }
14
+ return false;
15
+ }
16
+
17
+ function scopeSelector(sel, cssId) {
18
+ const s = sel.trim();
19
+ if (s.startsWith("." + cssId)) return s;
20
+ if (hasCombinator(s)) return `.${cssId} ${s}`;
21
+ return `.${cssId}${s}, .${cssId} ${s}`;
22
+ }
23
+
7
24
  function scopeCss(cssString, cssId) {
8
25
  function findMatchingBrace(s, openIndex) {
9
26
  let depth = 0;
@@ -42,13 +59,7 @@ function scopeCss(cssString, cssId) {
42
59
  .map(s => s.trim())
43
60
  .filter(Boolean);
44
61
  const scopedHeader = selectors.length > 0
45
- ? selectors.map(sel => {
46
- const s = sel.trim();
47
- if (s.startsWith("." + cssId)) {
48
- return s;
49
- }
50
- return `.${cssId} ${s}`;
51
- }).join(", ")
62
+ ? selectors.map(sel => scopeSelector(sel, cssId)).join(", ")
52
63
  : header;
53
64
  out += scopedHeader + "{" + innerScoped + "}";
54
65
  }
@@ -75,6 +86,72 @@ function interpolateNode(node, ctxProxy) {
75
86
  }
76
87
  }
77
88
 
89
+ function validateChainStructure(parent, sourceFile, sourceContent, parentContent) {
90
+ const children = [...parent.children];
91
+ let chainActive = false;
92
+ const outerCounts = {};
93
+
94
+ for (const child of children) {
95
+ const hasIf = child.hasAttribute("if");
96
+ const hasDelIf = child.hasAttribute("del-if");
97
+ const hasElif = child.hasAttribute("elif");
98
+ const hasElse = child.hasAttribute("else");
99
+
100
+ if (hasElif || hasElse) {
101
+ if (!chainActive) {
102
+ const tag = child.tagName.toLowerCase();
103
+ const attr = hasElif ? "elif" : "else";
104
+ let loc = sourceFile;
105
+ if (sourceContent && parentContent) {
106
+ const outer = child.outerHTML;
107
+ outerCounts[outer] = (outerCounts[outer] || 0) + 1;
108
+ let searchIdx = 0;
109
+ let hits = 0;
110
+ while ((searchIdx = parentContent.indexOf(outer, searchIdx)) !== -1) {
111
+ hits++;
112
+ if (hits === outerCounts[outer]) {
113
+ let line = parentContent.substring(0, searchIdx).split("\n").length;
114
+ if (sourceContent !== parentContent) {
115
+ const anchor = parentContent.trim().slice(0, 80);
116
+ const vars = [
117
+ anchor,
118
+ anchor.replace(/=""/g, ' '),
119
+ anchor.replace(/=""/g, ''),
120
+ anchor.replace(/="/g, "='").replace(/"/g, "'"),
121
+ ];
122
+ let anchorIdx = -1;
123
+ for (const v of vars) {
124
+ anchorIdx = sourceContent.indexOf(v);
125
+ if (anchorIdx !== -1) break;
126
+ }
127
+ if (anchorIdx !== -1) {
128
+ const offset = sourceContent.substring(0, anchorIdx).split("\n").length;
129
+ line = offset + line - 1;
130
+ }
131
+ }
132
+ loc = `${sourceFile}:${line}`;
133
+ break;
134
+ }
135
+ searchIdx += outer.length;
136
+ }
137
+ }
138
+ throwError(`${loc}\n <${tag}> has ${attr} without a preceding if/del-if sibling`);
139
+ }
140
+ if (hasElse) {
141
+ chainActive = false;
142
+ }
143
+ }
144
+
145
+ if (hasIf || hasDelIf) {
146
+ chainActive = true;
147
+ } else if (!hasElif && !hasElse) {
148
+ chainActive = false;
149
+ }
150
+
151
+ validateChainStructure(child, sourceFile, sourceContent, parentContent);
152
+ }
153
+ }
154
+
78
155
  /**
79
156
  * Processes a single component element and inserts it into the DOM
80
157
  * @param {Element} element
@@ -95,7 +172,9 @@ export function processComponentElement(
95
172
  cssScopesMap,
96
173
  scopedStyles,
97
174
  renderChain = [],
98
- staticCtxRegistry
175
+ staticCtxRegistry,
176
+ sourceFile,
177
+ sourceContent
99
178
  ) {
100
179
  const tagName = element.tagName.toLowerCase();
101
180
  const compName = tagName + ".js";
@@ -119,19 +198,85 @@ export function processComponentElement(
119
198
  });
120
199
 
121
200
  if (instance.body) {
122
- let body = instance.body;
201
+ let body = protectCurlyBraces(instance.body);
123
202
 
124
203
  const fragment = JSDOM.fragment(body);
125
204
 
126
205
  const slotFragment = JSDOM.fragment(srcInnerHtml);
206
+ if (sourceFile) {
207
+ validateChainStructure(slotFragment, sourceFile, sourceContent, srcInnerHtml);
208
+ }
209
+ validateChainStructure(fragment, instance.__sourceFile || compName, body, body);
127
210
  Array.from(fragment.querySelectorAll("slot")).forEach(slot => {
128
211
  slot.replaceWith(slotFragment);
129
212
  });
130
213
 
131
- const children = Array.from(fragment.querySelectorAll("*"));
214
+ const childEntries = Array.from(fragment.querySelectorAll("*")).map(el => ({
215
+ el,
216
+ parent: el.parentNode
217
+ }));
218
+ const condChains = new Map();
219
+
220
+ childEntries.forEach(({ el: child, parent }) => {
221
+ if (!condChains.has(parent)) {
222
+ condChains.set(parent, { active: false, rendered: false });
223
+ }
224
+ const condChain = condChains.get(parent);
132
225
 
133
- children.forEach(child => {
134
- const reservedAttrs = ["if", "del-if"];
226
+ const hasIf = child.hasAttribute("if");
227
+ const hasDelIf = child.hasAttribute("del-if");
228
+ const hasElif = child.hasAttribute("elif");
229
+ const hasElse = child.hasAttribute("else");
230
+
231
+ if (hasElif || hasElse) {
232
+ if (!condChain.active) {
233
+ throwError(`${instance.__sourceFile || compName}: <${child.tagName.toLowerCase()}> has ${hasElif ? "elif" : "else"} without a preceding if/del-if sibling`);
234
+ }
235
+ if (condChain.rendered) {
236
+ child.remove();
237
+ if (hasElse) {
238
+ condChain.active = false;
239
+ }
240
+ return;
241
+ }
242
+ }
243
+
244
+ if (child.tagName.toLowerCase() === "void") {
245
+ if (hasElif || hasElse) {
246
+ if (hasElif) {
247
+ const expr = child.getAttribute("elif").slice(1, -1);
248
+ const fn = new Function("ctx", `with(ctx) { return (${expr}); }`);
249
+ if (!fn(ctxProxy)) {
250
+ child.remove();
251
+ return;
252
+ }
253
+ }
254
+ child.replaceWith(...child.children);
255
+ condChain.rendered = true;
256
+ if (hasElse) {
257
+ condChain.active = false;
258
+ }
259
+ } else if (hasIf || hasDelIf) {
260
+ const attr = hasIf ? "if" : "del-if";
261
+ const expr = child.getAttribute(attr).slice(1, -1);
262
+ const fn = new Function("ctx", `with(ctx) { return (${expr}); }`);
263
+ condChain.active = true;
264
+ if (fn(ctxProxy)) {
265
+ child.replaceWith(...child.children);
266
+ condChain.rendered = true;
267
+ } else {
268
+ child.remove();
269
+ condChain.rendered = false;
270
+ }
271
+ } else {
272
+ child.replaceWith(...child.children);
273
+ condChain.active = false;
274
+ condChain.rendered = false;
275
+ }
276
+ return;
277
+ }
278
+
279
+ const reservedAttrs = ["if", "del-if", "elif", "else"];
135
280
 
136
281
  Array.from(child.attributes).forEach(attribute => {
137
282
  if (!attribute || attribute === undefined) return;
@@ -159,20 +304,50 @@ export function processComponentElement(
159
304
  cssScopesMap,
160
305
  scopedStyles,
161
306
  renderChain.concat(compName),
162
- staticCtxRegistry
307
+ staticCtxRegistry,
308
+ instance.__sourceFile || compName,
309
+ body
163
310
  );
164
311
 
165
- ["if", "del-if"].forEach(statement => {
166
- const statAtt = child.getAttribute(statement);
167
- if (!statAtt) return;
168
- const expr = statAtt.slice(1, -1);
312
+ if (hasIf) {
313
+ const expr = child.getAttribute("if").slice(1, -1);
314
+ const fn = new Function("ctx", `with(ctx) { return (${expr}); }`);
315
+ condChain.active = true;
316
+ if (fn(ctxProxy)) {
317
+ condChain.rendered = true;
318
+ } else {
319
+ child.style.display = "none";
320
+ condChain.rendered = false;
321
+ }
322
+ child.removeAttribute("if");
323
+ } else if (hasDelIf) {
324
+ const expr = child.getAttribute("del-if").slice(1, -1);
169
325
  const fn = new Function("ctx", `with(ctx) { return (${expr}); }`);
170
- if (!fn(ctxProxy)) {
171
- if (statement === "if") child.style.display = "none";
172
- if (statement === "del-if") child.remove();
326
+ condChain.active = true;
327
+ if (fn(ctxProxy)) {
328
+ condChain.rendered = true;
329
+ } else {
330
+ child.remove();
331
+ condChain.rendered = false;
173
332
  }
174
- child.removeAttribute(statement);
175
- });
333
+ child.removeAttribute("del-if");
334
+ } else if (hasElif) {
335
+ const expr = child.getAttribute("elif").slice(1, -1);
336
+ const fn = new Function("ctx", `with(ctx) { return (${expr}); }`);
337
+ if (fn(ctxProxy)) {
338
+ condChain.rendered = true;
339
+ } else {
340
+ child.remove();
341
+ }
342
+ child.removeAttribute("elif");
343
+ } else if (hasElse) {
344
+ condChain.rendered = true;
345
+ condChain.active = false;
346
+ child.removeAttribute("else");
347
+ } else {
348
+ condChain.active = false;
349
+ condChain.rendered = false;
350
+ }
176
351
 
177
352
  interpolateNode(child, ctxProxy)
178
353
  });
@@ -231,11 +406,16 @@ export function processComponentElement(
231
406
  style = scopeCss(style, cssId);
232
407
  scopedStyles.push(style);
233
408
  }
409
+
410
+ if (fragment.firstChild && fragment.firstChild.nodeType === 1) {
411
+ fragment.firstChild.setAttribute("data-ch-source", instance.__sourceFile || compName);
412
+ }
413
+
234
414
  element.replaceWith(fragment);
235
415
  return true;
236
416
  }
237
417
 
238
- console.warn(chalk.yellow(`${compName} component could not be loaded`));
418
+ console.warn(chalk.yellow(`${compName} — component could not be loaded (missing body or styles export)`));
239
419
  return false;
240
420
  }
241
421
 
@@ -249,7 +429,7 @@ export function processComponentElement(
249
429
  * scopesCss: CSSString
250
430
  * }}
251
431
  */
252
- export function processAllComponents(appElements, loadedComponents) {
432
+ export function processAllComponents(appElements, loadedComponents, pageSourceFile, pageSourceContent) {
253
433
  let runtimeChunks = [];
254
434
  let compIdColl = [];
255
435
  let letterState = { value: null };
@@ -260,7 +440,7 @@ export function processAllComponents(appElements, loadedComponents) {
260
440
  let staticCtxRegistry = new Map();
261
441
 
262
442
  appElements.forEach(el => {
263
- processComponentElement(el, loadedComponents, runtimeChunks, compIdColl, letterState, runtimeMap, cssScopes, cssScopesMap, scopedStyles, [], staticCtxRegistry);
443
+ processComponentElement(el, loadedComponents, runtimeChunks, compIdColl, letterState, runtimeMap, cssScopes, cssScopesMap, scopedStyles, [], staticCtxRegistry, pageSourceFile, pageSourceContent);
264
444
  });
265
445
  const runtimeScript = runtimeChunks.join("\n");
266
446
  const hasComponents = runtimeChunks.length > 0;
@@ -20,13 +20,14 @@ export async function loadConfig(rootDir) {
20
20
  const outDir = bundleConfig.outDir || "dist";
21
21
  const libDir = bundleConfig.libDir || "lib";
22
22
  const emptyOutDir = bundleConfig.emptyOutDir !== false;
23
+ const assetImport = bundleConfig.assetImport || "legacy";
23
24
 
24
- logConfigWarnings(bundleConfig, emptyOutDir);
25
+ logConfigWarnings(bundleConfig, emptyOutDir, assetImport);
25
26
 
26
- return { srcDir, outDir, libDir, emptyOutDir };
27
+ return { srcDir, outDir, libDir, emptyOutDir, assetImport };
27
28
  }
28
29
 
29
- function logConfigWarnings(bundleConfig, emptyOutDir) {
30
+ function logConfigWarnings(bundleConfig, emptyOutDir, assetImport) {
30
31
  if (!bundleConfig.srcDir) {
31
32
  console.warn(
32
33
  chalk.bold.yellow("WARNING!"),
@@ -49,6 +50,14 @@ function logConfigWarnings(bundleConfig, emptyOutDir) {
49
50
  }
50
51
 
51
52
  console.log(`> using emptyOutDir = ${emptyOutDir}`);
53
+
54
+ if (assetImport !== "static") {
55
+ console.warn(
56
+ chalk.bold.yellow("DEPRECATION WARNING!"),
57
+ 'The legacy asset import system ("bundle.assetImport") will be removed in Chocola 2. ' +
58
+ 'Set "bundle.assetImport": "static" in chocola.config.json and migrate your assets to src/static/.'
59
+ );
60
+ }
52
61
  }
53
62
 
54
63
  /**
@@ -1,7 +1,7 @@
1
1
  import { JSDOM } from "jsdom";
2
2
  import { promises as fs } from "fs";
3
3
  import path from "path";
4
- import { throwError } from "./utils.js";
4
+ import { throwError, protectCurlyBraces, restoreCurlyBraces } from "./utils.js";
5
5
  import { readMyFile } from "./fs.js";
6
6
 
7
7
  /**
@@ -10,7 +10,7 @@ import { readMyFile } from "./fs.js";
10
10
  * @returns {JSDOM}
11
11
  */
12
12
  export function createDOM(srcIndexContent) {
13
- return new JSDOM(srcIndexContent);
13
+ return new JSDOM(protectCurlyBraces(srcIndexContent));
14
14
  }
15
15
 
16
16
  /**
@@ -56,7 +56,7 @@ export function extractContextFromElement(element) {
56
56
  */
57
57
  export async function serializeDOM(dom) {
58
58
  const beautify = (await import("js-beautify")).default;
59
- const finalHtml = dom.serialize();
59
+ const finalHtml = restoreCurlyBraces(dom.serialize());
60
60
  return beautify.html(finalHtml, { indent_size: 2 });
61
61
  }
62
62
 
package/compiler/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import path from "path";
2
1
  import { promises as fs } from "fs";
3
2
  import chalk from "chalk";
4
3
  import { loadConfig, resolvePaths } from "./config.js";
@@ -18,6 +17,7 @@ import { generateRuntimeScript } from "./runtime-generator.js";
18
17
  import { genRandomId, throwError } from "./utils.js";
19
18
  import {
20
19
  copyResources,
20
+ copyStaticDir,
21
21
  getComponents,
22
22
  getSrcIndex,
23
23
  processIcons,
@@ -101,7 +101,7 @@ async function processAssets(doc, rootDir, srcDir, outDirPath) {
101
101
  }
102
102
 
103
103
  for (const link of icons) {
104
- await processIcons(link, rootDir, srcDir, outDirPath, fileIds);
104
+ await processIcons(link, rootDir, srcDir, outDirPath);
105
105
  }
106
106
  return cssContents
107
107
  }
@@ -126,6 +126,7 @@ export default async function runtime(rootDir, buildConfig) {
126
126
 
127
127
  const indexFiles = await getSrcIndex(paths.src);
128
128
  const srcIndexContent = indexFiles.srcHtmlFile || indexFiles.srcChocoFile;
129
+ const pageSourcePath = indexFiles.srcPath;
129
130
 
130
131
  const loadedComponents = await loadAndDisplayComponents(paths.components);
131
132
  !isHotReload && console.log(logSeparation);
@@ -138,7 +139,7 @@ export default async function runtime(rootDir, buildConfig) {
138
139
  const appContainer = validateAppContainer(doc);
139
140
  const appElements = getAppElements(appContainer);
140
141
 
141
- const { runtimeScript, scopesCss } = processAllComponents(appElements, loadedComponents);
142
+ const { runtimeScript, scopesCss } = processAllComponents(appElements, loadedComponents, pageSourcePath, srcIndexContent);
142
143
  const runtimeFilename = await generateRuntimeScript(runtimeScript, paths.outDir);
143
144
  const globalCss = (await processAssets(doc, rootDir, config.srcDir, paths.outDir)).join("\n");
144
145
 
@@ -152,7 +153,15 @@ export default async function runtime(rootDir, buildConfig) {
152
153
  const html = await serializeDOM(dom);
153
154
  await writeHTMLOutput(html, paths.outDir);
154
155
 
155
- await copyResources(rootDir, scopesCss, globalCss, config.srcDir, paths.outDir);
156
+ try {
157
+ if (config.assetImport === "static") {
158
+ await copyStaticDir(paths.src, paths.outDir);
159
+ } else {
160
+ await copyResources(rootDir, scopesCss, globalCss, config.srcDir, paths.outDir);
161
+ }
162
+ } catch (err) {
163
+ throwError(err.message || err);
164
+ }
156
165
 
157
166
  !isHotReload && logSuccess(paths.outDir);
158
167
  isHotReload && console.log("Dev server updated");
@@ -33,21 +33,28 @@ export async function getComponents(libDir) {
33
33
 
34
34
  componentsLib.push(comp);
35
35
 
36
- const module = await loadWithAssets(path.join(libDir, comp));
36
+ let module;
37
+ try {
38
+ module = await loadWithAssets(path.join(libDir, comp));
37
39
 
38
- if (typeof module.default !== "function") {
39
- notDefComps.push(comp);
40
- continue;
41
- }
40
+ if (typeof module.default !== "function") {
41
+ notDefComps.push(comp);
42
+ continue;
43
+ }
42
44
 
43
- const instance = module.default();
45
+ const instance = module.default();
46
+ const compPath = path.join(libDir, comp);
47
+ instance.__sourceFile = compPath;
44
48
 
45
- loadedComponents.set(comp.toLowerCase(), instance);
49
+ loadedComponents.set(comp.toLowerCase(), instance);
50
+ } catch (err) {
51
+ throwError(`Failed to load component "${comp}": ${err.message || err}`);
52
+ }
46
53
  }
47
54
 
48
55
  return { componentsLib, loadedComponents, notDefComps };
49
56
  } catch (err) {
50
- throwError(err);
57
+ throwError(`Failed to load components from ${libDir}: ${err.message}`);
51
58
  }
52
59
  }
53
60
 
@@ -80,7 +87,7 @@ export async function getSrcIndex(srcPath) {
80
87
  if (htmlExists) {
81
88
  try {
82
89
  srcHtmlFile = await readMyFile(srcHtmlPath);
83
- return { srcHtmlFile, srcChocoFile };
90
+ return { srcHtmlFile, srcChocoFile, srcPath: srcHtmlPath };
84
91
  } catch (err) {
85
92
  throwError(err);
86
93
  }
@@ -113,7 +120,7 @@ export async function processStylesheet(link, rootDir, srcDir, outDirPath, fileI
113
120
 
114
121
  return css;
115
122
  } catch (err) {
116
- throwError(err);
123
+ throwError(`Failed to process stylesheet: ${err}`);
117
124
  }
118
125
  }
119
126
 
@@ -128,65 +135,66 @@ export async function processStylesheet(link, rootDir, srcDir, outDirPath, fileI
128
135
  export async function processIcons(link, rootDir, srcDir, outDirPath) {
129
136
  try {
130
137
  const href = link.href;
138
+ if (href.startsWith("http://") || href.startsWith("https://")) return;
131
139
  const iconPath = path.join(rootDir, srcDir, href);
132
140
  await fs.copyFile(iconPath, path.join(outDirPath, href));
133
141
  } catch (err) {
134
- throwError(err);
142
+ throwError(`Failed to copy icon: ${err}`);
135
143
  }
136
144
  }
137
145
 
138
146
  export function getCssAssets(css) {
139
147
  const results = new Set();
140
-
141
148
  const urlRegex = /url\(([^)]+)\)/gi;
142
-
143
149
  const importStringRegex = /@import\s+["']([^"']+)["']/gi;
144
-
145
150
  const importUrlRegex = /@import\s+url\(([^)]+)\)/gi;
146
151
 
147
152
  const clean = (raw) => {
148
153
  let p = raw.trim();
149
-
150
154
  if (
151
155
  (p.startsWith('"') && p.endsWith('"')) ||
152
156
  (p.startsWith("'") && p.endsWith("'"))
153
157
  ) {
154
158
  p = p.slice(1, -1);
155
159
  }
156
-
157
160
  if (p.startsWith("data:")) return null;
158
-
159
161
  return p;
160
162
  };
161
163
 
162
164
  let match;
163
-
164
165
  while ((match = urlRegex.exec(css))) {
165
166
  const p = clean(match[1]);
166
167
  if (p) results.add(p);
167
168
  }
168
-
169
169
  while ((match = importStringRegex.exec(css))) {
170
170
  const p = clean(match[1]);
171
171
  if (p) results.add(p);
172
172
  }
173
-
174
173
  while ((match = importUrlRegex.exec(css))) {
175
174
  const p = clean(match[1]);
176
175
  if (p) results.add(p);
177
176
  }
178
-
179
177
  return [...results];
180
178
  }
181
179
 
180
+ function getComponentSource(el) {
181
+ let cur = el;
182
+ while (cur) {
183
+ const src = cur.getAttribute?.("data-ch-source");
184
+ if (src) return src;
185
+ cur = cur.parentElement;
186
+ }
187
+ return null;
188
+ }
182
189
 
183
190
  /**
184
- * Copies all local resources (images, fonts, etc.) to output directory
185
- * Excludes web links and script/link tags
191
+ * Copies all local resources (images, fonts, etc.) to output directory.
192
+ * Excludes web links and script/link tags.
186
193
  * @param {import("fs").PathLike} rootDir - Root project directory
194
+ * @param {string} scopesCss - Scoped CSS content
195
+ * @param {string} globalCss - Global CSS content
187
196
  * @param {string} srcDir - Source directory name
188
197
  * @param {import("fs").PathLike} outDirPath - Output directory path
189
- * @throws {Error} if resources cannot be copied
190
198
  */
191
199
  export async function copyResources(rootDir, scopesCss, globalCss, srcDir, outDirPath) {
192
200
  try {
@@ -198,53 +206,83 @@ export async function copyResources(rootDir, scopesCss, globalCss, srcDir, outDi
198
206
  for (const el of newElements) {
199
207
  if (el.tagName === "LINK" || el.tagName === "SCRIPT") continue;
200
208
 
201
- const src = el.getAttribute("src") || el.getAttribute("href");
202
-
203
- if (src && !isWebLink(src)) {
209
+ const attrName = el.getAttribute("src") ? "src" : "href";
210
+ const src = el.getAttribute(attrName);
211
+ if (src && !isWebLink(src) && !src.startsWith("#") && !src.startsWith("/")) {
204
212
  const srcPath = path.join(rootDir, srcDir, src);
205
213
  const destPath = path.join(outDirPath, src);
206
-
207
- await fs.mkdir(path.dirname(destPath), { recursive: true });
208
- await fs.copyFile(srcPath, destPath);
214
+ try {
215
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
216
+ await fs.copyFile(srcPath, destPath);
217
+ } catch (err) {
218
+ throw new Error(
219
+ `Failed to copy resource referenced by <${el.tagName.toLowerCase()} ${attrName}="${src}">` +
220
+ (getComponentSource(el) ? ` in component "${getComponentSource(el)}"` : "") +
221
+ `: ${err.message || err}`
222
+ );
223
+ }
209
224
  }
210
225
 
211
226
  const styles = el.getAttribute("style");
212
-
213
227
  if (styles) {
214
228
  let stylesMatch;
215
229
  while ((stylesMatch = inlinePathsRegex.exec(styles)) !== null) {
216
230
  let filePath = stylesMatch[1].trim();
217
-
218
231
  if (
219
232
  (filePath.startsWith('"') && filePath.endsWith('"')) ||
220
233
  (filePath.startsWith("'") && filePath.endsWith("'"))
221
234
  ) {
222
235
  filePath = filePath.slice(1, -1);
223
236
  }
224
-
225
237
  const srcPath = path.join(rootDir, srcDir, filePath);
226
238
  const destPath = path.join(outDirPath, filePath);
227
-
228
- await fs.mkdir(path.dirname(destPath), { recursive: true });
229
- await fs.copyFile(srcPath, destPath);
239
+ try {
240
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
241
+ await fs.copyFile(srcPath, destPath);
242
+ } catch (err) {
243
+ throw new Error(
244
+ `Failed to copy resource from inline style on <${el.tagName.toLowerCase()}> url("${filePath}")` +
245
+ (getComponentSource(el) ? ` in component "${getComponentSource(el)}"` : "") +
246
+ `: ${err.message || err}`
247
+ );
248
+ }
230
249
  }
231
250
  }
232
251
  }
233
252
 
234
- const cssAssets = [...getCssAssets(scopesCss), ...getCssAssets(globalCss)];
253
+ const cssAssets = [...getCssAssets(scopesCss || ""), ...getCssAssets(globalCss || "")];
235
254
  for (const assetPath of cssAssets) {
236
255
  try {
237
256
  if (assetPath.startsWith('http:') || assetPath.startsWith('https:')) continue;
238
257
  const srcPath = path.join(rootDir, srcDir, assetPath);
239
258
  const destPath = path.join(outDirPath, assetPath);
240
-
241
259
  await fs.mkdir(path.dirname(destPath), { recursive: true });
242
260
  await fs.copyFile(srcPath, destPath);
243
261
  } catch (err) {
244
- throwError(err)
262
+ throw new Error(
263
+ `Failed to copy CSS asset "${assetPath}" referenced from stylesheets: ${err.message || err}`
264
+ );
245
265
  }
246
266
  }
247
267
  } catch (err) {
248
- throwError(err);
268
+ throw err;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Copies the static/ directory from source to output.
274
+ * The entire static/ directory tree is copied recursively.
275
+ * Silently skips if static/ does not exist.
276
+ * @param {import("fs").PathLike} srcPath - Source directory path
277
+ * @param {import("fs").PathLike} outDirPath - Output directory path
278
+ */
279
+ export async function copyStaticDir(srcPath, outDirPath) {
280
+ const staticSrc = path.join(srcPath, "static");
281
+ const staticDest = path.join(outDirPath, "static");
282
+ try {
283
+ await fs.access(staticSrc);
284
+ await fs.cp(staticSrc, staticDest, { recursive: true, force: true });
285
+ } catch {
286
+ // static/ directory doesn't exist — nothing to copy
249
287
  }
250
288
  }
package/compiler/utils.js CHANGED
@@ -24,28 +24,51 @@ export function throwError(err) {
24
24
  * export default function Button() { return { body: template }; }
25
25
  */
26
26
  export async function loadWithAssets(filePath) {
27
- let code = await fs.readFile(filePath, "utf8");
27
+ let code;
28
+ try {
29
+ code = await fs.readFile(filePath, "utf8");
30
+ } catch (err) {
31
+ throwError(`Failed to read component file "${filePath}": ${err.message || err}`);
32
+ }
28
33
 
29
34
  const importRegex = /import\s+(\w+)\s+from\s+["'](.+?\.(html|css))["'];?/g;
30
35
 
36
+ const replacements = [];
31
37
  let match;
32
38
  while ((match = importRegex.exec(code))) {
33
39
  const varName = match[1];
34
40
  const relPath = match[2];
35
-
36
41
  const absPath = path.resolve(path.dirname(filePath), relPath);
37
- let content = await fs.readFile(absPath, "utf8");
38
42
 
39
- const replacement = `const ${varName} = ${JSON.stringify(content)};`;
40
- code = code.replace(match[0], replacement);
43
+ let content;
44
+ try {
45
+ content = await fs.readFile(absPath, "utf8");
46
+ } catch (err) {
47
+ throwError(
48
+ `Failed to read imported file "${relPath}" (resolved to "${absPath}") ` +
49
+ `for variable "${varName}" in component "${filePath}": ${err.message || err}`
50
+ );
51
+ }
52
+
53
+ replacements.push({ from: match[0], to: `const ${varName} = ${JSON.stringify(content)};` });
54
+ }
55
+
56
+ for (const { from, to } of replacements) {
57
+ code = code.replace(from, to);
41
58
  }
42
59
 
43
60
  const dataUrl =
44
61
  "data:text/javascript;base64," +
45
62
  Buffer.from(code).toString("base64");
46
63
 
47
- const mod = await import(dataUrl);
48
- return mod;
64
+ try {
65
+ const mod = await import(dataUrl);
66
+ return mod;
67
+ } catch (err) {
68
+ throwError(
69
+ `Failed to evaluate component "${filePath}": ${err.message || err}`
70
+ );
71
+ }
49
72
  }
50
73
 
51
74
  /**
@@ -111,3 +134,30 @@ export function isWebLink(str) {
111
134
  return false;
112
135
  }
113
136
  }
137
+
138
+ const LBRACE_PH = "_%%CHOCOLA-LBRACE%%_";
139
+ const RBRACE_PH = "_%%CHOCOLA-RBRACE%%_";
140
+
141
+ /**
142
+ * Replaces HTML entities for curly braces with placeholders before JSDOM parsing.
143
+ * This prevents JSDOM from decoding them into literal braces before binding processing.
144
+ * @param {string} html - Raw HTML content
145
+ * @returns {string} - HTML with curly brace entities replaced
146
+ */
147
+ export function protectCurlyBraces(html) {
148
+ return html
149
+ .replace(/&(?:lbrace|#123|#x7B);/gi, LBRACE_PH)
150
+ .replace(/&(?:rcub|#125|#x7D);/gi, RBRACE_PH);
151
+ }
152
+
153
+ /**
154
+ * Restores curly brace placeholders back to literal characters.
155
+ * Called after binding processing is complete and before final output.
156
+ * @param {string} html - Serialized HTML with placeholders
157
+ * @returns {string} - HTML with literal curly braces restored
158
+ */
159
+ export function restoreCurlyBraces(html) {
160
+ return html
161
+ .replace(/_%%CHOCOLA-LBRACE%%_/g, "{")
162
+ .replace(/_%%CHOCOLA-RBRACE%%_/g, "}");
163
+ }
package/dev/index.js CHANGED
@@ -112,10 +112,10 @@ export async function serve(__rootdir) {
112
112
  if (error) {
113
113
  if (error.code === "ENOENT") {
114
114
  res.writeHead(404, { "Content-Type": "text/html" });
115
- res.end("<h1>404 Not Found</h1>", "utf-8");
115
+ res.end("<h1>404 Not Found</h1><p>The requested resource was not found on this server.</p>", "utf-8");
116
116
  } else {
117
117
  res.writeHead(500);
118
- res.end("Error interno: " + error.code);
118
+ res.end("Internal server error: " + error.code);
119
119
  }
120
120
  } else {
121
121
  if (extname === ".html" || extname === ".htm") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chocola",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "The sweetest way to build reactive web apps",
5
5
  "keywords": [
6
6
  "web",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "chalk": "^5.6.2",
35
+ "chocola": "file:chocola-1.4.2.tgz",
35
36
  "js-beautify": "^1.15.4",
36
37
  "jsdom": "^27.4.0"
37
38
  },
package/utils.js CHANGED
@@ -12,6 +12,6 @@ export async function getChocolaConfig(__rootdir) {
12
12
  const config = await fs.readFile(path.join(__rootdir, "chocola.config.json"), "utf-8");
13
13
  return JSON.parse(config);
14
14
  } catch(err) {
15
- throwError("An error ocurred while fetching the Chocola config file:\n" + err);
15
+ throwError("An error occurred while fetching the Chocola config file:\n" + err);
16
16
  }
17
17
  }