chocola 1.6.0 → 2.0.0-next.10

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.
@@ -0,0 +1,210 @@
1
+ import path from "path";
2
+ import { promises as fs } from "fs";
3
+ import { parseHTML } from "linkedom";
4
+ import chalk from "./chalk.js";
5
+ import { loadConfig, resolvePaths } from "./config.js";
6
+ import { getComponents, getSrcIndex } from "./pipeline.js";
7
+ import { protectCurlyBraces } from "../utils.js";
8
+ import { deterministicHash, runtimeFunctionId } from "./utils.js";
9
+ import { createDOM, getAssetLinks, getScriptElements } from "./dom-processor.js";
10
+ import {
11
+ extractPropsDefaults, extractTopLevelFunctions, extractTopLevelVariables,
12
+ } from "../parser/index.js";
13
+
14
+ const RUNTIME_KW = "$runtime";
15
+ const PAGE_ID = "index.html";
16
+
17
+ export class ChocolaModule {
18
+ constructor({ id, kind, sourcePath, source, mtimeMs }) {
19
+ this.id = id;
20
+ this.kind = kind;
21
+ this.sourcePath = sourcePath;
22
+ this.source = source;
23
+ this.mtimeMs = mtimeMs;
24
+ this.compName = kind === "component" ? path.basename(id) : null;
25
+ this.deps = new Set();
26
+ this.parsed = false;
27
+ this.script = null;
28
+ this.template = null;
29
+ this.styles = null;
30
+ this.compProps = [];
31
+ this.topFuncs = [];
32
+ this.topVars = [];
33
+ this.imports = [];
34
+ this.cssId = null;
35
+ this.fnId = null;
36
+ }
37
+ }
38
+
39
+ export class ModuleGraph {
40
+ constructor(rootDir, config, paths) {
41
+ this.rootDir = rootDir;
42
+ this.config = config;
43
+ this.paths = paths;
44
+ this.modules = new Map();
45
+ this.components = new Map();
46
+ this.loadedComponents = new Map();
47
+ this.page = null;
48
+ }
49
+
50
+ addModule(module) {
51
+ this.modules.set(module.id, module);
52
+ if (module.kind === "component") {
53
+ this.components.set(module.compName, module);
54
+ this.loadedComponents.set(module.compName, module.source);
55
+ }
56
+ }
57
+
58
+ component(compName) {
59
+ return this.components.get(compName);
60
+ }
61
+
62
+ moduleById(id) {
63
+ return this.modules.get(id);
64
+ }
65
+ }
66
+
67
+ async function getFileMtime(filePath) {
68
+ try {
69
+ return (await fs.stat(filePath)).mtimeMs;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function isExternalUrl(url) {
76
+ return url.startsWith("http://") || url.startsWith("https://");
77
+ }
78
+
79
+ function compileComponentModule(module, graph) {
80
+ if (module.parsed) return module;
81
+
82
+ const dom = parseHTML(protectCurlyBraces(module.source));
83
+ const doc = dom.document;
84
+ module.script = doc.querySelector("script")?.innerHTML ?? null;
85
+ module.template = doc.querySelector("template")?.innerHTML ?? null;
86
+ module.styles = doc.querySelector("style")?.innerHTML ?? null;
87
+ module.compProps = extractPropsDefaults(module.script);
88
+ module.topFuncs = extractTopLevelFunctions(module.script || "", RUNTIME_KW);
89
+ module.topVars = extractTopLevelVariables(module.script || "");
90
+ module.cssId = deterministicHash(module.compName, 8);
91
+ module.fnId = runtimeFunctionId(module.compName);
92
+
93
+ const deps = new Set();
94
+ if (module.script) {
95
+ const importRegex = /import\s+(\w+)\s+from\s+['"]([^'"]+)['"]\s*;?\s*/g;
96
+ let match;
97
+ while ((match = importRegex.exec(module.script)) !== null) {
98
+ const importedCompName = path.basename(match[2]).toLowerCase();
99
+ const importedModule = graph.component(importedCompName);
100
+ if (importedModule) deps.add(importedModule.id);
101
+ }
102
+ }
103
+ if (module.template) {
104
+ const frag = parseHTML(module.template).document;
105
+ for (const el of frag.querySelectorAll("*")) {
106
+ const childModule = graph.component(el.tagName.toLowerCase() + ".html");
107
+ if (childModule) deps.add(childModule.id);
108
+ }
109
+ }
110
+ module.deps = deps;
111
+ module.parsed = true;
112
+ return module;
113
+ }
114
+
115
+ function scanComponentDeps(root, graph) {
116
+ const deps = new Set();
117
+ for (const el of root.querySelectorAll("*")) {
118
+ const childModule = graph.component(el.tagName.toLowerCase() + ".html");
119
+ if (childModule) deps.add(childModule.id);
120
+ }
121
+ return deps;
122
+ }
123
+
124
+ function scanAssetModules(doc, graph) {
125
+ const deps = new Set();
126
+ const { stylesheets, icons } = getAssetLinks(doc);
127
+ const scripts = getScriptElements(doc);
128
+
129
+ const collect = (href) => {
130
+ if (isExternalUrl(href)) return;
131
+ const id = href.split("\\").join("/");
132
+ const existing = graph.moduleById(id);
133
+ if (existing) {
134
+ deps.add(id);
135
+ return;
136
+ }
137
+ const asset = new ChocolaModule({
138
+ id,
139
+ kind: "asset",
140
+ sourcePath: path.join(graph.paths.src, href),
141
+ source: null,
142
+ mtimeMs: null,
143
+ });
144
+ graph.addModule(asset);
145
+ deps.add(id);
146
+ };
147
+
148
+ for (const link of stylesheets) collect(link.href);
149
+ for (const link of icons) collect(link.href);
150
+ for (const script of scripts) collect(script.getAttribute("src"));
151
+
152
+ return deps;
153
+ }
154
+
155
+ export async function buildModuleGraph(rootDir) {
156
+ const config = await loadConfig(rootDir);
157
+ const paths = resolvePaths(rootDir, config);
158
+
159
+ const graph = new ModuleGraph(rootDir, config, paths);
160
+
161
+ const indexFiles = await getSrcIndex(paths.src);
162
+ const pageModule = new ChocolaModule({
163
+ id: PAGE_ID,
164
+ kind: "page",
165
+ sourcePath: indexFiles.srcPath,
166
+ source: indexFiles.srcHtmlFile,
167
+ mtimeMs: await getFileMtime(indexFiles.srcPath),
168
+ });
169
+ graph.addModule(pageModule);
170
+ graph.page = pageModule;
171
+
172
+ const foundComponents = await getComponents(paths.components);
173
+ const { loadedComponents, componentsLib, emptyComps } = foundComponents;
174
+
175
+ console.log(chalk.bold.green(">"), "Components found in", chalk.green.underline(paths.components) + ":");
176
+ console.log(" ", componentsLib, "\n\n");
177
+
178
+ if (emptyComps?.length > 0) {
179
+ console.warn(chalk.bold.yellow("WARNING!"), "The following component files are empty:");
180
+ console.log(" ", emptyComps);
181
+ }
182
+
183
+ for (const filename of componentsLib) {
184
+ const compName = filename.toLowerCase();
185
+ const source = loadedComponents.get(compName);
186
+ if (source === undefined) continue;
187
+ const id = path.posix.join(config.libDir, compName);
188
+ const module = new ChocolaModule({
189
+ id,
190
+ kind: "component",
191
+ sourcePath: path.join(paths.components, filename),
192
+ source,
193
+ mtimeMs: await getFileMtime(path.join(paths.components, filename)),
194
+ });
195
+ graph.addModule(module);
196
+ }
197
+
198
+ for (const module of graph.modules.values()) {
199
+ if (module.kind === "component") compileComponentModule(module, graph);
200
+ }
201
+
202
+ const pageDom = createDOM(pageModule.source);
203
+ const pageDeps = scanComponentDeps(pageDom.document, graph);
204
+ for (const dep of scanAssetModules(pageDom.document, graph)) {
205
+ pageDeps.add(dep);
206
+ }
207
+ pageModule.deps = pageDeps;
208
+
209
+ return graph;
210
+ }
@@ -1,26 +1,13 @@
1
1
  import { promises as fs } from "fs";
2
- import { loadWithAssets, throwError, genRandomId, isWebLink } from "./utils.js";
2
+ import { throwError, deterministicHash } from "./utils.js";
3
3
  import { readMyFile, checkFile } from "./fs.js";
4
- import { JSDOM } from "jsdom";
5
4
  import path from "path";
6
5
 
7
- /**
8
- * Discovers and loads all components from a library directory.
9
- * Components are JavaScript files that start with an uppercase letter.
10
- * They must have a default export that is a function
11
- * @param {import("node:fs").PathLike} libDir - Directory containing component files
12
- * @returns {Promise<{
13
- * componentsLib: string[],
14
- * loadedComponents: Map<string, object>,
15
- * notDefComps: string[]
16
- * }>}
17
- * @throws {Error} if libDir cannot be found
18
- */
19
6
  export async function getComponents(libDir) {
20
7
  try {
21
8
  let componentsLib = [];
22
9
  let loadedComponents = new Map();
23
- let notDefComps = [];
10
+ let emptyComps = [];
24
11
 
25
12
  const components = await fs.readdir(libDir);
26
13
 
@@ -28,46 +15,30 @@ export async function getComponents(libDir) {
28
15
  throw Error(`The specified components folder ${libDir} could not be found.`);
29
16
  }
30
17
 
31
- for (const comp of components) {
32
- if (!comp.endsWith(".js") || comp[0] !== comp[0].toUpperCase()) continue;
33
-
34
- componentsLib.push(comp);
18
+ const reads = components
19
+ .filter(comp => comp.endsWith(".html"))
20
+ .map(async (comp) => {
21
+ try {
22
+ const compPath = path.join(libDir, comp);
23
+ const instance = await fs.readFile(compPath, "utf-8");
35
24
 
36
- let module;
37
- try {
38
- module = await loadWithAssets(path.join(libDir, comp));
25
+ if (instance === "" || instance.trim().length === 0) emptyComps.push(comp);
39
26
 
40
- if (typeof module.default !== "function") {
41
- notDefComps.push(comp);
42
- continue;
27
+ loadedComponents.set(comp.toLowerCase(), instance);
28
+ componentsLib.push(comp);
29
+ } catch (err) {
30
+ throwError(`Failed to load component "${comp}": ${err.message || err}`);
43
31
  }
32
+ });
44
33
 
45
- const instance = module.default();
46
- const compPath = path.join(libDir, comp);
47
- instance.__sourceFile = compPath;
48
-
49
- loadedComponents.set(comp.toLowerCase(), instance);
50
- } catch (err) {
51
- throwError(`Failed to load component "${comp}": ${err.message || err}`);
52
- }
53
- }
34
+ await Promise.all(reads);
54
35
 
55
- return { componentsLib, loadedComponents, notDefComps };
36
+ return { componentsLib, loadedComponents, emptyComps };
56
37
  } catch (err) {
57
38
  throwError(`Failed to load components from ${libDir}: ${err.message}`);
58
39
  }
59
40
  }
60
41
 
61
- /**
62
- * Loads the project index file (HTML or .choco)
63
- * If both HTML and .choco files exist, throws an error
64
- * @param {import("fs").PathLike} srcPath - Source directory
65
- * @returns {Promise<{
66
- * srcHtmlFile: string | null,
67
- * srcChocoFile: string | null
68
- * }>}
69
- * @throws {Error} if both index files exist or .choco is used (not yet supported)
70
- */
71
42
  export async function getSrcIndex(srcPath) {
72
43
  const srcHtmlPath = path.join(srcPath, "index.html");
73
44
 
@@ -85,24 +56,21 @@ export async function getSrcIndex(srcPath) {
85
56
  }
86
57
  }
87
58
 
88
- /**
89
- * Processes stylesheet links: copies CSS files to output and updates link href
90
- * @param {HTMLLinkElement} link - The link element to process
91
- * @param {import("fs").PathLike} rootDir - Root project directory
92
- * @param {string} srcDir - Source directory name
93
- * @param {import("fs").PathLike} outDirPath - Output directory path
94
- * @param {Array} fileIds - Array to track used file IDs
95
- * @throws {Error} if stylesheet cannot be read or written
96
- */
97
- export async function processStylesheet(link, rootDir, srcDir, outDirPath, fileIds) {
59
+ export async function processStylesheet(link, rootDir, srcDir, out) {
98
60
  try {
99
61
  const href = link.href;
100
62
  if (href.startsWith("http://") || href.startsWith("https://")) return;
101
63
  const stylesheetPath = path.join(rootDir, srcDir, href);
102
64
  const css = await fs.readFile(stylesheetPath, { encoding: "utf8" });
103
- const cssFileName = "css-" + genRandomId(fileIds, 6) + ".css";
65
+ const base = deterministicHash(css + href, 6);
66
+ let cssFileName = "css-" + base + ".css";
67
+ let counter = 0;
68
+ while (out.ids.includes(cssFileName)) {
69
+ cssFileName = "css-" + deterministicHash(css + href + counter++, 6) + ".css";
70
+ }
71
+ out.ids.push(cssFileName);
104
72
 
105
- await fs.writeFile(path.join(outDirPath, cssFileName), css);
73
+ out.files.push({ path: cssFileName, content: css });
106
74
  link.setAttribute("href", "./" + cssFileName);
107
75
 
108
76
  return css;
@@ -111,165 +79,55 @@ export async function processStylesheet(link, rootDir, srcDir, outDirPath, fileI
111
79
  }
112
80
  }
113
81
 
114
- /**
115
- * Processes icon links: copies icon files to output directory
116
- * @param {HTMLLinkElement} link - The link element to process
117
- * @param {import("fs").PathLike} rootDir - Root project directory
118
- * @param {string} srcDir - Source directory name
119
- * @param {import("fs").PathLike} outDirPath - Output directory path
120
- * @throws {Error} if icon cannot be copied
121
- */
122
- export async function processIcons(link, rootDir, srcDir, outDirPath) {
82
+ export async function processIcons(link, rootDir, srcDir, out) {
123
83
  try {
124
84
  const href = link.href;
125
85
  if (href.startsWith("http://") || href.startsWith("https://")) return;
126
86
  const iconPath = path.join(rootDir, srcDir, href);
127
- await fs.copyFile(iconPath, path.join(outDirPath, href));
87
+ out.copies.push({ from: iconPath, to: path.join(out.outDir, href) });
128
88
  } catch (err) {
129
89
  throwError(`Failed to copy icon: ${err}`);
130
90
  }
131
91
  }
132
92
 
133
- export function getCssAssets(css) {
134
- const results = new Set();
135
- const urlRegex = /url\(([^)]+)\)/gi;
136
- const importStringRegex = /@import\s+["']([^"']+)["']/gi;
137
- const importUrlRegex = /@import\s+url\(([^)]+)\)/gi;
138
-
139
- const clean = (raw) => {
140
- let p = raw.trim();
141
- if (
142
- (p.startsWith('"') && p.endsWith('"')) ||
143
- (p.startsWith("'") && p.endsWith("'"))
144
- ) {
145
- p = p.slice(1, -1);
146
- }
147
- if (p.startsWith("data:")) return null;
148
- return p;
149
- };
150
-
151
- let match;
152
- while ((match = urlRegex.exec(css))) {
153
- const p = clean(match[1]);
154
- if (p) results.add(p);
155
- }
156
- while ((match = importStringRegex.exec(css))) {
157
- const p = clean(match[1]);
158
- if (p) results.add(p);
159
- }
160
- while ((match = importUrlRegex.exec(css))) {
161
- const p = clean(match[1]);
162
- if (p) results.add(p);
163
- }
164
- return [...results];
165
- }
166
-
167
- function getComponentSource(el) {
168
- let cur = el;
169
- while (cur) {
170
- const src = cur.getAttribute?.("data-ch-source");
171
- if (src) return src;
172
- cur = cur.parentElement;
173
- }
174
- return null;
175
- }
176
-
177
- /**
178
- * Copies all local resources (images, fonts, etc.) to output directory.
179
- * Excludes web links and script/link tags.
180
- * @param {import("fs").PathLike} rootDir - Root project directory
181
- * @param {string} scopesCss - Scoped CSS content
182
- * @param {string} globalCss - Global CSS content
183
- * @param {string} srcDir - Source directory name
184
- * @param {import("fs").PathLike} outDirPath - Output directory path
185
- */
186
- export async function copyResources(rootDir, scopesCss, globalCss, srcDir, outDirPath) {
93
+ export async function processScript(doc, script, rootDir, srcDir, out) {
187
94
  try {
188
- const newIndex = await fs.readFile(path.join(outDirPath, "index.html"), "utf8");
189
- const newDoc = new JSDOM(newIndex);
190
- const newElements = Array.from(newDoc.window.document.querySelectorAll("*"));
191
- const inlinePathsRegex = /url\((.*?)\)/gi;
192
-
193
- for (const el of newElements) {
194
- if (el.tagName === "LINK" || el.tagName === "SCRIPT") continue;
195
-
196
- const attrName = el.getAttribute("src") ? "src" : "href";
197
- const src = el.getAttribute(attrName);
198
- if (src && !isWebLink(src) && !src.startsWith("#") && !src.startsWith("/")) {
199
- const srcPath = path.join(rootDir, srcDir, src);
200
- const destPath = path.join(outDirPath, src);
201
- try {
202
- await fs.mkdir(path.dirname(destPath), { recursive: true });
203
- await fs.copyFile(srcPath, destPath);
204
- } catch (err) {
205
- throw new Error(
206
- `Failed to copy resource referenced by <${el.tagName.toLowerCase()} ${attrName}="${src}">` +
207
- (getComponentSource(el) ? ` in component "${getComponentSource(el)}"` : "") +
208
- `: ${err.message || err}`
209
- );
210
- }
211
- }
212
-
213
- const styles = el.getAttribute("style");
214
- if (styles) {
215
- let stylesMatch;
216
- while ((stylesMatch = inlinePathsRegex.exec(styles)) !== null) {
217
- let filePath = stylesMatch[1].trim();
218
- if (
219
- (filePath.startsWith('"') && filePath.endsWith('"')) ||
220
- (filePath.startsWith("'") && filePath.endsWith("'"))
221
- ) {
222
- filePath = filePath.slice(1, -1);
223
- }
224
- const srcPath = path.join(rootDir, srcDir, filePath);
225
- const destPath = path.join(outDirPath, filePath);
226
- try {
227
- await fs.mkdir(path.dirname(destPath), { recursive: true });
228
- await fs.copyFile(srcPath, destPath);
229
- } catch (err) {
230
- throw new Error(
231
- `Failed to copy resource from inline style on <${el.tagName.toLowerCase()}> url("${filePath}")` +
232
- (getComponentSource(el) ? ` in component "${getComponentSource(el)}"` : "") +
233
- `: ${err.message || err}`
234
- );
235
- }
236
- }
237
- }
95
+ const src = script.getAttribute("src");
96
+ if (src.startsWith("http://") || src.startsWith("https://")) return;
97
+ const scriptPath = path.join(rootDir, srcDir, src);
98
+ const content = await fs.readFile(scriptPath, { encoding: "utf8" });
99
+ const base = deterministicHash(content + src, 6);
100
+ let jsFileName = "js-" + base + ".js";
101
+ let counter = 0;
102
+ while (out.ids.includes(jsFileName)) {
103
+ jsFileName = "js-" + deterministicHash(content + src + counter++, 6) + ".js";
238
104
  }
239
-
240
- const cssAssets = [...getCssAssets(scopesCss || ""), ...getCssAssets(globalCss || "")];
241
- for (const assetPath of cssAssets) {
242
- try {
243
- if (assetPath.startsWith('http:') || assetPath.startsWith('https:')) continue;
244
- const srcPath = path.join(rootDir, srcDir, assetPath);
245
- const destPath = path.join(outDirPath, assetPath);
246
- await fs.mkdir(path.dirname(destPath), { recursive: true });
247
- await fs.copyFile(srcPath, destPath);
248
- } catch (err) {
249
- throw new Error(
250
- `Failed to copy CSS asset "${assetPath}" referenced from stylesheets: ${err.message || err}`
251
- );
105
+ out.ids.push(jsFileName);
106
+ out.files.push({ path: jsFileName, content });
107
+
108
+ const newScript = doc.createElement("script");
109
+ for (const attr of script.attributes) {
110
+ if (attr.name === "src") {
111
+ newScript.setAttribute("src", "./" + jsFileName);
112
+ } else {
113
+ newScript.setAttribute(attr.name, attr.value);
252
114
  }
253
115
  }
116
+ if (script.textContent.trim()) {
117
+ newScript.textContent = script.textContent;
118
+ }
119
+ script.parentNode.replaceChild(newScript, script);
254
120
  } catch (err) {
255
- throw err;
121
+ throwError(`Failed to process script: ${err}`);
256
122
  }
257
123
  }
258
124
 
259
- /**
260
- * Copies the static/ directory from source to output.
261
- * The entire static/ directory tree is copied recursively.
262
- * Silently skips if static/ does not exist.
263
- * @param {import("fs").PathLike} srcPath - Source directory path
264
- * @param {import("fs").PathLike} outDirPath - Output directory path
265
- */
266
- export async function copyStaticDir(srcPath, outDirPath) {
125
+ export async function copyStaticDir(srcPath, out) {
267
126
  const staticSrc = path.join(srcPath, "static");
268
- const staticDest = path.join(outDirPath, "static");
127
+ const staticDest = path.join(out.outDir, "static");
269
128
  try {
270
129
  await fs.access(staticSrc);
271
- await fs.cp(staticSrc, staticDest, { recursive: true, force: true });
130
+ out.copies.push({ from: staticSrc, to: staticDest, recursive: true });
272
131
  } catch {
273
- // static/ directory doesn't exist — nothing to copy
274
132
  }
275
133
  }