@c6fc/spellcraft 0.0.1

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.
package/src/index.js ADDED
@@ -0,0 +1,796 @@
1
+ // ./src/index.js
2
+ 'use strict';
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const yaml = require('js-yaml');
7
+ const crypto = require('crypto');
8
+ const { spawnSync } = require('child_process');
9
+ const { Jsonnet } = require("@hanazuki/node-jsonnet");
10
+
11
+ const baseDir = process.cwd();
12
+
13
+ /**
14
+ * @constant {object} defaultFileTypeHandlers
15
+ * @description Default handlers for different file types based on their extensions.
16
+ * Each key is a regex string to match filenames, and the value is a function
17
+ * that takes the file content (as a JavaScript object/value) and returns a string.
18
+ */
19
+ const defaultFileTypeHandlers = {
20
+ // JSON files
21
+ '.*?\.json$': (content) => JSON.stringify(content, null, 4),
22
+ // YAML files
23
+ '.*?\.yaml$': (content) => yaml.dump(content, { indent: 4 }),
24
+ '.*?\.yml$': (content) => yaml.dump(content, { indent: 4 }),
25
+ };
26
+
27
+ /**
28
+ * Default file handler used when no specific handler matches the file type.
29
+ * @param {*} content - The content to be stringified.
30
+ * @returns {string} The content stringified as JSON with an indent of 4.
31
+ */
32
+ const defaultFileHandler = (content) => JSON.stringify(content, null, 4);
33
+
34
+ /**
35
+ * Extracts parameter names from a function signature.
36
+ * Note: This method relies on Function.prototype.toString() and may not be
37
+ * robust for all JavaScript function syntaxes (e.g., minified code,
38
+ * complex destructuring in parameters).
39
+ * @param {Function} func - The function to inspect.
40
+ * @returns {string[]} An array of parameter names.
41
+ */
42
+ function getFunctionParameterList(func) {
43
+ let funcStr = func.toString();
44
+
45
+ // Remove comments and function body
46
+ funcStr = funcStr.replace(/\/\*[\s\S]*?\*\//g, '') // Multi-line comments
47
+ .replace(/\/\/(.)*/g, '') // Single-line comments
48
+ .replace(/{[\s\S]*}/, '') // Function body
49
+ .replace(/=>/g, '') // Arrow function syntax
50
+ .trim();
51
+
52
+ const paramStartIndex = funcStr.indexOf("(") + 1;
53
+ // For arrow functions without parentheses for a single arg, e.g., x => x*x
54
+ // This regex might not be perfect. A more robust parser would be needed for all cases.
55
+ const paramEndIndex = funcStr.lastIndexOf(")");
56
+
57
+ if (paramStartIndex === 0 || paramEndIndex === -1 || paramStartIndex >= paramEndIndex) {
58
+ // Handle case for single arg arrow function without parens like `arg => ...`
59
+ // Or if no parameters are found.
60
+ const potentialSingleArg = funcStr.split('=>')[0].trim();
61
+ if (potentialSingleArg && !potentialSingleArg.includes('(') && !potentialSingleArg.includes(')')) {
62
+ return [potentialSingleArg].filter(p => p.length > 0);
63
+ }
64
+ return [];
65
+ }
66
+
67
+ const paramsString = funcStr.substring(paramStartIndex, paramEndIndex);
68
+ if (!paramsString.trim()) {
69
+ return [];
70
+ }
71
+
72
+ return paramsString.split(",")
73
+ .map(param => param.replace(/=[\s\S]*/g, '').trim()) // Remove default values
74
+ .filter(param => param.length > 0);
75
+ }
76
+
77
+
78
+ /**
79
+ * @class SpellFrame
80
+ * @classdesc Manages the rendering of configurations using Jsonnet,
81
+ * allowing extension with JavaScript modules and custom file handlers.
82
+ * @exports SpellFrame
83
+ */
84
+ exports.SpellFrame = class SpellFrame {
85
+ /**
86
+ * The path where rendered files will be written.
87
+ * @type {string}
88
+ */
89
+ renderPath;
90
+
91
+ /**
92
+ * An array of functions to extend CLI argument parsing (e.g., with yargs).
93
+ * Each function typically takes the yargs instance as an argument.
94
+ * @type {Array<Function>}
95
+ */
96
+ cliExtensions;
97
+
98
+ /**
99
+ * If true, the renderPath directory will be cleaned of files matching
100
+ * registered fileTypeHandlers before new files are written.
101
+ * @type {boolean}
102
+ */
103
+ cleanBeforeRender;
104
+
105
+ /**
106
+ * An object mapping file extension patterns (regex strings) to handler functions.
107
+ * Handler functions take the evaluated Jsonnet output for a file and return a string.
108
+ * @type {Object<string, Function>}
109
+ */
110
+ fileTypeHandlers;
111
+
112
+ /**
113
+ * An array of initialization functions to be run (awaited) before rendering.
114
+ * These are often contributed by SpellCraft modules.
115
+ * @type {Array<Function>}
116
+ */
117
+ initFn;
118
+
119
+ /**
120
+ * The Jsonnet instance used for evaluation.
121
+ * @type {Jsonnet}
122
+ */
123
+ jsonnet;
124
+
125
+ /**
126
+ * The result of the last successful Jsonnet evaluation.
127
+ * @type {object|null}
128
+ */
129
+ lastRender;
130
+
131
+ /**
132
+ * The directory path of the currently active Jsonnet file being processed.
133
+ * @type {string|null}
134
+ */
135
+ activePath;
136
+
137
+ /**
138
+ * An object that will be used as `this` context for native functions
139
+ * called from Jsonnet.
140
+ * @type {object}
141
+ */
142
+ functionContext;
143
+
144
+ /**
145
+ * If true, default file handlers for .json and .yaml/.yml will be used.
146
+ * @type {boolean}
147
+ */
148
+ useDefaultFileHandlers;
149
+
150
+ /**
151
+ * A cache for the results of synchronous native functions called from Jsonnet.
152
+ * @private
153
+ * @type {object}
154
+ */
155
+ _cache;
156
+
157
+
158
+ /**
159
+ * Creates an instance of SpellFrame.
160
+ * @param {object} [options] - Configuration options.
161
+ * @param {string} [options.renderPath="./render"] - The output directory for rendered files.
162
+ * @param {boolean} [options.cleanBeforeRender=true] - Whether to clean the renderPath before writing.
163
+ * @param {boolean} [options.useDefaultFileHandlers=true] - Whether to include default .json and .yaml handlers.
164
+ */
165
+ constructor(options = {}) {
166
+ const defaults = {
167
+ renderPath: "./render",
168
+ cleanBeforeRender: true,
169
+ useDefaultFileHandlers: true
170
+ };
171
+
172
+ // Assign options, falling back to defaults
173
+ Object.assign(this, defaults, options);
174
+
175
+ this.initFn = [];
176
+ this._cache = {}; // Initialize cache
177
+ this.cliExtensions = [];
178
+ this.fileTypeHandlers = (this.useDefaultFileHandlers) ? { ...defaultFileTypeHandlers } : {};
179
+ this.functionContext = {};
180
+ this.lastRender = null;
181
+ this.activePath = null;
182
+
183
+ this.jsonnet = new Jsonnet()
184
+ .addJpath(path.join(__dirname, '../lib')) // For core SpellCraft libsonnet files
185
+ .addJpath(path.join(__dirname, '../modules')); // For dynamically generated module imports
186
+
187
+ // Add built-in native functions
188
+ this.addNativeFunction("envvar", (name) => process.env[name] || false, "name");
189
+ this.addNativeFunction("path", () => this.activePath || process.cwd()); // Use activePath if available
190
+
191
+ this.loadModulesFromPackageList();
192
+ }
193
+
194
+ /**
195
+ * Generates a cache key for native function calls.
196
+ * @private
197
+ * @param {string} functionName - The name of the function.
198
+ * @param {Array<*>} args - The arguments passed to the function.
199
+ * @returns {string} A SHA256 hash representing the cache key.
200
+ */
201
+ _generateCacheKey(functionName, args) {
202
+ return crypto.createHash('sha256').update(JSON.stringify([functionName, ...args])).digest('hex');
203
+ }
204
+
205
+ /**
206
+ * Adds a file type handler for a given file pattern.
207
+ * The handler function receives the processed data for a file and should return its string content.
208
+ * @param {string} pattern - A regex string to match filenames.
209
+ * @param {Function} handler - The handler function (content: any) => string.
210
+ * @returns {this} The SpellFrame instance for chaining.
211
+ */
212
+ addFileTypeHandler(pattern, handler) {
213
+ // Making it writable: false by default is a strong choice.
214
+ // If flexibility to override is needed later, this could be a simple assignment.
215
+ Object.defineProperty(this.fileTypeHandlers, pattern, {
216
+ value: handler,
217
+ writable: false, // Or true if overrides should be easy
218
+ enumerable: true,
219
+ configurable: true
220
+ });
221
+ return this;
222
+ }
223
+
224
+ /**
225
+ * Adds a native JavaScript function to be callable from Jsonnet.
226
+ * Results of synchronous functions are cached.
227
+ * @param {string} name - The name of the function in Jsonnet.
228
+ * @param {Function} func - The JavaScript function to execute.
229
+ * @param {...string} parameters - The names of the parameters the function expects (for Jsonnet signature).
230
+ * @returns {this} The SpellFrame instance for chaining.
231
+ */
232
+ addNativeFunction(name, func, ...parameters) {
233
+ this.jsonnet.nativeCallback(name, (...args) => {
234
+ const key = this._generateCacheKey(name, args);
235
+ if (this._cache[key] !== undefined) {
236
+ return this._cache[key];
237
+ }
238
+
239
+ // Execute the function with `this.functionContext` as its `this` value.
240
+ const result = func.apply(this.functionContext, args);
241
+ this._cache[key] = result;
242
+ return result;
243
+ }, ...parameters);
244
+ return this;
245
+ }
246
+
247
+ /**
248
+ * Adds an external string variable to the Jsonnet evaluation context.
249
+ * If the value is not a string, it will be JSON.stringified.
250
+ * @param {string} name - The name of the external variable in Jsonnet.
251
+ * @param {*} value - The value to expose.
252
+ * @returns {this} The SpellFrame instance for chaining.
253
+ */
254
+ addExternalValue(name, value) {
255
+ const finalValue = (typeof value === "string") ? value : JSON.stringify(value);
256
+ this.jsonnet = this.jsonnet.extCode(name, finalValue);
257
+ return this;
258
+ }
259
+
260
+ /**
261
+ * Extends SpellFrame's capabilities based on metadata from a module.
262
+ * (Currently a placeholder - implement as needed)
263
+ * @param {object} metadata - The metadata object from a SpellCraft module.
264
+ * @todo Implement the logic for processing module metadata.
265
+ * @returns {this} The SpellFrame instance for chaining.
266
+ */
267
+ extendWithModuleMetadata(metadata) {
268
+ if (metadata.fileTypeHandlers) {
269
+ Object.entries(metadata.fileTypeHandlers).forEach(([pattern, handler]) => {
270
+ this.addFileTypeHandler(pattern, handler);
271
+ });
272
+ }
273
+ if (metadata.cliExtensions) {
274
+ this.cliExtensions.push(...(Array.isArray(metadata.cliExtensions) ? metadata.cliExtensions : [metadata.cliExtensions]));
275
+ }
276
+ if (metadata.initFn) {
277
+ this.initFn.push(...(Array.isArray(metadata.initFn) ? metadata.initFn : [metadata.initFn]));
278
+ }
279
+ Object.assign(this.functionContext, metadata.functionContext || {});
280
+
281
+ return this;
282
+ }
283
+
284
+ /**
285
+ * Adds a path to the Jsonnet import search paths (JPATH).
286
+ * @param {string} jpath - The directory path to add.
287
+ * @returns {this} The SpellFrame instance for chaining.
288
+ */
289
+ addJpath(jpath) {
290
+ this.jsonnet = this.jsonnet.addJpath(jpath);
291
+ return this;
292
+ }
293
+
294
+ /**
295
+ * Runs all registered initialization functions.
296
+ * @async
297
+ * @returns {Promise<void>}
298
+ */
299
+ async init() {
300
+ for (const step of this.initFn) {
301
+ await step.call();
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Imports a SpellCraft module from an npm package.
307
+ * This involves installing the package if not present, reading its package.json
308
+ * for SpellCraft configuration, and linking it for use.
309
+ * @param {string} npmPackage - The name of the npm package (e.g., "my-spellcraft-module" or "@scope/my-module").
310
+ * @param {string} [name=false] - An optional alias name for the module. If false, uses default from package.
311
+ * @async
312
+ * @returns {Promise<void>}
313
+ * @throws {Error} If the package cannot be installed, lacks package.json, or has no import name.
314
+ */
315
+ async importSpellCraftModuleFromNpm(npmPackage, name = false) {
316
+ const npmPath = path.resolve(baseDir, 'node_modules', npmPackage);
317
+
318
+ if (!fs.existsSync(npmPath)) {
319
+ console.log(`[+] Attempting to install ${npmPackage}...`);
320
+ // Note: `spawnSync` is blocking. For a CLI tool, this might be acceptable.
321
+ // Consider an async alternative if this needs to be non-blocking.
322
+ const install = spawnSync(`npm`, ['install', '--save-dev', npmPackage], { // Using --save-dev for local project context
323
+ cwd: baseDir, // Install in the project's node_modules, not renderPath
324
+ stdio: 'inherit' // Show npm output directly
325
+ });
326
+ if (install.error || install.status !== 0) {
327
+ throw new Error(`Failed to install npm package ${npmPackage}. Error: ${install.error || install.stderr.toString()}`);
328
+ }
329
+ console.log(`[+] Successfully installed ${npmPackage}.`);
330
+ }
331
+
332
+ const packageJsonPath = path.join(npmPath, 'package.json');
333
+ if (!fs.existsSync(packageJsonPath)) {
334
+ throw new Error(`[!] Package ${npmPackage} is missing package.json at ${packageJsonPath}`);
335
+ }
336
+
337
+ const packageConfig = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
338
+ const spellcraftConfig = packageConfig.config || packageConfig.spellcraft; // Allow 'spellcraft' key too
339
+
340
+ if (!name && !spellcraftConfig?.spellcraft_module_default_name) {
341
+ console.log("Package config:", packageConfig);
342
+ throw new Error(`[!] No import name specified for ${npmPackage}, and it has no 'spellcraft_module_default_name' in its package.json config.`);
343
+ }
344
+
345
+ const packagesDirPath = path.join(baseDir, 'spellcraft_modules');
346
+ if (!fs.existsSync(packagesDirPath)) {
347
+ fs.mkdirSync(packagesDirPath, { recursive: true });
348
+ }
349
+
350
+ const packagesFilePath = path.join(packagesDirPath, 'packages.json');
351
+ let packages = {};
352
+ if (fs.existsSync(packagesFilePath)) {
353
+ try {
354
+ packages = JSON.parse(fs.readFileSync(packagesFilePath, 'utf-8'));
355
+ } catch (e) {
356
+ console.warn(`[!] Could not parse existing ${packagesFilePath}. Starting fresh. Error: ${e.message}`);
357
+ packages = {};
358
+ }
359
+ }
360
+
361
+ // Derive the base name to store (e.g., "my-package" from "my-package@1.0.0")
362
+ const npmPackageBaseName = npmPackage.startsWith("@") ?
363
+ `@${npmPackage.split('/')[1].split('@')[0]}` : // Handles @scope/name and @scope/name@version
364
+ npmPackage.split('@')[0]; // Handles name and name@version
365
+
366
+ const packagesKey = name || spellcraftConfig.spellcraft_module_default_name;
367
+ packages[packagesKey] = npmPackageBaseName; // Store the clean package name
368
+
369
+ fs.writeFileSync(packagesFilePath, JSON.stringify(packages, null, "\t"));
370
+ console.log(`[+] Linked ${npmPackage} as SpellCraft module '${packagesKey}'`);
371
+
372
+ // After linking, ensure it's loaded for the current session
373
+ this.loadModuleByName(packagesKey, npmPackageBaseName);
374
+ }
375
+
376
+ /**
377
+ * Evaluates a Jsonnet file and stores the result.
378
+ * This also sets up the dynamic `modules/modules.libsonnet` import aggregator.
379
+ * @param {string} file - The path to the main Jsonnet file to evaluate.
380
+ * @async
381
+ * @returns {Promise<object>} The parsed JSON object from the Jsonnet evaluation.
382
+ * @throws {Error} If the file doesn't exist or if Jsonnet parsing fails.
383
+ */
384
+ async render(file) {
385
+ const absoluteFilePath = path.resolve(file);
386
+ if (!fs.existsSync(absoluteFilePath)) {
387
+ throw new Error(`SpellCraft Render Error: Input file ${absoluteFilePath} does not exist.`);
388
+ }
389
+
390
+ this.activePath = path.dirname(absoluteFilePath); // Set active path for relative 'path()' calls
391
+
392
+ // Path to the dynamically generated libsonnet file that imports all modules
393
+ const dynamicModulesImportFile = path.resolve(__dirname, '../modules/modules.libsonnet');
394
+
395
+ // It's crucial this file is managed carefully.
396
+ // If it exists from a previous failed run or other reasons, it might cause issues.
397
+ // Option 1: Delete if exists (simple, ensures clean state for this run)
398
+ if (fs.existsSync(dynamicModulesImportFile)) {
399
+ // console.warn(`[!] Warning: Stale '${path.basename(dynamicModulesImportFile)}' found. It will be overwritten.`);
400
+ fs.unlinkSync(dynamicModulesImportFile); // Or ensure it's correctly generated by loadModulesFromModuleDirectory
401
+ }
402
+ // Option 2: Ensure loadModulesFromModuleDirectory overwrites or creates it.
403
+ // loadModulesFromModuleDirectory now handles creation/overwriting.
404
+
405
+ this.loadModulesFromModuleDirectory(dynamicModulesImportFile);
406
+
407
+ // Ensure renderPath does not have a trailing slash for consistency
408
+ if (this.renderPath.endsWith(path.sep)) {
409
+ this.renderPath = this.renderPath.slice(0, -1);
410
+ }
411
+
412
+ try {
413
+ console.log(`[+] Evaluating Jsonnet file: ${absoluteFilePath}`);
414
+ this.lastRender = JSON.parse(await this.jsonnet.evaluateFile(absoluteFilePath));
415
+ } catch (e) {
416
+ throw new Error(`Jsonnet Evaluation Error for ${absoluteFilePath}: ${e.message || e}`);
417
+ }
418
+
419
+ // Clean up the dynamically generated modules.libsonnet after rendering,
420
+ // as it's specific to this render pass and its discovered modules.
421
+ // This was previously cleaning the whole modules directory, which might be too broad
422
+ // if other static .libsonnet files are meant to be there.
423
+ if (fs.existsSync(dynamicModulesImportFile)) {
424
+ try {
425
+ fs.unlinkSync(dynamicModulesImportFile);
426
+ } catch (e) {
427
+ console.warn(`[!] Could not clean up temporary module file ${dynamicModulesImportFile}: ${e.message}`);
428
+ }
429
+ }
430
+
431
+ return this.lastRender;
432
+ }
433
+
434
+ /**
435
+ * Loads SpellCraft modules by scanning the `spellcraft_modules` directory for `.js` files.
436
+ * Generates a `modules.libsonnet` file in `../modules/` to make them importable in Jsonnet.
437
+ * @param {string} aggregateModuleFile - The path where the aggregating libsonnet file will be written.
438
+ * @returns {Array<string>} A list of registered function names from the loaded modules.
439
+ */
440
+ loadModulesFromModuleDirectory(aggregateModuleFile) {
441
+ const spellcraftModulesPath = path.join(baseDir, 'spellcraft_modules');
442
+ if (!fs.existsSync(spellcraftModulesPath)) {
443
+ // Ensure the aggregate file is empty if no modules found
444
+ fs.writeFileSync(aggregateModuleFile, '{}', 'utf-8');
445
+ return [];
446
+ }
447
+
448
+ const jsModuleFiles = fs.readdirSync(spellcraftModulesPath)
449
+ .filter(f => f.endsWith('.js')) // Simpler check for .js files
450
+ .map(f => path.join(spellcraftModulesPath, f));
451
+
452
+ return this.loadModulesFromFileList(jsModuleFiles, aggregateModuleFile);
453
+ }
454
+
455
+
456
+ /**
457
+ * Loads SpellCraft module configurations and functions from `spellcraft_modules/packages.json`.
458
+ * This involves reading linked npm packages and setting them up.
459
+ * @returns {Array<string>} A list of module keys that were loaded.
460
+ */
461
+ loadModulesFromPackageList() {
462
+ const packagesConfigPath = path.join(baseDir, 'spellcraft_modules', 'packages.json');
463
+
464
+ if (!fs.existsSync(packagesConfigPath)) {
465
+ // console.log('[+] No spellcraft_modules/packages.json file found. Skipping package-based module import.');
466
+ return [];
467
+ }
468
+
469
+ let packages;
470
+ try {
471
+ packages = JSON.parse(fs.readFileSync(packagesConfigPath, 'utf-8'));
472
+ } catch (e) {
473
+ console.error(`[!] Error parsing ${packagesConfigPath}: ${e.message}. Skipping package-based module import.`);
474
+ return [];
475
+ }
476
+
477
+ return Object.entries(packages).map(([moduleKey, npmPackageName]) => {
478
+ return this.loadModuleByName(moduleKey, npmPackageName);
479
+ }).filter(Boolean); // Filter out any undefined results if a module fails to load
480
+ }
481
+
482
+ /**
483
+ * Loads a specific module by its registered key and npm package name.
484
+ * @private
485
+ * @param {string} moduleKey - The key (alias) under which the module is registered.
486
+ * @param {string} npmPackageName - The actual npm package name.
487
+ * @returns {string|null} The moduleKey if successful, null otherwise.
488
+ */
489
+ loadModuleByName(moduleKey, npmPackageName) {
490
+ const packageJsonPath = path.join(baseDir, 'node_modules', npmPackageName, 'package.json');
491
+ if (!fs.existsSync(packageJsonPath)) {
492
+ console.warn(`[!] package.json not found for module '${moduleKey}' (package: ${npmPackageName}) at ${packageJsonPath}. Skipping.`);
493
+ return null;
494
+ }
495
+
496
+ let packageConfig;
497
+ try {
498
+ packageConfig = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
499
+ } catch (e) {
500
+ console.warn(`[!] Error parsing package.json for module '${moduleKey}' (package: ${npmPackageName}): ${e.message}. Skipping.`);
501
+ return null;
502
+ }
503
+
504
+ if (!packageConfig.main) {
505
+ console.warn(`[!] 'main' field missing in package.json for module '${moduleKey}' (package: ${npmPackageName}). Skipping JS function loading.`);
506
+ } else {
507
+ const jsMainFilePath = path.resolve(baseDir, 'node_modules', npmPackageName, packageConfig.main);
508
+ if (fs.existsSync(jsMainFilePath)) {
509
+ this.loadFunctionsFromFile(jsMainFilePath);
510
+ } else {
511
+ console.warn(`[!] Main JS file '${jsMainFilePath}' not found for module '${moduleKey}'. Skipping JS function loading.`);
512
+ }
513
+ }
514
+
515
+
516
+ // Define where the module's .libsonnet file (if any) should be copied to be found by Jsonnet
517
+ // It will be copied to `../modules/<moduleKey>.libsonnet` (relative to this file)
518
+ const targetLibsonnetPath = path.resolve(__dirname, '..', 'modules', `${moduleKey}.libsonnet`);
519
+
520
+ // Check for `module.libsonnet` or a path specified in package.json (e.g., spellcraft.libsonnet_module)
521
+ let sourceLibsonnetPath;
522
+ const spellcraftConfig = packageConfig.config || packageConfig.spellcraft;
523
+ if (spellcraftConfig?.libsonnet_module) {
524
+ sourceLibsonnetPath = path.resolve(baseDir, 'node_modules', npmPackageName, spellcraftConfig.libsonnet_module);
525
+ } else {
526
+ // Default to 'module.libsonnet' in the package's root or main file's directory
527
+ const defaultLibsonnetName = 'module.libsonnet';
528
+ const packageRootDir = path.resolve(baseDir, 'node_modules', npmPackageName);
529
+ sourceLibsonnetPath = path.join(packageRootDir, defaultLibsonnetName);
530
+ if (!fs.existsSync(sourceLibsonnetPath) && packageConfig.main) {
531
+ sourceLibsonnetPath = path.join(path.dirname(path.resolve(packageRootDir, packageConfig.main)), defaultLibsonnetName);
532
+ }
533
+ }
534
+
535
+ if (fs.existsSync(sourceLibsonnetPath)) {
536
+ try {
537
+ // Ensure the target directory exists
538
+ fs.mkdirSync(path.dirname(targetLibsonnetPath), { recursive: true });
539
+ fs.copyFileSync(sourceLibsonnetPath, targetLibsonnetPath);
540
+ console.log(`[+] Copied libsonnet module for '${moduleKey}' from ${sourceLibsonnetPath} to ${targetLibsonnetPath}`);
541
+ } catch (e) {
542
+ console.warn(`[!] Failed to copy libsonnet module for '${moduleKey}': ${e.message}`);
543
+ }
544
+ } else {
545
+ // console.log(`[+] No .libsonnet module found for '${moduleKey}' at expected paths.`);
546
+ }
547
+ return moduleKey;
548
+ }
549
+
550
+
551
+ /**
552
+ * Loads native functions from a list of JavaScript module files and generates
553
+ * an aggregate Jsonnet import file (`modules.libsonnet` by default convention).
554
+ * @param {string[]} jsModuleFiles - An array of absolute paths to JS module files.
555
+ * @param {string} aggregateModuleFile - The path to the Jsonnet file that will aggregate imports.
556
+ * @returns {{registeredFunctions: string[], magicContent: string[]}}
557
+ * An object containing lists of registered function names and the Jsonnet "magic" import strings.
558
+ */
559
+ loadModulesFromFileList(jsModuleFiles, aggregateModuleFile) {
560
+ let allRegisteredFunctions = [];
561
+ let allMagicContent = [];
562
+
563
+ if (jsModuleFiles.length < 1) {
564
+ fs.writeFileSync(aggregateModuleFile, '{}', 'utf-8'); // Write empty object if no modules
565
+ return { registeredFunctions: [], magicContent: [] };
566
+ }
567
+
568
+ jsModuleFiles.forEach(file => {
569
+ try {
570
+ const { functions, magic } = this.loadFunctionsFromFile(file);
571
+ allRegisteredFunctions.push(...functions);
572
+ allMagicContent.push(...magic);
573
+ } catch (e) {
574
+ console.warn(`[!] Failed to load functions from module ${file}: ${e.message}`);
575
+ }
576
+ });
577
+
578
+ const aggregateFileDir = path.dirname(aggregateModuleFile);
579
+ if (!fs.existsSync(aggregateFileDir)) {
580
+ fs.mkdirSync(aggregateFileDir, { recursive: true });
581
+ }
582
+ // This content creates a Jsonnet object where keys are module names
583
+ // and values are their corresponding .libsonnet imports.
584
+ // It's assumed that loadModuleByName has already copied relevant .libsonnet files
585
+ // into the ../modules directory, named like <moduleKey>.libsonnet
586
+ const packagesConfigPath = path.join(baseDir, 'spellcraft_modules', 'packages.json');
587
+ let moduleImportsString = "";
588
+ if (fs.existsSync(packagesConfigPath)) {
589
+ try {
590
+ const packages = JSON.parse(fs.readFileSync(packagesConfigPath, 'utf-8'));
591
+ moduleImportsString = Object.keys(packages)
592
+ .map(key => ` ${key}: import "${key}.libsonnet"`) // Assumes <key>.libsonnet exists in jpath
593
+ .join(",\n");
594
+ } catch (e) {
595
+ console.warn(`[!] Error processing packages.json for libsonnet aggregation: ${e.message}`);
596
+ }
597
+ }
598
+
599
+ // The aggregateModuleFile ('modules/modules.libsonnet') will now look like:
600
+ // {
601
+ // moduleKey1: import "moduleKey1.libsonnet",
602
+ // moduleKey2: import "moduleKey2.libsonnet",
603
+ // // ... any other native functions directly defined
604
+ // }
605
+ // Native functions defined via loadFunctionsFromFile are added directly to jsonnet instance,
606
+ // and their `std.native` calls are generated by loadFunctionsFromFile's `magicContent`.
607
+ // The `modules.libsonnet` is primarily for importing `.libsonnet` files from packages.
608
+ // The `magicContent` from JS files might need a different aggregation strategy
609
+ // if they are meant to be part of this central `modules.libsonnet`.
610
+ // For now, `allMagicContent` seems to be intended for a different purpose or needs clarification.
611
+ // If `magicContent` is for defining native functions in a libsonnet structure, it needs to be integrated.
612
+ // The current `magicContent.join(",\n")` assumes it's creating entries for a Jsonnet object.
613
+ // Let's assume modules.libsonnet is for Jsonnet library imports, and native functions are separate.
614
+ //
615
+ // Revision: If `allMagicContent` is for `std.native` declarations,
616
+ // those are typically not put inside `modules.libsonnet` but rather are available globally
617
+ // once `addNativeFunction` is called. The `modules.libsonnet` file is for Jsonnet code.
618
+ // The `magicContent` was generating strings like `\t${e}(${parameters.join(', ')}):: std.native('${e}')(${parameters.join(', ')})`
619
+ // This is Jsonnet code. If this is meant to be *the* content of `modules.libsonnet`,
620
+ // then the `moduleImportsString` part is separate.
621
+ //
622
+ // Let's clarify:
623
+ // 1. `../modules/<moduleKey>.libsonnet`: Copied from each package.
624
+ // 2. `../modules/modules.libsonnet`: This file should make #1 easily accessible.
625
+ // Example: `{ myModule: import "myModule.libsonnet" }`
626
+ // 3. Native JS functions: Registered directly with `this.jsonnet.nativeCallback`.
627
+ // They are globally available in Jsonnet via `std.native('funcName')`.
628
+ // The `magicContent` seems to be an attempt to create a .libsonnet wrapper for native functions.
629
+ // This is good practice! So `modules.libsonnet` could combine these.
630
+
631
+ let finalModulesContent = "{\n";
632
+ if (moduleImportsString) {
633
+ finalModulesContent += moduleImportsString;
634
+ }
635
+ if (allMagicContent.length > 0) {
636
+ if (moduleImportsString) finalModulesContent += ",\n"; // Add comma if previous content exists
637
+ finalModulesContent += allMagicContent.join(",\n");
638
+ }
639
+ finalModulesContent += "\n}";
640
+
641
+ fs.writeFileSync(aggregateModuleFile, finalModulesContent, 'utf-8');
642
+
643
+ if (jsModuleFiles.length > 0) {
644
+ console.log(`[+] Processed ${jsModuleFiles.length} JS module file(s) for native functions.`);
645
+ }
646
+ if (allRegisteredFunctions.length > 0) {
647
+ console.log(`[+] Registered ${allRegisteredFunctions.length} native function(s): [ ${allRegisteredFunctions.sort().join(', ')} ]`);
648
+ }
649
+ if (moduleImportsString || allMagicContent.length > 0) {
650
+ console.log(`[+] Aggregated modules written to '${path.basename(aggregateModuleFile)}'`);
651
+ }
652
+
653
+
654
+ return { registeredFunctions: allRegisteredFunctions, magicContent: allMagicContent };
655
+ }
656
+
657
+
658
+ /**
659
+ * Loads functions and metadata from a single JavaScript module file.
660
+ * Registers functions as native callbacks in Jsonnet and processes SpellCraft metadata.
661
+ * @param {string} file - Absolute path to the JavaScript module file.
662
+ * @returns {{functions: string[], magic: string[]}} Registered function names and "magic" Jsonnet strings for them.
663
+ * @throws {Error} If the file cannot be required.
664
+ */
665
+ loadFunctionsFromFile(file) {
666
+ let moduleExports;
667
+ try {
668
+ // Bust require cache for potentially updated modules during a session (e.g. dev mode)
669
+ delete require.cache[require.resolve(file)];
670
+ moduleExports = require(file);
671
+ } catch (e) {
672
+ throw new Error(`SpellCraft Error: Could not require module ${file}. ${e.message}`);
673
+ }
674
+
675
+ const magicContentSnippets = [];
676
+ if (moduleExports._spellcraft_metadata) {
677
+ this.extendWithModuleMetadata(moduleExports._spellcraft_metadata);
678
+ }
679
+
680
+ const registeredFunctionNames = Object.keys(moduleExports)
681
+ .filter(key => key !== '_spellcraft_metadata' && typeof moduleExports[key] !== 'undefined')
682
+ .map(funcName => {
683
+ let func, params;
684
+
685
+ if (typeof moduleExports[funcName] === "object" && Array.isArray(moduleExports[funcName])) {
686
+ // Expects [function, paramName1, paramName2, ...]
687
+ [func, ...params] = moduleExports[funcName];
688
+ } else if (typeof moduleExports[funcName] === "function") {
689
+ func = moduleExports[funcName];
690
+ params = getFunctionParameterList(func); // Auto-detect params
691
+ } else {
692
+ // console.warn(`[!] Skipping non-function export '${funcName}' in module ${file}.`);
693
+ return null; // Skip if not a function or recognized array structure
694
+ }
695
+
696
+ if (typeof func !== 'function') {
697
+ // console.warn(`[!] Export '${funcName}' in module ${file} is not a valid function for native binding.`);
698
+ return null;
699
+ }
700
+
701
+ // For `modules.libsonnet` to provide convenient wrappers:
702
+ // e.g. myNativeFunc(a,b):: std.native('myNativeFunc')(a,b)
703
+ const paramString = params.join(', ');
704
+ magicContentSnippets.push(` ${funcName}(${paramString}):: std.native('${funcName}')(${paramString})`);
705
+
706
+ this.addNativeFunction(funcName, func, ...params);
707
+ return funcName;
708
+ }).filter(Boolean); // Remove nulls from skipped items
709
+
710
+ return { functions: registeredFunctionNames, magic: magicContentSnippets };
711
+ }
712
+
713
+ /**
714
+ * Returns the string representation of the last render, or null.
715
+ * By default, this might return the object itself if `lastRender` is an object.
716
+ * @returns {object|string|null} The last rendered output, or null if no render has occurred.
717
+ */
718
+ toString() {
719
+ return this.lastRender ?? null; // If lastRender is an object, it returns the object.
720
+ // If a string is always desired, use JSON.stringify or similar.
721
+ }
722
+
723
+ /**
724
+ * Writes the rendered files to the configured `renderPath`.
725
+ * Applies appropriate file handlers based on filename patterns.
726
+ * @param {object} [filesToWrite=this.lastRender] - An object where keys are filenames
727
+ * and values are the content (typically from Jsonnet evaluation).
728
+ * @returns {this} The SpellFrame instance for chaining.
729
+ * @throws {Error} If `renderPath` cannot be created or files cannot be written.
730
+ */
731
+ write(filesToWrite = this.lastRender) {
732
+ if (!filesToWrite || typeof filesToWrite !== 'object' || Object.keys(filesToWrite).length === 0) {
733
+ console.log("[+] No files to write from the last render or provided input.");
734
+ return this;
735
+ }
736
+
737
+ try {
738
+ if (!fs.existsSync(this.renderPath)) {
739
+ fs.mkdirSync(this.renderPath, { recursive: true });
740
+ }
741
+ } catch (e) {
742
+ throw new Error(`SpellCraft Write Error: renderPath '${this.renderPath}' could not be created. ${e.message}`);
743
+ }
744
+
745
+ if (this.cleanBeforeRender) {
746
+ console.log(`[+] Cleaning render path: ${this.renderPath}`);
747
+ try {
748
+ Object.keys(this.fileTypeHandlers).forEach(regexPattern => {
749
+ const regex = new RegExp(regexPattern, "i"); // Case-insensitive match
750
+ fs.readdirSync(this.renderPath)
751
+ .filter(f => regex.test(f))
752
+ .forEach(f => {
753
+ const filePathToClean = path.join(this.renderPath, f);
754
+ try {
755
+ fs.unlinkSync(filePathToClean);
756
+ // console.log(` - Removed ${filePathToClean}`);
757
+ } catch (cleanError) {
758
+ console.warn(` [!] Failed to remove ${filePathToClean}: ${cleanError.message}`);
759
+ }
760
+ });
761
+ });
762
+ } catch (e) {
763
+ // This error is for readdirSync itself, less likely but possible
764
+ throw new Error(`SpellCraft Clean Error: Failed to read/clean files from renderPath '${this.renderPath}'. ${e.message}`);
765
+ }
766
+ }
767
+
768
+ console.log(`[+] Writing files to: ${this.renderPath}`);
769
+ try {
770
+ for (const filename in filesToWrite) {
771
+ if (Object.prototype.hasOwnProperty.call(filesToWrite, filename)) {
772
+ const outputFilePath = path.join(this.renderPath, filename);
773
+ const fileContent = filesToWrite[filename];
774
+
775
+ // Find the appropriate handler or use default
776
+ const [, handlerFn] = Object.entries(this.fileTypeHandlers)
777
+ .find(([pattern]) => new RegExp(pattern).test(filename)) || [null, defaultFileHandler];
778
+
779
+ try {
780
+ const processedContent = handlerFn(fileContent);
781
+ fs.writeFileSync(outputFilePath, processedContent, 'utf-8');
782
+ console.log(' -> ' + path.basename(outputFilePath));
783
+ } catch (handlerError) {
784
+ console.error(` [!] Error processing or writing file ${filename}: ${handlerError.message}`);
785
+ // Optionally re-throw or collect errors
786
+ }
787
+ }
788
+ }
789
+ } catch (e) {
790
+ // This would catch errors in the loop structure itself, less likely for file operations
791
+ throw new Error(`SpellCraft Write Error: Failed during file writing loop. ${e.message}`);
792
+ }
793
+
794
+ return this;
795
+ }
796
+ };