@kubb/studio 5.6.2 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +459 -401
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +461 -403
- package/dist/index.js.map +1 -1
- package/dist/{protocol-P0znFp9w.d.ts → protocol-i7JV0P4z.d.ts} +7 -5
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -216,7 +216,7 @@ function getElapsedMs(hrStart) {
|
|
|
216
216
|
}
|
|
217
217
|
//#endregion
|
|
218
218
|
//#region package.json
|
|
219
|
-
var version = "5.
|
|
219
|
+
var version = "5.7.0";
|
|
220
220
|
//#endregion
|
|
221
221
|
//#region src/resolveConfig.ts
|
|
222
222
|
/**
|
|
@@ -261,6 +261,46 @@ function toPluginName(packageName) {
|
|
|
261
261
|
function toPackageName(name) {
|
|
262
262
|
return name.startsWith("plugin-") ? `@kubb/${name}` : name;
|
|
263
263
|
}
|
|
264
|
+
function resolvesFrom(base, packageName) {
|
|
265
|
+
try {
|
|
266
|
+
(0, node_module.createRequire)(base).resolve(`${packageName}/package.json`);
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether `packageName` resolves from the project at `root`, from this package, or from the `kubb`
|
|
274
|
+
* package the project installs, which brings the plugins Kubb adds on its own such as
|
|
275
|
+
* `@kubb/plugin-barrel`. Only resolves the package, it never runs it.
|
|
276
|
+
*/
|
|
277
|
+
function isInstalled({ packageName, root }) {
|
|
278
|
+
const project = (0, node_url.pathToFileURL)(`${root}/`);
|
|
279
|
+
if (resolvesFrom(project, packageName) || resolvesFrom(require("url").pathToFileURL(__filename).href, packageName)) return true;
|
|
280
|
+
try {
|
|
281
|
+
return resolvesFrom((0, node_module.createRequire)(project).resolve("kubb/package.json"), packageName);
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The name Studio knows a plugin by: its `@kubb/plugin-*` package when one is installed, otherwise
|
|
288
|
+
* the plugin's own `name`, so a plugin defined in the user's project is never reported as a Kubb
|
|
289
|
+
* package that does not exist.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* toStudioName({ name: 'plugin-ts', root }) // '@kubb/plugin-ts'
|
|
294
|
+
* toStudioName({ name: 'plugin-zod-operations', root }) // 'plugin-zod-operations', a local plugin
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
function toStudioName({ name, root }) {
|
|
298
|
+
const packageName = toPackageName(name);
|
|
299
|
+
return packageName !== name && isInstalled({
|
|
300
|
+
packageName,
|
|
301
|
+
root
|
|
302
|
+
}) ? packageName : name;
|
|
303
|
+
}
|
|
264
304
|
/**
|
|
265
305
|
* Derives the conventional named export for a `@kubb/*` plugin package from its package name.
|
|
266
306
|
*
|
|
@@ -356,6 +396,38 @@ async function resolvePeerDependencies(names) {
|
|
|
356
396
|
};
|
|
357
397
|
}
|
|
358
398
|
/**
|
|
399
|
+
* Keys that reach an object's prototype when assigned. Never read from or written into options.
|
|
400
|
+
*/
|
|
401
|
+
const UNSAFE_KEYS = /* @__PURE__ */ new Set([
|
|
402
|
+
"__proto__",
|
|
403
|
+
"constructor",
|
|
404
|
+
"prototype"
|
|
405
|
+
]);
|
|
406
|
+
/**
|
|
407
|
+
* Whether `key` can be used as an option key without touching an object's prototype.
|
|
408
|
+
*/
|
|
409
|
+
function isSafeKey(key) {
|
|
410
|
+
return !UNSAFE_KEYS.has(key);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* A JSON copy of plugin or adapter options: functions and other values JSON cannot hold are left
|
|
414
|
+
* out, so nothing callable crosses the socket. Options that cannot be copied at all, such as a
|
|
415
|
+
* circular structure, come back empty.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* toJSONOptions({ output: { path: 'types' }, group: { name: () => 'x' } }) // { output: { path: 'types' }, group: {} }
|
|
420
|
+
* ```
|
|
421
|
+
*/
|
|
422
|
+
function toJSONOptions(options) {
|
|
423
|
+
try {
|
|
424
|
+
const copy = JSON.parse(JSON.stringify(options ?? {}, (key, value) => isSafeKey(key) && typeof value !== "bigint" ? value : void 0));
|
|
425
|
+
return isPlainObject(copy) ? copy : {};
|
|
426
|
+
} catch {
|
|
427
|
+
return {};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
359
431
|
* Whether `value` is an object literal or a null-prototype object, not an array or class instance.
|
|
360
432
|
*/
|
|
361
433
|
function isPlainObject(value) {
|
|
@@ -372,7 +444,7 @@ function isOptionValue(value) {
|
|
|
372
444
|
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
373
445
|
if (typeof value === "number") return Number.isFinite(value);
|
|
374
446
|
if (Array.isArray(value)) return value.every(isOptionValue);
|
|
375
|
-
if (isPlainObject(value)) return Object.
|
|
447
|
+
if (isPlainObject(value)) return Object.entries(value).every(([key, entry]) => isSafeKey(key) && isOptionValue(entry));
|
|
376
448
|
return false;
|
|
377
449
|
}
|
|
378
450
|
/**
|
|
@@ -390,6 +462,7 @@ function isOptionValue(value) {
|
|
|
390
462
|
function mergeOptions(disk, studio) {
|
|
391
463
|
const merged = { ...disk };
|
|
392
464
|
for (const [key, value] of Object.entries(studio)) {
|
|
465
|
+
if (!isSafeKey(key)) continue;
|
|
393
466
|
const current = disk[key];
|
|
394
467
|
if (isPlainObject(current) && isPlainObject(value)) {
|
|
395
468
|
merged[key] = mergeOptions(current, value);
|
|
@@ -412,7 +485,7 @@ function mergeOptions(disk, studio) {
|
|
|
412
485
|
*/
|
|
413
486
|
async function mergePlugins(diskPlugins, studioPlugins) {
|
|
414
487
|
const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => toPluginName(entry.name)));
|
|
415
|
-
const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has(plugin.name)) : diskPlugins;
|
|
488
|
+
const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has((0, _kubb_core.resolveMeta)(plugin).name)) : diskPlugins;
|
|
416
489
|
const activeStudioPlugins = studioPlugins?.filter((entry) => !entry.disabled);
|
|
417
490
|
if (!activeDiskPlugins && !activeStudioPlugins?.length) return void 0;
|
|
418
491
|
if (!activeStudioPlugins?.length) return activeDiskPlugins;
|
|
@@ -420,13 +493,18 @@ async function mergePlugins(diskPlugins, studioPlugins) {
|
|
|
420
493
|
const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [toPluginName(entry.name), entry]));
|
|
421
494
|
const diskNames = new Set(activeDiskPlugins.map((plugin) => plugin.name));
|
|
422
495
|
const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
|
|
423
|
-
const studioEntry = studioEntryByName.get(diskPlugin.name);
|
|
496
|
+
const studioEntry = studioEntryByName.get((0, _kubb_core.resolveMeta)(diskPlugin).name);
|
|
424
497
|
if (!studioEntry) return diskPlugin;
|
|
425
|
-
const
|
|
498
|
+
const diskOptions = diskPlugin.options ?? {};
|
|
499
|
+
const options = mergeOptions(diskOptions, studioEntry.options ?? {});
|
|
500
|
+
const unchanged = JSON.stringify(toJSONOptions(options)) === JSON.stringify(toJSONOptions(diskOptions));
|
|
426
501
|
const [resolved] = await resolvePlugins([{
|
|
427
502
|
name: studioEntry.name,
|
|
428
503
|
options
|
|
429
|
-
}])
|
|
504
|
+
}]).catch((cause) => {
|
|
505
|
+
if (unchanged) return [diskPlugin];
|
|
506
|
+
throw new Error(`Plugin "${diskPlugin.name}" is not an installed @kubb/plugin-* package, so Studio cannot change its options. Edit it in the config file, or only enable or disable it from Studio.`, { cause });
|
|
507
|
+
});
|
|
430
508
|
return resolved ?? diskPlugin;
|
|
431
509
|
}));
|
|
432
510
|
const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(toPluginName(entry.name)));
|
|
@@ -456,142 +534,130 @@ async function mergeAdapter(diskAdapter, studioOptions) {
|
|
|
456
534
|
*/
|
|
457
535
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
458
536
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
*/
|
|
464
|
-
|
|
465
|
-
|
|
537
|
+
* The marker line a `disable-plugin` writes above the block it comments out. It carries the block's
|
|
538
|
+
* line count, so `enable-plugin` restores exactly those lines and not whatever comments follow.
|
|
539
|
+
*/
|
|
540
|
+
const DISABLED_MARKER = /^\/\/ kubb:disabled (.+)\s+(\d+)$/;
|
|
541
|
+
const OPERATIONS = /* @__PURE__ */ new Set([
|
|
542
|
+
"set",
|
|
543
|
+
"remove",
|
|
544
|
+
"add-plugin",
|
|
545
|
+
"disable-plugin",
|
|
546
|
+
"enable-plugin"
|
|
547
|
+
]);
|
|
548
|
+
/**
|
|
549
|
+
* Why an edit from Studio cannot be applied as sent, checked before anything reads its fields.
|
|
550
|
+
* The edit crossed the wire, so its declared type is a promise the sender may not have kept.
|
|
551
|
+
*/
|
|
552
|
+
function invalidEdit(edit) {
|
|
553
|
+
const { operation, plugin, config, path, importName } = typeof edit === "object" && edit !== null ? edit : {};
|
|
554
|
+
if (typeof operation !== "string" || !OPERATIONS.has(operation)) return `unknown operation ${JSON.stringify(operation)}`;
|
|
555
|
+
if (typeof plugin !== "string" || !plugin) return "the edit names no plugin";
|
|
556
|
+
if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
|
|
557
|
+
if (operation === "set" || operation === "remove") {
|
|
558
|
+
if (!Array.isArray(path) || !path.every((key) => typeof key === "string" && key)) return "path must be a list of option names";
|
|
559
|
+
if (!path.length) return "no option path given";
|
|
560
|
+
const unsafe = path.find((key) => !isSafeKey(key));
|
|
561
|
+
if (unsafe !== void 0) return `${unsafe} cannot be used as an option name`;
|
|
562
|
+
}
|
|
563
|
+
if (operation === "add-plugin" && importName !== void 0 && typeof importName !== "string") return "importName must be a string";
|
|
564
|
+
}
|
|
565
|
+
/** Where a node sits in the source text. */
|
|
566
|
+
function rangeOf(node) {
|
|
567
|
+
if (typeof node.start !== "number" || typeof node.end !== "number") throw new Error("the config file could not be mapped back to its source text");
|
|
466
568
|
return {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
value: built.value,
|
|
470
|
-
computed: false,
|
|
471
|
-
shorthand: false
|
|
569
|
+
start: node.start,
|
|
570
|
+
end: node.end
|
|
472
571
|
};
|
|
473
572
|
}
|
|
474
|
-
/**
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* The plugin and comment-block length a marker line names, when `line` is one.
|
|
482
|
-
*/
|
|
483
|
-
function parseMarker(line) {
|
|
484
|
-
const trimmed = line.trim();
|
|
485
|
-
if (!trimmed.startsWith(`// ${DISABLED_MARKER} `)) return;
|
|
486
|
-
const match = trimmed.slice(`// ${DISABLED_MARKER} `.length).match(/^(.+)\s+(\d+)$/);
|
|
487
|
-
return match ? {
|
|
488
|
-
plugin: match[1],
|
|
489
|
-
lineCount: Number(match[2])
|
|
490
|
-
} : void 0;
|
|
573
|
+
/** `source` with the text between `start` and `end` replaced by `text`. */
|
|
574
|
+
function splice(source, start, end, text = "") {
|
|
575
|
+
return `${source.slice(0, start)}${text}${source.slice(end)}`;
|
|
576
|
+
}
|
|
577
|
+
function bodyOf(mod) {
|
|
578
|
+
return mod.$ast.type === "Program" ? mod.$ast.body : [];
|
|
491
579
|
}
|
|
492
580
|
/**
|
|
493
581
|
* Steps through a config's wrappers to the object literal underneath: a `satisfies`/`as`
|
|
494
582
|
* assertion, a `() => ...` factory, or a factory whose block body returns the config.
|
|
495
583
|
*/
|
|
496
584
|
function unwrap(node) {
|
|
497
|
-
if (!node) return;
|
|
585
|
+
if (!node) return void 0;
|
|
498
586
|
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return unwrap(node.expression);
|
|
499
587
|
if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return node;
|
|
500
588
|
if (node.body.type !== "BlockStatement") return unwrap(node.body);
|
|
501
589
|
return unwrap(node.body.body.find((statement) => statement.type === "ReturnStatement")?.argument);
|
|
502
590
|
}
|
|
591
|
+
function isModuleExports(node) {
|
|
592
|
+
return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "module" && node.property.type === "Identifier" && node.property.name === "exports";
|
|
593
|
+
}
|
|
503
594
|
/**
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
595
|
+
* Parses `source` and finds every config object in `export default defineConfig(...)` or
|
|
596
|
+
* `module.exports = defineConfig(...)`, or why the file is unmanaged. An array export gets one entry
|
|
597
|
+
* per element, matching a numeric `config` index.
|
|
507
598
|
*
|
|
508
599
|
* Walks the parsed AST rather than magicast's proxies, which throw on node types they cannot
|
|
509
600
|
* cast, most of what an unmanaged config file is made of.
|
|
510
601
|
*/
|
|
511
|
-
function
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
602
|
+
function parseConfigs(source) {
|
|
603
|
+
let mod;
|
|
604
|
+
try {
|
|
605
|
+
mod = (0, magicast.parseModule)(source);
|
|
606
|
+
} catch {
|
|
607
|
+
return { reason: "the config file could not be parsed" };
|
|
608
|
+
}
|
|
609
|
+
let exported;
|
|
610
|
+
let kind = "esm";
|
|
611
|
+
for (const node of bodyOf(mod)) {
|
|
612
|
+
if (node.type === "ExportDefaultDeclaration") {
|
|
613
|
+
exported = node.declaration;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (node.type === "ExpressionStatement" && node.expression.type === "AssignmentExpression" && isModuleExports(node.expression.left)) {
|
|
617
|
+
exported = node.expression.right;
|
|
618
|
+
kind = "cjs";
|
|
619
|
+
break;
|
|
523
620
|
}
|
|
524
|
-
return { configs };
|
|
525
621
|
}
|
|
526
|
-
|
|
527
|
-
return {
|
|
622
|
+
const value = unwrap(exported);
|
|
623
|
+
if (!value) return { reason: "no `export default` or `module.exports` found" };
|
|
624
|
+
if (value.type !== "CallExpression" || value.callee.type !== "Identifier" || value.callee.name !== "defineConfig") return { reason: "the exported config is not a defineConfig(...) call" };
|
|
625
|
+
const argument = unwrap(value.arguments[0]);
|
|
626
|
+
if (!argument) return { reason: "defineConfig(...) was called without a config" };
|
|
627
|
+
const entries = argument.type === "ArrayExpression" ? argument.elements.map((element) => unwrap(element)) : [argument];
|
|
628
|
+
const configs = entries.filter((entry) => entry?.type === "ObjectExpression");
|
|
629
|
+
if (configs.length !== entries.length) return { reason: "config is not an object literal" };
|
|
630
|
+
return {
|
|
631
|
+
mod,
|
|
632
|
+
configs,
|
|
633
|
+
kind
|
|
634
|
+
};
|
|
528
635
|
}
|
|
529
|
-
/**
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
636
|
+
/** The name of a `key` or `'key'` member, `undefined` for a spread or a computed key. */
|
|
637
|
+
function memberKey(entry) {
|
|
638
|
+
if (entry.type === "SpreadElement" || entry.computed) return void 0;
|
|
639
|
+
return entry.key.type === "Identifier" ? entry.key.name : entry.key.type === "StringLiteral" ? entry.key.value : void 0;
|
|
640
|
+
}
|
|
641
|
+
/** The member named `key`. The last one wins when the key repeats, as it does when the object is evaluated. */
|
|
642
|
+
function findMember(node, key) {
|
|
643
|
+
const index = node.properties.findLastIndex((entry) => memberKey(entry) === key);
|
|
644
|
+
return index === -1 ? void 0 : {
|
|
645
|
+
member: node.properties[index],
|
|
646
|
+
index
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
/** The value node of a member. A method or accessor is its own value: it is code, never a literal. */
|
|
650
|
+
function property(node, key) {
|
|
651
|
+
const member = findMember(node, key)?.member;
|
|
652
|
+
return member?.type === "ObjectProperty" ? member.value : member;
|
|
536
653
|
}
|
|
537
654
|
function configName(config) {
|
|
538
655
|
const name = property(config, "name");
|
|
539
656
|
return name?.type === "StringLiteral" ? name.value : void 0;
|
|
540
657
|
}
|
|
541
|
-
/**
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
*/
|
|
545
|
-
function propertyKey(entry) {
|
|
546
|
-
if (entry.key.type === "Identifier") return entry.key.name;
|
|
547
|
-
if (entry.key.type === "StringLiteral") return entry.key.value;
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* The index of an object literal's own property named `key`, `-1` when it has none.
|
|
551
|
-
*/
|
|
552
|
-
function entryIndex({ node, key }) {
|
|
553
|
-
return node.properties.findIndex((entry) => entry.type === "ObjectProperty" && propertyKey(entry) === key);
|
|
554
|
-
}
|
|
555
|
-
/**
|
|
556
|
-
* The value node of an object literal's own property.
|
|
557
|
-
*/
|
|
558
|
-
function property(node, key) {
|
|
559
|
-
const index = entryIndex({
|
|
560
|
-
node,
|
|
561
|
-
key
|
|
562
|
-
});
|
|
563
|
-
return index === -1 ? void 0 : node.properties[index].value;
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Writes `key: value` on an object literal, replacing the value when the property is already there.
|
|
567
|
-
*
|
|
568
|
-
* An existing property has its value swapped in place rather than being replaced whole, so recast
|
|
569
|
-
* reprints only that value and leaves the object's own layout alone.
|
|
570
|
-
*/
|
|
571
|
-
function setProperty({ node, key, value }) {
|
|
572
|
-
const entry = literalProperty({
|
|
573
|
-
key,
|
|
574
|
-
value
|
|
575
|
-
});
|
|
576
|
-
const index = entryIndex({
|
|
577
|
-
node,
|
|
578
|
-
key
|
|
579
|
-
});
|
|
580
|
-
if (index === -1) {
|
|
581
|
-
node.properties.push(entry);
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
node.properties[index].value = entry.value;
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* Drops `key` from an object literal.
|
|
588
|
-
*/
|
|
589
|
-
function removeProperty({ node, key }) {
|
|
590
|
-
const index = entryIndex({
|
|
591
|
-
node,
|
|
592
|
-
key
|
|
593
|
-
});
|
|
594
|
-
if (index !== -1) node.properties.splice(index, 1);
|
|
658
|
+
/** Whether a `...spread` after the member at `index` could override it. */
|
|
659
|
+
function isShadowed(node, index) {
|
|
660
|
+
return node.properties.slice(index + 1).some((entry) => entry.type === "SpreadElement");
|
|
595
661
|
}
|
|
596
662
|
/**
|
|
597
663
|
* Reads a literal node's value: a primitive, or an object/array built only from primitives.
|
|
@@ -599,42 +665,109 @@ function removeProperty({ node, key }) {
|
|
|
599
665
|
* whether a node is a literal at all.
|
|
600
666
|
*/
|
|
601
667
|
function readLiteral(node) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
668
|
+
switch (node?.type) {
|
|
669
|
+
case "StringLiteral":
|
|
670
|
+
case "NumericLiteral":
|
|
671
|
+
case "BooleanLiteral": return node.value;
|
|
672
|
+
case "NullLiteral": return null;
|
|
673
|
+
case "TemplateLiteral": return node.expressions.length ? void 0 : node.quasis[0]?.value.cooked ?? "";
|
|
674
|
+
case "UnaryExpression": {
|
|
675
|
+
const value = readLiteral(node.argument);
|
|
676
|
+
if (typeof value !== "number") return void 0;
|
|
677
|
+
return node.operator === "-" ? -value : node.operator === "+" ? value : void 0;
|
|
678
|
+
}
|
|
679
|
+
case "ArrayExpression": {
|
|
680
|
+
const values = node.elements.map((element) => readLiteral(element));
|
|
681
|
+
return values.every((value) => value !== void 0) ? values : void 0;
|
|
682
|
+
}
|
|
683
|
+
case "ObjectExpression": {
|
|
684
|
+
const entries = {};
|
|
685
|
+
for (const entry of node.properties) {
|
|
686
|
+
if (entry.type !== "ObjectProperty") return void 0;
|
|
687
|
+
const key = memberKey(entry);
|
|
688
|
+
const value = readLiteral(entry.value);
|
|
689
|
+
if (key === void 0 || !isSafeKey(key) || value === void 0) return void 0;
|
|
690
|
+
entries[key] = value;
|
|
691
|
+
}
|
|
692
|
+
return entries;
|
|
625
693
|
}
|
|
626
|
-
return entries;
|
|
627
694
|
}
|
|
628
695
|
}
|
|
629
|
-
/**
|
|
630
|
-
|
|
631
|
-
|
|
696
|
+
/** Writes `value` as a single-line literal with the file's quote style. */
|
|
697
|
+
function printValue(value, quote) {
|
|
698
|
+
if (typeof value === "string") {
|
|
699
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
700
|
+
return quote === `"` ? `"${escaped}"` : `'${escaped.replaceAll(`\\"`, `"`).replaceAll(`'`, `\\'`)}'`;
|
|
701
|
+
}
|
|
702
|
+
if (value === null || typeof value !== "object") return String(value);
|
|
703
|
+
if (Array.isArray(value)) return `[${value.map((item) => printValue(item, quote)).join(", ")}]`;
|
|
704
|
+
const entries = Object.entries(value);
|
|
705
|
+
return entries.length ? `{ ${entries.map(([key, item]) => `${printKey(key, quote)}: ${printValue(item, quote)}`).join(", ")} }` : "{}";
|
|
706
|
+
}
|
|
707
|
+
/** An object key as source text: bare when it is an identifier, quoted otherwise. */
|
|
708
|
+
function printKey(key, quote) {
|
|
709
|
+
return IDENTIFIER.test(key) ? key : printValue(key, quote);
|
|
710
|
+
}
|
|
711
|
+
/** `value` wrapped in one object per key of `path`: `nest(['enum', 'type'], 'enum')` is `{ enum: { type: 'enum' } }`. */
|
|
712
|
+
function nest(path, value) {
|
|
713
|
+
return path.reduceRight((inner, key) => ({ [key]: inner }), value);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Adds `text` as the last item of an object or array literal, following its layout: on a line of
|
|
717
|
+
* its own with the last item's indentation when the literal spans lines, inline otherwise.
|
|
718
|
+
*/
|
|
719
|
+
function insertItem(source, container, text) {
|
|
720
|
+
const { start, end } = rangeOf(container);
|
|
721
|
+
const last = (container.type === "ObjectExpression" ? container.properties : container.elements).filter((item) => item !== null).at(-1);
|
|
722
|
+
if (!last) {
|
|
723
|
+
const inner = source.slice(start + 1, end - 1);
|
|
724
|
+
if (inner.trim()) return splice(source, start + 1, start + 1, /^\s/.test(inner) ? `${text},` : `${text}, `);
|
|
725
|
+
return splice(source, start, end, container.type === "ObjectExpression" ? `{ ${text} }` : `[${text}]`);
|
|
726
|
+
}
|
|
727
|
+
const lastRange = rangeOf(last);
|
|
728
|
+
const between = source.slice(lastRange.end, end - 1);
|
|
729
|
+
if (!between.includes("\n")) return splice(source, lastRange.end, lastRange.end, `, ${text}`);
|
|
730
|
+
const lineStart = source.lastIndexOf("\n", lastRange.start - 1) + 1;
|
|
731
|
+
const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
|
|
732
|
+
const lineEnd = source.indexOf("\n", lastRange.end);
|
|
733
|
+
const comma = /^\s*,/.test(between);
|
|
734
|
+
return `${source.slice(0, lastRange.end)}${comma ? "" : ","}${source.slice(lastRange.end, lineEnd)}\n${indent}${text}${comma ? "," : ""}${source.slice(lineEnd)}`;
|
|
735
|
+
}
|
|
736
|
+
/** Drops the member at `index` from an object literal, with its own lines when it sits on them alone. */
|
|
737
|
+
function removeMember(source, node, index) {
|
|
738
|
+
if (node.properties.length === 1) return splice(source, rangeOf(node).start, rangeOf(node).end, "{}");
|
|
739
|
+
const { start, end } = rangeOf(node.properties[index]);
|
|
740
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
741
|
+
const nextNewline = source.indexOf("\n", end);
|
|
742
|
+
const lineEnd = nextNewline === -1 ? source.length : nextNewline;
|
|
743
|
+
if (!source.slice(lineStart, start).trim() && /^\s*,?\s*(\/\/.*)?$/.test(source.slice(end, lineEnd))) return splice(source, lineStart, Math.min(lineEnd + 1, source.length));
|
|
744
|
+
const next = node.properties[index + 1];
|
|
745
|
+
return next ? splice(source, start, rangeOf(next).start) : splice(source, rangeOf(node.properties[index - 1]).end, end);
|
|
746
|
+
}
|
|
747
|
+
/** The module a `require('...')` call loads, when `node` is one. */
|
|
748
|
+
function requiredModule(node) {
|
|
749
|
+
if (node?.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "require") return void 0;
|
|
750
|
+
const [argument] = node.arguments;
|
|
751
|
+
return argument?.type === "StringLiteral" ? argument.value : void 0;
|
|
752
|
+
}
|
|
753
|
+
/** Maps each identifier the file imports, through `import { x } from` or `const { x } = require(...)`, to its module. */
|
|
632
754
|
function importedFrom(mod) {
|
|
633
|
-
|
|
755
|
+
const imports = /* @__PURE__ */ new Map();
|
|
756
|
+
for (const statement of bodyOf(mod)) {
|
|
757
|
+
if (statement.type === "ImportDeclaration") {
|
|
758
|
+
for (const specifier of statement.specifiers) imports.set(specifier.local.name, statement.source.value);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
762
|
+
for (const declarator of statement.declarations) {
|
|
763
|
+
const from = requiredModule(declarator.init);
|
|
764
|
+
if (from === void 0 || declarator.id.type !== "ObjectPattern") continue;
|
|
765
|
+
for (const entry of declarator.id.properties) if (entry.type === "ObjectProperty" && entry.value.type === "Identifier") imports.set(entry.value.name, from);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return imports;
|
|
634
769
|
}
|
|
635
|
-
/**
|
|
636
|
-
* Every `pluginX(...)` element of a config's plugins array that resolves to an import.
|
|
637
|
-
*/
|
|
770
|
+
/** Every `pluginX(...)` element of a config's plugins array that resolves to an import. */
|
|
638
771
|
function pluginCalls(mod, config) {
|
|
639
772
|
const plugins = property(config, "plugins");
|
|
640
773
|
if (plugins?.type !== "ArrayExpression") return [];
|
|
@@ -650,19 +783,51 @@ function pluginCalls(mod, config) {
|
|
|
650
783
|
});
|
|
651
784
|
}
|
|
652
785
|
/**
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
* Read from the marker lines rather than the AST, since a commented-out call is no longer a node.
|
|
786
|
+
* The blocks a previous `disable-plugin` commented out of this config, read from the marker lines
|
|
787
|
+
* since a commented-out call is no longer a node. `code` holds the block's lines as they were.
|
|
656
788
|
*/
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
789
|
+
function disabledBlocks(source, config) {
|
|
790
|
+
const lines = source.split("\n");
|
|
791
|
+
const first = config.loc?.start.line ?? 0;
|
|
792
|
+
const last = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
|
|
793
|
+
return lines.flatMap((text, index) => {
|
|
794
|
+
const match = text.trim().match(DISABLED_MARKER);
|
|
795
|
+
if (!match || index + 1 < first || index + 1 > last) return [];
|
|
796
|
+
const end = index + 1 + Number(match[2]);
|
|
797
|
+
const code = lines.slice(index + 1, end).map((line) => line.replace(/^(\s*)\/\/ ?/, "$1").replace(/^\s+$/, ""));
|
|
798
|
+
return [{
|
|
799
|
+
packageName: match[1],
|
|
800
|
+
line: index + 1,
|
|
801
|
+
end,
|
|
802
|
+
code
|
|
803
|
+
}];
|
|
664
804
|
});
|
|
665
805
|
}
|
|
806
|
+
/** The top-level options of a plugin call, each flagged with whether Studio may write it. */
|
|
807
|
+
function readOptions(call) {
|
|
808
|
+
const entries = {};
|
|
809
|
+
const options = call.arguments[0];
|
|
810
|
+
if (options?.type !== "ObjectExpression") return entries;
|
|
811
|
+
for (const [index, entry] of options.properties.entries()) {
|
|
812
|
+
const key = memberKey(entry);
|
|
813
|
+
if (key === void 0 || !isSafeKey(key)) continue;
|
|
814
|
+
const value = entry.type === "ObjectProperty" && !isShadowed(options, index) ? readLiteral(entry.value) : void 0;
|
|
815
|
+
entries[key] = value === void 0 ? { literal: false } : {
|
|
816
|
+
literal: true,
|
|
817
|
+
value
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
return entries;
|
|
821
|
+
}
|
|
822
|
+
/** The options a commented-out plugin call holds, so Studio can show them while it is disabled. */
|
|
823
|
+
function readDisabledOptions(code) {
|
|
824
|
+
try {
|
|
825
|
+
const call = (0, magicast.parseExpression)(code.join("\n").trim().replace(/,$/, "")).$ast;
|
|
826
|
+
return call.type === "CallExpression" ? readOptions(call) : {};
|
|
827
|
+
} catch {
|
|
828
|
+
return {};
|
|
829
|
+
}
|
|
830
|
+
}
|
|
666
831
|
/**
|
|
667
832
|
* Reads which plugins the file declares and which of their options Studio may write.
|
|
668
833
|
*
|
|
@@ -682,37 +847,27 @@ function readConfig(source) {
|
|
|
682
847
|
};
|
|
683
848
|
const { mod, configs } = parsed;
|
|
684
849
|
const importNames = new Map([...importedFrom(mod)].map(([local, from]) => [from, local]));
|
|
685
|
-
const disabled = disabledMarkers(source);
|
|
686
850
|
return {
|
|
687
851
|
managed: true,
|
|
688
852
|
configs: configs.map((config) => {
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (options?.type === "ObjectExpression") for (const entry of options.properties) {
|
|
693
|
-
if (entry.type !== "ObjectProperty") continue;
|
|
694
|
-
const key = propertyKey(entry);
|
|
695
|
-
if (key === void 0) continue;
|
|
696
|
-
const value = readLiteral(entry.value);
|
|
697
|
-
entries[key] = value === void 0 ? { literal: false } : {
|
|
698
|
-
literal: true,
|
|
699
|
-
value
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
return {
|
|
853
|
+
const active = pluginCalls(mod, config).map(({ importName, packageName, call }) => ({
|
|
854
|
+
line: call.loc?.start.line ?? 0,
|
|
855
|
+
plugin: {
|
|
703
856
|
importName,
|
|
704
857
|
packageName,
|
|
705
|
-
options:
|
|
706
|
-
}
|
|
707
|
-
});
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
858
|
+
options: readOptions(call)
|
|
859
|
+
}
|
|
860
|
+
}));
|
|
861
|
+
const disabled = disabledBlocks(source, config).map(({ packageName, line, code }) => ({
|
|
862
|
+
line,
|
|
863
|
+
plugin: {
|
|
864
|
+
importName: importNames.get(packageName) ?? toExportName(packageName),
|
|
865
|
+
packageName,
|
|
866
|
+
options: readDisabledOptions(code),
|
|
867
|
+
disabled: true
|
|
868
|
+
}
|
|
869
|
+
}));
|
|
870
|
+
const plugins = [...active, ...disabled].sort((a, b) => a.line - b.line).map(({ plugin }) => plugin);
|
|
716
871
|
return {
|
|
717
872
|
name: configName(config),
|
|
718
873
|
plugins
|
|
@@ -721,173 +876,131 @@ function readConfig(source) {
|
|
|
721
876
|
};
|
|
722
877
|
}
|
|
723
878
|
/**
|
|
724
|
-
*
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
return options?.type === "ObjectExpression" ? options : void 0;
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* The options object of a plugin call, creating an empty one when the plugin was called bare.
|
|
732
|
-
*/
|
|
733
|
-
function ensureOptions(call) {
|
|
734
|
-
if (call.arguments.length === 0) call.arguments.push({
|
|
735
|
-
type: "ObjectExpression",
|
|
736
|
-
properties: []
|
|
737
|
-
});
|
|
738
|
-
return getOptions(call);
|
|
739
|
-
}
|
|
740
|
-
/**
|
|
741
|
-
* Walks `path` down to the object holding its last key, descending only through object literals.
|
|
879
|
+
* Walks `path` through a plugin's options to the literal at its last key. Returns `missing` with
|
|
880
|
+
* the index of the first key that is not there yet, so `set` can write the rest of the path as one
|
|
881
|
+
* nested literal and `remove` can report it as unset. Refuses a value customized in code, so it is
|
|
882
|
+
* never overwritten.
|
|
742
883
|
*/
|
|
743
|
-
function
|
|
884
|
+
function walk(options, path) {
|
|
744
885
|
let object = options;
|
|
745
|
-
for (const [
|
|
746
|
-
|
|
886
|
+
for (const [depth, key] of path.entries()) {
|
|
887
|
+
const found = findMember(object, key);
|
|
888
|
+
if (!found) return {
|
|
747
889
|
object,
|
|
748
|
-
|
|
890
|
+
missing: depth
|
|
749
891
|
};
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
object
|
|
892
|
+
if (isShadowed(object, found.index)) return { reason: `${path.slice(0, depth + 1).join(".")} can be overridden by a spread after it, so it is customized in code` };
|
|
893
|
+
const { member, index } = found;
|
|
894
|
+
if (depth === path.length - 1) return member.type === "ObjectProperty" && readLiteral(member.value) !== void 0 ? {
|
|
895
|
+
object,
|
|
896
|
+
index,
|
|
897
|
+
value: member.value
|
|
898
|
+
} : { reason: `${path.join(".")} is customized in code` };
|
|
899
|
+
if (member.type !== "ObjectProperty" || member.value.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
|
|
900
|
+
object = member.value;
|
|
758
901
|
}
|
|
759
902
|
return { reason: "no option path given" };
|
|
760
903
|
}
|
|
761
|
-
/**
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
904
|
+
/** Writes `value` at `path` inside a plugin call's options, adding the options object and any intermediate object it needs. */
|
|
905
|
+
function applySet(source, call, path, value, quote) {
|
|
906
|
+
if (!isOptionValue(value)) return { reason: "the value is not a literal that can be written to a config file" };
|
|
907
|
+
const options = call.arguments[0];
|
|
908
|
+
if (!options) {
|
|
909
|
+
const closing = rangeOf(call).end - 1;
|
|
910
|
+
if (source[closing] !== ")") return { reason: "the plugin call could not be located in the file" };
|
|
911
|
+
return { source: splice(source, closing, closing, printValue(nest(path, value), quote)) };
|
|
912
|
+
}
|
|
913
|
+
if (options.type !== "ObjectExpression") return { reason: "the plugin was not called with an object literal" };
|
|
914
|
+
const target = walk(options, path);
|
|
915
|
+
if ("reason" in target) return target;
|
|
916
|
+
if ("missing" in target) {
|
|
917
|
+
const [key, ...rest] = path.slice(target.missing);
|
|
918
|
+
return { source: insertItem(source, target.object, `${printKey(key, quote)}: ${printValue(nest(rest, value), quote)}`) };
|
|
919
|
+
}
|
|
920
|
+
return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
|
|
921
|
+
}
|
|
922
|
+
/** Deletes the option at `path`, falling the plugin back to its default for it. */
|
|
923
|
+
function applyRemove(source, call, path) {
|
|
924
|
+
const options = call.arguments[0];
|
|
925
|
+
if (options?.type !== "ObjectExpression") return { reason: "the plugin has no options to remove" };
|
|
926
|
+
const target = walk(options, path);
|
|
927
|
+
if ("reason" in target) return target;
|
|
928
|
+
if ("missing" in target) return { reason: `${path.join(".")} is not set` };
|
|
929
|
+
return { source: removeMember(source, target.object, target.index) };
|
|
779
930
|
}
|
|
780
931
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
* `
|
|
932
|
+
* Writes an import after the last one already in the file, matching its quote style and whether it
|
|
933
|
+
* ends in a semicolon: `import { x } from '...'`, or `const { x } = require('...')` in a CommonJS file.
|
|
934
|
+
* The last import is read off the AST, so a multi-line `import {\n x,\n} from '...'` counts to its closing line.
|
|
784
935
|
*/
|
|
785
|
-
function
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
key: target.key
|
|
796
|
-
});
|
|
936
|
+
function insertImport(source, { mod, kind }, importName, moduleSpecifier) {
|
|
937
|
+
const afterLine = bodyOf(mod).findLast((node) => kind === "esm" ? node.type === "ImportDeclaration" : node.type === "VariableDeclaration" && node.declarations.some((declarator) => requiredModule(declarator.init) !== void 0))?.loc?.end.line ?? 0;
|
|
938
|
+
const lines = source.split("\n");
|
|
939
|
+
const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
|
|
940
|
+
const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
|
|
941
|
+
const semicolon = lastImportLine?.trimEnd().endsWith(";") ? ";" : "";
|
|
942
|
+
const specifier = `${quote}${moduleSpecifier}${quote}`;
|
|
943
|
+
lines.splice(afterLine, 0, kind === "esm" ? `import { ${importName} } from ${specifier}${semicolon}` : `const { ${importName} } = require(${specifier})${semicolon}`);
|
|
944
|
+
if (afterLine === 0) lines.splice(1, 0, "");
|
|
945
|
+
return lines.join("\n");
|
|
797
946
|
}
|
|
798
947
|
/**
|
|
799
|
-
* Adds a `pluginX(...)` call to a config's plugins array. Replaying the same add is
|
|
800
|
-
* an import name collision with an unrelated package remains an error.
|
|
948
|
+
* Adds a `pluginX(...)` call to a config's plugins array, and its import. Replaying the same add is
|
|
949
|
+
* a no-op, while an import name collision with an unrelated package remains an error.
|
|
801
950
|
*/
|
|
802
|
-
function applyAddPlugin(
|
|
951
|
+
function applyAddPlugin(source, parsed, config, edit, quote) {
|
|
803
952
|
if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
|
|
804
953
|
const importName = edit.importName ?? toExportName(edit.plugin);
|
|
805
954
|
if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
|
|
806
|
-
if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return {
|
|
807
|
-
const taken = importedFrom(mod).get(importName);
|
|
955
|
+
if (pluginCalls(parsed.mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { source };
|
|
956
|
+
const taken = importedFrom(parsed.mod).get(importName);
|
|
808
957
|
if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
|
|
809
958
|
const options = edit.options ?? {};
|
|
810
959
|
if (!isOptionValue(options)) return { reason: "the options are not literals that can be written to a config file" };
|
|
811
960
|
const plugins = property(config, "plugins");
|
|
812
961
|
if (plugins?.type !== "ArrayExpression") return { reason: "plugins is not an array literal" };
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
return taken ? {} : { addImport: {
|
|
816
|
-
importName,
|
|
817
|
-
moduleSpecifier: edit.plugin
|
|
818
|
-
} };
|
|
962
|
+
const next = insertItem(source, plugins, `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`);
|
|
963
|
+
return { source: taken ? next : insertImport(next, parsed, importName, edit.plugin) };
|
|
819
964
|
}
|
|
820
|
-
/**
|
|
821
|
-
* Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore
|
|
822
|
-
* them exactly. Operates on `source` text rather than the AST: a commented-out call is no longer a
|
|
823
|
-
* node magicast can address, and the surrounding array must not reflow when its element count
|
|
824
|
-
* never actually changes.
|
|
825
|
-
*/
|
|
965
|
+
/** Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore them exactly. */
|
|
826
966
|
function disablePlugin(source, mod, config, plugin) {
|
|
827
|
-
const
|
|
828
|
-
if (!
|
|
829
|
-
const loc = target.call.loc;
|
|
830
|
-
if (!loc?.start || !loc.end) return { reason: `${plugin} has no source location to comment out` };
|
|
967
|
+
const loc = pluginCalls(mod, config).find((entry) => entry.packageName === plugin)?.call.loc;
|
|
968
|
+
if (!loc) return { reason: `${plugin} is not in the plugins array` };
|
|
831
969
|
const lines = source.split("\n");
|
|
832
970
|
const from = loc.start.line - 1;
|
|
833
971
|
const to = loc.end.line - 1;
|
|
834
972
|
const firstLine = lines[from] ?? "";
|
|
835
|
-
|
|
836
|
-
if (firstLine.slice(0, loc.start.column).trim() !== "" || !/^,?\s*$/.test(lastLine.slice(loc.end.column))) return { reason: `${plugin} shares a line with other code, so it cannot be commented out safely` };
|
|
973
|
+
if (firstLine.slice(0, loc.start.column).trim() || !/^,?\s*$/.test((lines[to] ?? "").slice(loc.end.column))) return { reason: `${plugin} shares a line with other code, so it cannot be commented out safely` };
|
|
837
974
|
const indent = firstLine.match(/^\s*/)?.[0] ?? "";
|
|
838
|
-
const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` :
|
|
839
|
-
lines.splice(from,
|
|
975
|
+
const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : `${indent}//`);
|
|
976
|
+
lines.splice(from, commented.length, `${indent}// kubb:disabled ${plugin} ${commented.length}`, ...commented);
|
|
840
977
|
return { source: lines.join("\n") };
|
|
841
978
|
}
|
|
842
|
-
/**
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
979
|
+
/** Uncomments the block a previous `disable-plugin` left behind for `plugin` in this config. */
|
|
980
|
+
function enablePlugin(source, config, plugin) {
|
|
981
|
+
const block = disabledBlocks(source, config).find((entry) => entry.packageName === plugin);
|
|
982
|
+
if (!block) return { reason: `${plugin} is not disabled` };
|
|
846
983
|
const lines = source.split("\n");
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
if (marker?.plugin !== plugin) continue;
|
|
850
|
-
const end = index + 1 + marker.lineCount;
|
|
851
|
-
const restored = lines.slice(index + 1, end).map((commented) => commented.replace(/^(\s*)\/\/ ?/, "$1"));
|
|
852
|
-
lines.splice(index, end - index, ...restored);
|
|
853
|
-
return { source: lines.join("\n") };
|
|
854
|
-
}
|
|
855
|
-
return { reason: `${plugin} is not disabled` };
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Parses `source` and finds the config entries it exports.
|
|
859
|
-
*/
|
|
860
|
-
function parseConfigs(source) {
|
|
861
|
-
let mod;
|
|
862
|
-
try {
|
|
863
|
-
mod = (0, magicast.parseModule)(source);
|
|
864
|
-
} catch {
|
|
865
|
-
return { reason: "the config file could not be parsed" };
|
|
866
|
-
}
|
|
867
|
-
const found = findConfigs(mod);
|
|
868
|
-
return "reason" in found ? found : {
|
|
869
|
-
mod,
|
|
870
|
-
configs: found.configs
|
|
871
|
-
};
|
|
984
|
+
lines.splice(block.line - 1, block.end - block.line + 1, ...block.code);
|
|
985
|
+
return { source: lines.join("\n") };
|
|
872
986
|
}
|
|
873
|
-
/**
|
|
874
|
-
|
|
875
|
-
* than sharing one module across the batch, since the disable/enable edits rewrite `source` as
|
|
876
|
-
* text and would otherwise leave the others working from a stale tree.
|
|
877
|
-
*/
|
|
878
|
-
function parseTarget(source, ref) {
|
|
987
|
+
/** Applies one edit to `source`. Every edit re-parses the file, since the previous one rewrote it. */
|
|
988
|
+
function applyEdit(source, edit, quote) {
|
|
879
989
|
const parsed = parseConfigs(source);
|
|
880
990
|
if ("reason" in parsed) return parsed;
|
|
881
|
-
const
|
|
991
|
+
const ref = edit.config;
|
|
992
|
+
const config = typeof ref === "string" ? parsed.configs.find((entry) => configName(entry) === ref) : parsed.configs[ref ?? 0];
|
|
882
993
|
if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
|
|
883
|
-
return
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
994
|
+
if (edit.operation === "disable-plugin") return disablePlugin(source, parsed.mod, config, edit.plugin);
|
|
995
|
+
if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
|
|
996
|
+
if (edit.operation === "add-plugin") return applyAddPlugin(source, parsed, config, edit, quote);
|
|
997
|
+
const call = pluginCalls(parsed.mod, config).find((plugin) => plugin.packageName === edit.plugin)?.call;
|
|
998
|
+
if (!call) return { reason: disabledBlocks(source, config).some((block) => block.packageName === edit.plugin) ? `${edit.plugin} is disabled, enable it first` : `${edit.plugin} is not in the plugins array` };
|
|
999
|
+
return edit.operation === "set" ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
|
|
887
1000
|
}
|
|
888
1001
|
/**
|
|
889
|
-
* Applies edits to a `kubb.config.ts` in place.
|
|
890
|
-
*
|
|
1002
|
+
* Applies edits to a `kubb.config.ts` in place. Each edit rewrites only the text it targets, so
|
|
1003
|
+
* comments, formatting, and hand-written code around the config survive.
|
|
891
1004
|
*
|
|
892
1005
|
* Edits are independent: one that cannot be applied is reported in `outcomes` and the rest still run.
|
|
893
1006
|
*
|
|
@@ -897,25 +1010,19 @@ function parseTarget(source, ref) {
|
|
|
897
1010
|
* { operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' },
|
|
898
1011
|
* ])
|
|
899
1012
|
* ```
|
|
900
|
-
*
|
|
901
|
-
* @note recast always reprints a semicolon on a reprinted statement, so editing a block-body
|
|
902
|
-
* `defineConfig` in a semicolon-free file adds one to the `return` line. This is a known gap.
|
|
903
|
-
* Strip it when `detectCodeFormat` reports `useSemi: false`, if it turns out to matter in practice.
|
|
904
1013
|
*/
|
|
905
1014
|
function applyConfigEdits(source, edits) {
|
|
906
1015
|
let current = source;
|
|
907
|
-
const
|
|
908
|
-
const endsWithNewline = source.endsWith("\n");
|
|
1016
|
+
const quote = (0, magicast.detectCodeFormat)(source).quote === "double" ? `"` : `'`;
|
|
909
1017
|
const outcomes = edits.map((edit) => {
|
|
910
|
-
const
|
|
911
|
-
if (
|
|
1018
|
+
const invalid = invalidEdit(edit);
|
|
1019
|
+
if (invalid) return {
|
|
912
1020
|
edit,
|
|
913
1021
|
applied: false,
|
|
914
|
-
reason:
|
|
1022
|
+
reason: invalid
|
|
915
1023
|
};
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const result = edit.operation === "disable-plugin" ? disablePlugin(current, mod, config, edit.plugin) : enablePlugin(current, edit.plugin);
|
|
1024
|
+
try {
|
|
1025
|
+
const result = applyEdit(current, edit, quote);
|
|
919
1026
|
if ("reason" in result) return {
|
|
920
1027
|
edit,
|
|
921
1028
|
applied: false,
|
|
@@ -926,44 +1033,13 @@ function applyConfigEdits(source, edits) {
|
|
|
926
1033
|
edit,
|
|
927
1034
|
applied: true
|
|
928
1035
|
};
|
|
929
|
-
}
|
|
930
|
-
if (edit.operation === "add-plugin") {
|
|
931
|
-
const result = applyAddPlugin(mod, config, edit);
|
|
932
|
-
if ("reason" in result) return {
|
|
933
|
-
edit,
|
|
934
|
-
applied: false,
|
|
935
|
-
reason: result.reason
|
|
936
|
-
};
|
|
937
|
-
if ("noop" in result) return {
|
|
938
|
-
edit,
|
|
939
|
-
applied: true
|
|
940
|
-
};
|
|
941
|
-
const afterLine = lastImportEndLine(mod);
|
|
942
|
-
let next = (0, magicast.generateCode)(mod, { format }).code;
|
|
943
|
-
if (result.addImport) next = insertImportLine({
|
|
944
|
-
source: next,
|
|
945
|
-
afterLine,
|
|
946
|
-
...result.addImport
|
|
947
|
-
});
|
|
948
|
-
current = withTrailingNewline(next, endsWithNewline);
|
|
1036
|
+
} catch (error) {
|
|
949
1037
|
return {
|
|
950
1038
|
edit,
|
|
951
|
-
applied:
|
|
1039
|
+
applied: false,
|
|
1040
|
+
reason: getErrorMessage(error)
|
|
952
1041
|
};
|
|
953
1042
|
}
|
|
954
|
-
const pluginCall = pluginCalls(mod, config).find((plugin) => plugin.packageName === edit.plugin);
|
|
955
|
-
if (!pluginCall) return {
|
|
956
|
-
edit,
|
|
957
|
-
applied: false,
|
|
958
|
-
reason: `${edit.plugin} is not in the plugins array`
|
|
959
|
-
};
|
|
960
|
-
const reason = edit.operation === "set" ? applySet(pluginCall.call, edit.path, edit.value) : applyRemove(pluginCall.call, edit.path);
|
|
961
|
-
if (!reason) current = withTrailingNewline((0, magicast.generateCode)(mod, { format }).code, endsWithNewline);
|
|
962
|
-
return {
|
|
963
|
-
edit,
|
|
964
|
-
applied: !reason,
|
|
965
|
-
reason
|
|
966
|
-
};
|
|
967
1043
|
});
|
|
968
1044
|
return {
|
|
969
1045
|
source: current,
|
|
@@ -971,42 +1047,12 @@ function applyConfigEdits(source, edits) {
|
|
|
971
1047
|
changed: current !== source
|
|
972
1048
|
};
|
|
973
1049
|
}
|
|
1050
|
+
/** Applies `edits` to the config file at `filePath`, writing it only when something changed. */
|
|
974
1051
|
async function writeConfigEdits({ filePath, edits }) {
|
|
975
1052
|
const result = applyConfigEdits(await read(filePath), edits);
|
|
976
1053
|
if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
|
|
977
1054
|
return result;
|
|
978
1055
|
}
|
|
979
|
-
/**
|
|
980
|
-
* The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
|
|
981
|
-
* off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
|
|
982
|
-
* rather than the `import` keyword.
|
|
983
|
-
*/
|
|
984
|
-
function lastImportEndLine(mod) {
|
|
985
|
-
return (mod.$ast.type === "Program" ? mod.$ast.body : []).filter((node) => node.type === "ImportDeclaration").at(-1)?.loc?.end.line ?? 0;
|
|
986
|
-
}
|
|
987
|
-
/**
|
|
988
|
-
* Writes an import after the last one already in the file, matching its quote style and whether it
|
|
989
|
-
* ends in a semicolon. `afterLine` is where that last import ends, `0` for a file with none.
|
|
990
|
-
*
|
|
991
|
-
* Written as plain text rather than through magicast's import builder, which prints a brand-new
|
|
992
|
-
* import declaration with its own default spacing and a semicolon regardless of `format`, since
|
|
993
|
-
* that formatting only governs nodes recast can diff against the original source.
|
|
994
|
-
*/
|
|
995
|
-
function insertImportLine({ source, importName, moduleSpecifier, afterLine }) {
|
|
996
|
-
const lines = source.split("\n");
|
|
997
|
-
const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
|
|
998
|
-
const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
|
|
999
|
-
const line = `import { ${importName} } from ${quote}${moduleSpecifier}${quote}${lastImportLine?.trimEnd().endsWith(";") ? ";" : ""}`;
|
|
1000
|
-
lines.splice(afterLine, 0, ...afterLine > 0 ? [line] : [line, ""]);
|
|
1001
|
-
return lines.join("\n");
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* `generateCode` always drops the file's trailing newline. Restore it when the input had one.
|
|
1005
|
-
*/
|
|
1006
|
-
function withTrailingNewline(code, hadTrailingNewline) {
|
|
1007
|
-
if (!hadTrailingNewline || code.endsWith("\n")) return code;
|
|
1008
|
-
return `${code}\n`;
|
|
1009
|
-
}
|
|
1010
1056
|
//#endregion
|
|
1011
1057
|
//#region src/generations.ts
|
|
1012
1058
|
const READ_CONCURRENCY = 50;
|
|
@@ -1129,7 +1175,8 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
|
|
|
1129
1175
|
if (path.split("/").includes("..")) return;
|
|
1130
1176
|
const content = await files.storage.readItem((0, node_path.resolve)(files.root, path));
|
|
1131
1177
|
if (content === null) return;
|
|
1132
|
-
|
|
1178
|
+
if (source === "output" && !content.trim()) return;
|
|
1179
|
+
hashes[path] = hashOf(source === "output" ? `${content.trim()}\n` : content);
|
|
1133
1180
|
bytes += Buffer.byteLength(content);
|
|
1134
1181
|
if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
|
|
1135
1182
|
}
|
|
@@ -1536,8 +1583,11 @@ var Agent = class extends capnweb.RpcTarget {
|
|
|
1536
1583
|
path: configPath,
|
|
1537
1584
|
file,
|
|
1538
1585
|
plugins: config.plugins.map((plugin) => ({
|
|
1539
|
-
name:
|
|
1540
|
-
|
|
1586
|
+
name: toStudioName({
|
|
1587
|
+
name: (0, _kubb_core.resolveMeta)(plugin).name,
|
|
1588
|
+
root
|
|
1589
|
+
}),
|
|
1590
|
+
options: toJSONOptions(plugin.options)
|
|
1541
1591
|
}))
|
|
1542
1592
|
},
|
|
1543
1593
|
permissions: this.#permissions
|
|
@@ -1594,11 +1644,15 @@ var Agent = class extends capnweb.RpcTarget {
|
|
|
1594
1644
|
}
|
|
1595
1645
|
async #generate({ input, root, signal }) {
|
|
1596
1646
|
const { jobId, config: patch } = input;
|
|
1647
|
+
const studioInput = this.#sandbox ? patch?.input : void 0;
|
|
1648
|
+
if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: "A sandbox agent generates only from the OpenAPI document content Studio sends, not from a file path or URL" });
|
|
1597
1649
|
const config = await this.#options.loadConfig();
|
|
1598
|
-
const plugins = await mergePlugins(config.plugins, patch?.plugins)
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1650
|
+
const [plugins, adapter] = await Promise.all([mergePlugins(config.plugins, patch?.plugins), mergeAdapter(config.adapter, patch?.adapter)]).catch(async (error) => {
|
|
1651
|
+
await emit(this.#options.hooks, "kubb:error", { error: toError(error) });
|
|
1652
|
+
throw error;
|
|
1653
|
+
});
|
|
1654
|
+
const activePlugins = plugins ?? config.plugins;
|
|
1655
|
+
if (patch?.input && !this.#sandbox) await emit(this.#options.hooks, "kubb:warn", { message: "Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent" });
|
|
1602
1656
|
const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
|
|
1603
1657
|
root,
|
|
1604
1658
|
outputPath: config.output.path,
|
|
@@ -1616,7 +1670,7 @@ var Agent = class extends capnweb.RpcTarget {
|
|
|
1616
1670
|
root,
|
|
1617
1671
|
input: studioInput ?? config.input,
|
|
1618
1672
|
storage: this.#permissions.allowWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
|
|
1619
|
-
plugins,
|
|
1673
|
+
plugins: activePlugins,
|
|
1620
1674
|
adapter
|
|
1621
1675
|
},
|
|
1622
1676
|
hooks: this.#options.hooks,
|
|
@@ -1646,7 +1700,7 @@ var Agent = class extends capnweb.RpcTarget {
|
|
|
1646
1700
|
hashes: output.hashes,
|
|
1647
1701
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
1648
1702
|
},
|
|
1649
|
-
info: `${
|
|
1703
|
+
info: `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
|
|
1650
1704
|
};
|
|
1651
1705
|
}
|
|
1652
1706
|
saveConfig(input) {
|
|
@@ -1801,8 +1855,12 @@ async function runSession({ options, openRpc, url, instanceId }) {
|
|
|
1801
1855
|
clearTimeout(timer);
|
|
1802
1856
|
signal?.removeEventListener("abort", rpc.close);
|
|
1803
1857
|
session.connected.reject(/* @__PURE__ */ new Error("The socket closed before Studio called connect()"));
|
|
1804
|
-
session.active
|
|
1805
|
-
|
|
1858
|
+
const active = session.active;
|
|
1859
|
+
if (active) {
|
|
1860
|
+
active.abort();
|
|
1861
|
+
await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
|
|
1862
|
+
}
|
|
1863
|
+
await active?.settled;
|
|
1806
1864
|
return {
|
|
1807
1865
|
...close,
|
|
1808
1866
|
connected: session.info !== void 0
|
|
@@ -2165,7 +2223,7 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
|
|
|
2165
2223
|
if (content === null) throw new Error(`The generation listed ${path} but did not keep its content`);
|
|
2166
2224
|
return [path, content];
|
|
2167
2225
|
})));
|
|
2168
|
-
const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map((
|
|
2226
|
+
const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map((plugin) => (0, _kubb_core.resolveMeta)(plugin).name));
|
|
2169
2227
|
const missing = missingDependencies.filter((dependency) => !BUNDLED_DEPENDENCIES.includes(dependency));
|
|
2170
2228
|
if (missing.length) throw new Error(`Missing dependencies: ${missing.join(", ")}`);
|
|
2171
2229
|
const bytes = await createSnapshotPackage({
|