@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/LICENSE +21 -0
- package/README.md +17 -0
- package/bin/spellcraft.js +146 -0
- package/bin/spellcraft.js.bak +47 -0
- package/jsdoc.json +33 -0
- package/lib/spellcraft +6 -0
- package/package.json +39 -0
- package/src/index.js +796 -0
- package/src/index.js.bak +403 -0
- package/src/test.js +16 -0
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
|
+
};
|