@forinda/kickjs-cli 3.2.0 → 4.0.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/README.md +12 -89
- package/dist/cli.mjs +940 -47
- package/dist/index.d.mts +252 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +148 -26
- package/dist/index.mjs.map +1 -1
- package/dist/{typegen-C30frihW.mjs → typegen-vI1eqGLK.mjs} +446 -11
- package/dist/typegen-vI1eqGLK.mjs.map +1 -0
- package/package.json +9 -14
- package/dist/typegen-C30frihW.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-cli
|
|
2
|
+
* @forinda/kickjs-cli v4.0.0
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
12
12
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { statSync } from "node:fs";
|
|
14
|
+
import { globSync } from "glob";
|
|
13
15
|
//#region src/typegen/scanner.ts
|
|
14
16
|
/** Decorators that mark a class as DI-managed */
|
|
15
17
|
const DECORATOR_NAMES = [
|
|
@@ -64,6 +66,28 @@ const BARE_CREATE_TOKEN_REGEX = /createToken\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+
|
|
|
64
66
|
/** Match `@Inject('literal')` — only literals; computed args are skipped */
|
|
65
67
|
const INJECT_LITERAL_REGEX = /@Inject\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
66
68
|
/**
|
|
69
|
+
* Match the start of a `defineAdapter(...)` or `definePlugin(...)` call,
|
|
70
|
+
* tolerating optional `<TConfig, TExtra>` generics. Captures the helper
|
|
71
|
+
* name. The callsite's first-arg object is parsed forward via
|
|
72
|
+
* `findBalancedClose` so nested objects/parens don't confuse us.
|
|
73
|
+
*/
|
|
74
|
+
const DEFINE_HELPER_START = /\b(defineAdapter|definePlugin)\s*(?:<[^>]*>)?\s*\(/g;
|
|
75
|
+
/**
|
|
76
|
+
* Match a class declaration whose `implements` clause includes `AppAdapter`.
|
|
77
|
+
* Captures the class name. Used to pick up the (rare, post-defineAdapter)
|
|
78
|
+
* legacy class-style adapters so their literal `name = '...'` field can
|
|
79
|
+
* still feed `KickJsPluginRegistry`.
|
|
80
|
+
*/
|
|
81
|
+
const APP_ADAPTER_CLASS_REGEX = new RegExp(String.raw`export\s+(?:default\s+)?(?:abstract\s+)?class\s+(\w+)` + String.raw`(?:\s+extends\s+\w+(?:<[^>]*>)?)?` + String.raw`\s+implements\s+[^{]*\bAppAdapter\b`, "g");
|
|
82
|
+
/** Match a string-literal `name = '...'` field on a class body. */
|
|
83
|
+
const CLASS_NAME_FIELD_REGEX = /\bname\s*(?::\s*[^=]+)?=\s*['"`]([^'"`]+)['"`]/;
|
|
84
|
+
/**
|
|
85
|
+
* Match the start of a `defineAugmentation('Name', ...)` call. Captures
|
|
86
|
+
* the literal name. The optional second-arg object is parsed forward so
|
|
87
|
+
* `description` / `example` can be pulled out.
|
|
88
|
+
*/
|
|
89
|
+
const DEFINE_AUGMENTATION_START = /\bdefineAugmentation\s*\(\s*['"`]([^'"`]+)['"`]\s*(,\s*\{)?/g;
|
|
90
|
+
/**
|
|
67
91
|
* Locate the start of a route decorator: `@Get(`, `@Post(`, etc.
|
|
68
92
|
* Used by `extractRoutesFromSource`; the rest of the route declaration
|
|
69
93
|
* (balanced parens, stacked decorators, method name) is parsed by walking
|
|
@@ -410,6 +434,120 @@ function extractInjectsFromSource(source, filePath, cwd) {
|
|
|
410
434
|
return out;
|
|
411
435
|
}
|
|
412
436
|
/**
|
|
437
|
+
* Extract the bounds of an object literal that begins at `openBracePos`
|
|
438
|
+
* (the index of the `{` character). Returns the index of the matching `}`
|
|
439
|
+
* or -1 if no match is found. Counts balanced braces only — does not
|
|
440
|
+
* understand string literals so a `{` or `}` inside a string inside the
|
|
441
|
+
* object will skew the depth counter (matches `findBalancedClose`).
|
|
442
|
+
*/
|
|
443
|
+
function findBalancedBrace(text, openBracePos) {
|
|
444
|
+
let depth = 1;
|
|
445
|
+
for (let i = openBracePos + 1; i < text.length; i++) {
|
|
446
|
+
const ch = text[i];
|
|
447
|
+
if (ch === "{") depth++;
|
|
448
|
+
else if (ch === "}") {
|
|
449
|
+
depth--;
|
|
450
|
+
if (depth === 0) return i;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return -1;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Extract plugins/adapters declared via `defineAdapter({ name: '...' })`
|
|
457
|
+
* or `definePlugin({ name: '...' })` calls and via class-style adapters
|
|
458
|
+
* (`class XxxAdapter implements AppAdapter` with a string-literal `name`
|
|
459
|
+
* field).
|
|
460
|
+
*
|
|
461
|
+
* Only the literal `name:` field feeds the result — the symbol on the LHS
|
|
462
|
+
* is irrelevant since `dependsOn` references the runtime name.
|
|
463
|
+
*/
|
|
464
|
+
function extractPluginsAndAdaptersFromSource(source, filePath, cwd) {
|
|
465
|
+
const out = [];
|
|
466
|
+
const relPath = toRelative(filePath, cwd);
|
|
467
|
+
const seen = /* @__PURE__ */ new Set();
|
|
468
|
+
DEFINE_HELPER_START.lastIndex = 0;
|
|
469
|
+
let helperMatch;
|
|
470
|
+
while ((helperMatch = DEFINE_HELPER_START.exec(source)) !== null) {
|
|
471
|
+
const helper = helperMatch[1];
|
|
472
|
+
const openParen = DEFINE_HELPER_START.lastIndex - 1;
|
|
473
|
+
const closeParen = findBalancedClose(source, openParen);
|
|
474
|
+
if (closeParen < 0) continue;
|
|
475
|
+
const callArgs = source.slice(openParen + 1, closeParen);
|
|
476
|
+
const nameMatch = /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(callArgs);
|
|
477
|
+
if (!nameMatch) continue;
|
|
478
|
+
const name = nameMatch[1];
|
|
479
|
+
const dedupeKey = `${helper}::${name}::${filePath}`;
|
|
480
|
+
if (seen.has(dedupeKey)) continue;
|
|
481
|
+
seen.add(dedupeKey);
|
|
482
|
+
out.push({
|
|
483
|
+
kind: helper === "definePlugin" ? "plugin" : "adapter",
|
|
484
|
+
name,
|
|
485
|
+
filePath,
|
|
486
|
+
relativePath: relPath
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
APP_ADAPTER_CLASS_REGEX.lastIndex = 0;
|
|
490
|
+
let classMatch;
|
|
491
|
+
while ((classMatch = APP_ADAPTER_CLASS_REGEX.exec(source)) !== null) {
|
|
492
|
+
const classStart = classMatch.index;
|
|
493
|
+
const bracePos = source.indexOf("{", classStart);
|
|
494
|
+
if (bracePos < 0) continue;
|
|
495
|
+
const closeBrace = findBalancedBrace(source, bracePos);
|
|
496
|
+
if (closeBrace < 0) continue;
|
|
497
|
+
const body = source.slice(bracePos + 1, closeBrace);
|
|
498
|
+
const nameMatch = CLASS_NAME_FIELD_REGEX.exec(body);
|
|
499
|
+
if (!nameMatch) continue;
|
|
500
|
+
const name = nameMatch[1];
|
|
501
|
+
const dedupeKey = `class::${name}::${filePath}`;
|
|
502
|
+
if (seen.has(dedupeKey)) continue;
|
|
503
|
+
seen.add(dedupeKey);
|
|
504
|
+
out.push({
|
|
505
|
+
kind: "adapter",
|
|
506
|
+
name,
|
|
507
|
+
filePath,
|
|
508
|
+
relativePath: relPath
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Extract `defineAugmentation('Name', { description, example })` calls
|
|
515
|
+
* from a source file. The metadata object is optional — when absent both
|
|
516
|
+
* `description` and `example` resolve to `null`.
|
|
517
|
+
*/
|
|
518
|
+
function extractAugmentationsFromSource(source, filePath, cwd) {
|
|
519
|
+
const out = [];
|
|
520
|
+
const relPath = toRelative(filePath, cwd);
|
|
521
|
+
DEFINE_AUGMENTATION_START.lastIndex = 0;
|
|
522
|
+
let match;
|
|
523
|
+
while ((match = DEFINE_AUGMENTATION_START.exec(source)) !== null) {
|
|
524
|
+
const name = match[1];
|
|
525
|
+
let description = null;
|
|
526
|
+
let example = null;
|
|
527
|
+
if (match[2]) {
|
|
528
|
+
const bracePos = source.indexOf("{", match.index + match[0].length - 1);
|
|
529
|
+
if (bracePos >= 0) {
|
|
530
|
+
const closeBrace = findBalancedBrace(source, bracePos);
|
|
531
|
+
if (closeBrace >= 0) {
|
|
532
|
+
const body = source.slice(bracePos + 1, closeBrace);
|
|
533
|
+
const descMatch = /\bdescription\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
|
|
534
|
+
const exampleMatch = /\bexample\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
|
|
535
|
+
description = descMatch ? descMatch[1] : null;
|
|
536
|
+
example = exampleMatch ? exampleMatch[1] : null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
out.push({
|
|
541
|
+
name,
|
|
542
|
+
description,
|
|
543
|
+
example,
|
|
544
|
+
filePath,
|
|
545
|
+
relativePath: relPath
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
413
551
|
* Default search order for the env schema file. Newer projects keep
|
|
414
552
|
* the schema under `src/config/` so the framework's "config" concept
|
|
415
553
|
* has a single home; older scaffolds dropped it at `src/env.ts` (kept
|
|
@@ -480,6 +618,8 @@ async function scanProject(opts) {
|
|
|
480
618
|
const routes = [];
|
|
481
619
|
const tokens = [];
|
|
482
620
|
const injects = [];
|
|
621
|
+
const pluginsAndAdapters = [];
|
|
622
|
+
const augmentations = [];
|
|
483
623
|
const sources = /* @__PURE__ */ new Map();
|
|
484
624
|
for (const file of files) {
|
|
485
625
|
let source;
|
|
@@ -492,6 +632,8 @@ async function scanProject(opts) {
|
|
|
492
632
|
classes.push(...extractClassesFromSource(source, file, opts.cwd));
|
|
493
633
|
tokens.push(...extractTokensFromSource(source, file, opts.cwd));
|
|
494
634
|
injects.push(...extractInjectsFromSource(source, file, opts.cwd));
|
|
635
|
+
pluginsAndAdapters.push(...extractPluginsAndAdaptersFromSource(source, file, opts.cwd));
|
|
636
|
+
augmentations.push(...extractAugmentationsFromSource(source, file, opts.cwd));
|
|
495
637
|
}
|
|
496
638
|
for (const [file, source] of sources) {
|
|
497
639
|
const classesInFile = classes.filter((c) => c.filePath === file);
|
|
@@ -504,15 +646,148 @@ async function scanProject(opts) {
|
|
|
504
646
|
tokens.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
505
647
|
injects.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
506
648
|
routes.sort((a, b) => a.controller.localeCompare(b.controller) || a.method.localeCompare(b.method));
|
|
649
|
+
pluginsAndAdapters.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
650
|
+
augmentations.sort((a, b) => a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
507
651
|
return {
|
|
508
652
|
classes,
|
|
509
653
|
routes,
|
|
510
654
|
tokens,
|
|
511
655
|
injects,
|
|
512
656
|
collisions: findCollisions(classes),
|
|
513
|
-
env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts")
|
|
657
|
+
env: await detectEnvFile(opts.cwd, opts.envFile ?? "src/env.ts"),
|
|
658
|
+
pluginsAndAdapters,
|
|
659
|
+
augmentations
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/typegen/asset-types.ts
|
|
664
|
+
/**
|
|
665
|
+
* Walks every `assetMap` entry's source directory + emits a typed
|
|
666
|
+
* `KickAssets` ambient augmentation (assets-plan.md PR 4). Generates
|
|
667
|
+
* `.kickjs/types/assets.d.ts` so adopters get autocomplete on
|
|
668
|
+
* `assets.<namespace>.<key>` and `@Asset('<namespace>/<key>')`.
|
|
669
|
+
*
|
|
670
|
+
* Pure module — no side effects beyond what the caller does with the
|
|
671
|
+
* returned content. Mirrors the shape of `renderPlugins` /
|
|
672
|
+
* `renderRegistry` in the generator so the typegen output stays
|
|
673
|
+
* consistent across surfaces.
|
|
674
|
+
*
|
|
675
|
+
* @module @forinda/kickjs-cli/typegen/asset-types
|
|
676
|
+
*/
|
|
677
|
+
function discoverAssets(assetMap, cwd) {
|
|
678
|
+
if (!assetMap) return {
|
|
679
|
+
entries: [],
|
|
680
|
+
count: 0
|
|
681
|
+
};
|
|
682
|
+
const seen = /* @__PURE__ */ new Map();
|
|
683
|
+
for (const [namespace, entry] of Object.entries(assetMap)) {
|
|
684
|
+
if (!entry || typeof entry.src !== "string") continue;
|
|
685
|
+
const srcAbs = resolve(cwd, entry.src);
|
|
686
|
+
if (!isDir(srcAbs)) continue;
|
|
687
|
+
const matches = globSync(entry.glob ?? "**/*", {
|
|
688
|
+
cwd: srcAbs,
|
|
689
|
+
nodir: true,
|
|
690
|
+
dot: false,
|
|
691
|
+
posix: true
|
|
692
|
+
});
|
|
693
|
+
matches.sort();
|
|
694
|
+
for (const rel of matches) {
|
|
695
|
+
const key = stripExt(rel);
|
|
696
|
+
const logical = `${namespace}/${key}`;
|
|
697
|
+
seen.set(logical, {
|
|
698
|
+
namespace,
|
|
699
|
+
key
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
entries: [...seen.values()],
|
|
705
|
+
count: seen.size
|
|
514
706
|
};
|
|
515
707
|
}
|
|
708
|
+
function renderAssetTypes(discovered) {
|
|
709
|
+
const HEADER = `/* eslint-disable */
|
|
710
|
+
// AUTO-GENERATED by \`kick typegen\`. DO NOT EDIT.
|
|
711
|
+
// Re-run with \`kick typegen\` or rely on \`kick dev\` to refresh.
|
|
712
|
+
`;
|
|
713
|
+
if (discovered.entries.length === 0) return `${HEADER}
|
|
714
|
+
declare module '@forinda/kickjs' {
|
|
715
|
+
/**
|
|
716
|
+
* Map of every typed asset discovered in the project's assetMap.
|
|
717
|
+
* (No assetMap entries discovered yet — declare with
|
|
718
|
+
* \`assetMap: { name: { src: 'src/...' } }\` in kick.config.ts.)
|
|
719
|
+
*/
|
|
720
|
+
interface KickAssets {}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export {}
|
|
724
|
+
`;
|
|
725
|
+
const tree = {};
|
|
726
|
+
for (const entry of discovered.entries) {
|
|
727
|
+
const path = `${entry.namespace}/${entry.key}`.split("/");
|
|
728
|
+
let node = tree;
|
|
729
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
730
|
+
const part = path[i];
|
|
731
|
+
const existing = node[part];
|
|
732
|
+
if (existing === LEAF) {
|
|
733
|
+
const promoted = {};
|
|
734
|
+
node[part] = promoted;
|
|
735
|
+
node = promoted;
|
|
736
|
+
} else {
|
|
737
|
+
if (!existing) node[part] = {};
|
|
738
|
+
node = node[part];
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const leaf = path[path.length - 1];
|
|
742
|
+
if (typeof node[leaf] === "object") continue;
|
|
743
|
+
node[leaf] = LEAF;
|
|
744
|
+
}
|
|
745
|
+
return `${HEADER}
|
|
746
|
+
declare module '@forinda/kickjs' {
|
|
747
|
+
/**
|
|
748
|
+
* Map of every typed asset discovered in the project's assetMap.
|
|
749
|
+
* Each leaf is a \`() => string\` thunk that returns the resolved
|
|
750
|
+
* absolute path for the file in the current run mode (dev → src,
|
|
751
|
+
* prod → dist).
|
|
752
|
+
*/
|
|
753
|
+
interface KickAssets {
|
|
754
|
+
${renderTree(tree, " ")}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export {}
|
|
759
|
+
`;
|
|
760
|
+
}
|
|
761
|
+
const LEAF = Symbol("asset-leaf");
|
|
762
|
+
function renderTree(node, indent) {
|
|
763
|
+
const keys = Object.keys(node).sort();
|
|
764
|
+
const lines = [];
|
|
765
|
+
for (const key of keys) {
|
|
766
|
+
const child = node[key];
|
|
767
|
+
const safeKey = isIdentifier(key) ? key : JSON.stringify(key);
|
|
768
|
+
if (child === LEAF) lines.push(`${indent}${safeKey}: () => string`);
|
|
769
|
+
else {
|
|
770
|
+
lines.push(`${indent}${safeKey}: {`);
|
|
771
|
+
lines.push(renderTree(child, `${indent} `));
|
|
772
|
+
lines.push(`${indent}}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return lines.join("\n");
|
|
776
|
+
}
|
|
777
|
+
function isIdentifier(str) {
|
|
778
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(str);
|
|
779
|
+
}
|
|
780
|
+
function isDir(path) {
|
|
781
|
+
try {
|
|
782
|
+
return statSync(path).isDirectory();
|
|
783
|
+
} catch {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function stripExt(path) {
|
|
788
|
+
const ext = extname(path);
|
|
789
|
+
return ext ? path.slice(0, -ext.length) : path;
|
|
790
|
+
}
|
|
516
791
|
//#endregion
|
|
517
792
|
//#region src/typegen/generator.ts
|
|
518
793
|
/**
|
|
@@ -649,12 +924,17 @@ function renderIndex(includeEnv) {
|
|
|
649
924
|
export type { ServiceToken } from './services'
|
|
650
925
|
export type { ModuleToken } from './modules'
|
|
651
926
|
|
|
652
|
-
// The registry, routes, and env augmentations are
|
|
653
|
-
// importing this file (or having it on
|
|
654
|
-
// \`container.resolve()\`,
|
|
655
|
-
//
|
|
927
|
+
// The registry, routes, plugins, assets, and env augmentations are
|
|
928
|
+
// loaded as side-effects — importing this file (or having it on
|
|
929
|
+
// tsconfig include) is enough for \`container.resolve()\`,
|
|
930
|
+
// \`Ctx<KickRoutes.UserController['getUser']>\`,
|
|
931
|
+
// \`dependsOn: ['TenantAdapter']\`, \`assets.mails.welcome()\`, and
|
|
932
|
+
// \`@Value('PORT')\` to resolve.
|
|
656
933
|
import './registry'
|
|
657
934
|
import './routes'
|
|
935
|
+
import './plugins'
|
|
936
|
+
import './augmentations'
|
|
937
|
+
import './assets'
|
|
658
938
|
${includeEnv ? "import './env'\n" : ""}`;
|
|
659
939
|
}
|
|
660
940
|
/**
|
|
@@ -849,9 +1129,90 @@ ${interfaces.join("\n")}
|
|
|
849
1129
|
export {}
|
|
850
1130
|
`;
|
|
851
1131
|
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Render the `KickJsPluginRegistry` augmentation. Each entry maps the
|
|
1134
|
+
* literal `name` field of a plugin/adapter to a marker type (the
|
|
1135
|
+
* registry value isn't load-bearing at runtime — `dependsOn` only cares
|
|
1136
|
+
* about `keyof`, so any non-`never` type works). We emit `'plugin'` /
|
|
1137
|
+
* `'adapter'` strings so DevTools can later read the registry to tell
|
|
1138
|
+
* the kinds apart without a second source of truth.
|
|
1139
|
+
*
|
|
1140
|
+
* When the project has no discoverable plugins/adapters, the augmentation
|
|
1141
|
+
* is intentionally empty rather than skipped so the `keyof` constraint
|
|
1142
|
+
* resolves to `never` (which is harmless — `dependsOn: []` still works).
|
|
1143
|
+
*/
|
|
1144
|
+
function renderPlugins(items) {
|
|
1145
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1146
|
+
for (const item of items) if (!byName.has(item.name)) byName.set(item.name, item);
|
|
1147
|
+
const entries = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((item) => ` '${item.name}': '${item.kind}'`).join("\n");
|
|
1148
|
+
return `${HEADER}
|
|
1149
|
+
declare module '@forinda/kickjs' {
|
|
1150
|
+
/**
|
|
1151
|
+
* Map of every plugin/adapter \`name\` discovered in the project. The
|
|
1152
|
+
* value type is the kind tag (\`'plugin'\` or \`'adapter'\`); the
|
|
1153
|
+
* \`keyof\` of this interface narrows \`dependsOn\` so misspelled deps
|
|
1154
|
+
* become compile errors instead of boot-time \`MissingMountDepError\`.
|
|
1155
|
+
*/
|
|
1156
|
+
interface KickJsPluginRegistry {
|
|
1157
|
+
${entries ? entries : " // (no plugins/adapters discovered yet — `defineAdapter`/`definePlugin` calls feed this)"}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export {}
|
|
1162
|
+
`;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Render the augmentation manifest — one block per `defineAugmentation`
|
|
1166
|
+
* call discovered in the project. The output is a `.d.ts` file that
|
|
1167
|
+
* does nothing at runtime but acts as in-IDE documentation: adopters
|
|
1168
|
+
* jumping into it see every interface their plugins offer for
|
|
1169
|
+
* augmentation, alongside any `description` / `example` the plugin
|
|
1170
|
+
* authors provided.
|
|
1171
|
+
*/
|
|
1172
|
+
function renderAugmentations(items) {
|
|
1173
|
+
if (items.length === 0) return `${HEADER}
|
|
1174
|
+
// No augmentations discovered.
|
|
1175
|
+
//
|
|
1176
|
+
// Plugins advertise augmentable interfaces via:
|
|
1177
|
+
//
|
|
1178
|
+
// import { defineAugmentation } from '@forinda/kickjs'
|
|
1179
|
+
// defineAugmentation('FeatureFlags', {
|
|
1180
|
+
// description: 'Feature flag shape consumed by FlagsPlugin',
|
|
1181
|
+
// example: '{ beta: boolean; rolloutPercentage: number }',
|
|
1182
|
+
// })
|
|
1183
|
+
//
|
|
1184
|
+
// See \`docs/guide/typegen.md#augmentations\` for the full pattern.
|
|
1185
|
+
export {}
|
|
1186
|
+
`;
|
|
1187
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1188
|
+
for (const item of items) if (!byName.has(item.name)) byName.set(item.name, item);
|
|
1189
|
+
const blocks = [];
|
|
1190
|
+
for (const item of [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1191
|
+
const docLines = [];
|
|
1192
|
+
if (item.description) docLines.push(` * ${item.description}`);
|
|
1193
|
+
if (item.example) docLines.push(` * @example`, ` * \`\`\`ts`, ` * ${item.example}`, ` * \`\`\``);
|
|
1194
|
+
docLines.push(` * @see ${item.relativePath}`);
|
|
1195
|
+
blocks.push([
|
|
1196
|
+
"/**",
|
|
1197
|
+
...docLines,
|
|
1198
|
+
" */",
|
|
1199
|
+
`export interface ${item.name}Augmentation {}`
|
|
1200
|
+
].join("\n"));
|
|
1201
|
+
}
|
|
1202
|
+
return `${HEADER}
|
|
1203
|
+
// Catalogue of augmentable interfaces in this project. The interfaces
|
|
1204
|
+
// below are documentation only — augment the source-of-truth interfaces
|
|
1205
|
+
// in your own \`d.ts\` files (the framework declares the actual types).
|
|
1206
|
+
|
|
1207
|
+
${blocks.join("\n\n")}
|
|
1208
|
+
`;
|
|
1209
|
+
}
|
|
852
1210
|
/** Write all generated `.d.ts` files to `outDir` */
|
|
853
1211
|
async function generateTypes(opts) {
|
|
854
|
-
const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null,
|
|
1212
|
+
const { classes, routes = [], tokens = [], injects = [], collisions = [], env = null, pluginsAndAdapters = [], augmentations = [], assets = {
|
|
1213
|
+
entries: [],
|
|
1214
|
+
count: 0
|
|
1215
|
+
}, outDir, allowDuplicates = false, schemaValidator = false } = opts;
|
|
855
1216
|
if (collisions.length > 0 && !allowDuplicates) throw new TokenCollisionError(collisions);
|
|
856
1217
|
await mkdir(outDir, { recursive: true });
|
|
857
1218
|
const registryFile = join(outDir, "registry.d.ts");
|
|
@@ -859,6 +1220,9 @@ async function generateTypes(opts) {
|
|
|
859
1220
|
const modulesFile = join(outDir, "modules.d.ts");
|
|
860
1221
|
const routesFile = join(outDir, "routes.ts");
|
|
861
1222
|
const envFile = join(outDir, "env.ts");
|
|
1223
|
+
const pluginsFile = join(outDir, "plugins.d.ts");
|
|
1224
|
+
const augmentationsFile = join(outDir, "augmentations.d.ts");
|
|
1225
|
+
const assetsFile = join(outDir, "assets.d.ts");
|
|
862
1226
|
const indexFile = join(outDir, "index.d.ts");
|
|
863
1227
|
const collidingNames = new Set(collisions.map((c) => c.className));
|
|
864
1228
|
const registryContent = renderRegistry(classes, registryFile, collidingNames);
|
|
@@ -875,17 +1239,26 @@ async function generateTypes(opts) {
|
|
|
875
1239
|
const modulesContent = renderUnion("ModuleToken", modules, "(no @Module classes discovered — `kick g module <name>` to add one)");
|
|
876
1240
|
const routesContent = renderRoutes(routes, routesFile, schemaValidator);
|
|
877
1241
|
const envContent = renderEnv(env, envFile);
|
|
1242
|
+
const pluginsContent = renderPlugins(pluginsAndAdapters);
|
|
1243
|
+
const augmentationsContent = renderAugmentations(augmentations);
|
|
1244
|
+
const assetsContent = renderAssetTypes(assets);
|
|
878
1245
|
const indexContent = renderIndex(envContent !== null);
|
|
879
1246
|
await writeFile(registryFile, registryContent, "utf-8");
|
|
880
1247
|
await writeFile(servicesFile, servicesContent, "utf-8");
|
|
881
1248
|
await writeFile(modulesFile, modulesContent, "utf-8");
|
|
882
1249
|
await writeFile(routesFile, routesContent, "utf-8");
|
|
1250
|
+
await writeFile(pluginsFile, pluginsContent, "utf-8");
|
|
1251
|
+
await writeFile(augmentationsFile, augmentationsContent, "utf-8");
|
|
1252
|
+
await writeFile(assetsFile, assetsContent, "utf-8");
|
|
883
1253
|
await writeFile(indexFile, indexContent, "utf-8");
|
|
884
1254
|
const written = [
|
|
885
1255
|
registryFile,
|
|
886
1256
|
servicesFile,
|
|
887
1257
|
modulesFile,
|
|
888
1258
|
routesFile,
|
|
1259
|
+
pluginsFile,
|
|
1260
|
+
augmentationsFile,
|
|
1261
|
+
assetsFile,
|
|
889
1262
|
indexFile
|
|
890
1263
|
];
|
|
891
1264
|
if (envContent) {
|
|
@@ -893,17 +1266,62 @@ async function generateTypes(opts) {
|
|
|
893
1266
|
written.push(envFile);
|
|
894
1267
|
}
|
|
895
1268
|
await writeFile(join(dirname(outDir), ".gitignore"), "# Auto-generated by kick typegen\n*\n", "utf-8");
|
|
1269
|
+
const uniquePluginNames = new Set(pluginsAndAdapters.map((p) => p.name)).size;
|
|
1270
|
+
const uniqueAugmentations = new Set(augmentations.map((a) => a.name)).size;
|
|
896
1271
|
return {
|
|
897
1272
|
registryEntries: classTokens.length,
|
|
898
1273
|
serviceTokens: new Set(allServices).size,
|
|
899
1274
|
moduleTokens: modules.length,
|
|
900
1275
|
routeEntries: routes.length,
|
|
1276
|
+
pluginEntries: uniquePluginNames,
|
|
1277
|
+
augmentationEntries: uniqueAugmentations,
|
|
1278
|
+
assetEntries: assets.count,
|
|
901
1279
|
envWritten: envContent !== null,
|
|
902
1280
|
written,
|
|
903
1281
|
resolvedCollisions: collisions.length
|
|
904
1282
|
};
|
|
905
1283
|
}
|
|
906
1284
|
//#endregion
|
|
1285
|
+
//#region src/typegen/token-conventions.ts
|
|
1286
|
+
/**
|
|
1287
|
+
* Regex for the §22.2 token shape. Breakdown:
|
|
1288
|
+
*
|
|
1289
|
+
* - `^(kick\/)?` — optional reserved framework prefix.
|
|
1290
|
+
* - `([a-z][\w-]*\/[A-Z]\w*)` — `<scope>/<PascalKey>`. Scope is
|
|
1291
|
+
* lowercase, key is PascalCase.
|
|
1292
|
+
* - `(\/.+)?` — optional `/suffix` for sub-flavours
|
|
1293
|
+
* (e.g. `mycorp/Cache/redis`).
|
|
1294
|
+
* - `(:[a-z][\w-]+(:[a-z][\w-]+)*)?` — optional `:instance` (and
|
|
1295
|
+
* further `:extra` colon-sections) for `.scoped()` shards.
|
|
1296
|
+
*/
|
|
1297
|
+
const TOKEN_CONVENTION_REGEX = /^(kick\/)?([a-z][\w-]*\/[A-Z]\w*)(\/.+)?(:[a-z][\w-]+(:[a-z][\w-]+)*)?$/;
|
|
1298
|
+
const LEGACY_PREFIX = "kickjs.";
|
|
1299
|
+
function validateTokenConventions(tokens) {
|
|
1300
|
+
const warnings = [];
|
|
1301
|
+
for (const token of tokens) {
|
|
1302
|
+
const name = token.name;
|
|
1303
|
+
if (name.startsWith(LEGACY_PREFIX)) continue;
|
|
1304
|
+
if (TOKEN_CONVENTION_REGEX.test(name)) continue;
|
|
1305
|
+
warnings.push({
|
|
1306
|
+
token: name,
|
|
1307
|
+
variable: token.variable,
|
|
1308
|
+
filePath: token.relativePath,
|
|
1309
|
+
reason: "does not match `<scope>/<PascalKey>[/<suffix>][:<instance>]`",
|
|
1310
|
+
suggestion: suggestRename(name)
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
return warnings;
|
|
1314
|
+
}
|
|
1315
|
+
function suggestRename(name) {
|
|
1316
|
+
if (/^[A-Z]\w*$/.test(name)) return `'<scope>/${name}' (e.g. 'mycorp/${name}')`;
|
|
1317
|
+
if (name.includes(".")) return `consider '<scope>/PascalKey' instead of dotted form`;
|
|
1318
|
+
const slashLower = /^([a-z][\w-]*)\/([a-z]\w*)$/.exec(name);
|
|
1319
|
+
if (slashLower) {
|
|
1320
|
+
const [, scope, key] = slashLower;
|
|
1321
|
+
return `'${scope}/${key.charAt(0).toUpperCase()}${key.slice(1)}'`;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
//#endregion
|
|
907
1325
|
//#region src/typegen/index.ts
|
|
908
1326
|
/**
|
|
909
1327
|
* Public entry point for the KickJS typegen module.
|
|
@@ -944,6 +1362,7 @@ async function runTypegen(opts = {}) {
|
|
|
944
1362
|
cwd,
|
|
945
1363
|
envFile: envFile === false ? void 0 : envFile
|
|
946
1364
|
});
|
|
1365
|
+
const assets = discoverAssets(opts.assetMap, cwd);
|
|
947
1366
|
const result = await generateTypes({
|
|
948
1367
|
classes: scan.classes,
|
|
949
1368
|
routes: scan.routes,
|
|
@@ -951,23 +1370,39 @@ async function runTypegen(opts = {}) {
|
|
|
951
1370
|
injects: scan.injects,
|
|
952
1371
|
collisions: scan.collisions,
|
|
953
1372
|
env: envFile === false ? null : scan.env,
|
|
1373
|
+
pluginsAndAdapters: scan.pluginsAndAdapters,
|
|
1374
|
+
augmentations: scan.augmentations,
|
|
1375
|
+
assets,
|
|
954
1376
|
outDir,
|
|
955
1377
|
allowDuplicates,
|
|
956
1378
|
schemaValidator
|
|
957
1379
|
});
|
|
1380
|
+
const tokenWarnings = validateTokenConventions(scan.tokens);
|
|
958
1381
|
const elapsed = Date.now() - start;
|
|
959
1382
|
if (!silent) {
|
|
960
1383
|
const where = outDir.replace(cwd + "/", "");
|
|
961
1384
|
const collisionNote = result.resolvedCollisions > 0 ? `, ${result.resolvedCollisions} collisions namespaced` : "";
|
|
962
1385
|
const envNote = result.envWritten ? ", env typed" : "";
|
|
963
|
-
|
|
1386
|
+
const pluginNote = result.pluginEntries > 0 ? `, ${result.pluginEntries} plugins/adapters` : "";
|
|
1387
|
+
const augNote = result.augmentationEntries > 0 ? `, ${result.augmentationEntries} augmentations` : "";
|
|
1388
|
+
const assetNote = result.assetEntries > 0 ? `, ${result.assetEntries} assets` : "";
|
|
1389
|
+
console.log(` kick typegen → ${result.serviceTokens} services, ${result.routeEntries} routes, ${result.moduleTokens} modules${pluginNote}${augNote}${assetNote}${envNote}${collisionNote} → ${where} (${elapsed}ms)`);
|
|
1390
|
+
if (tokenWarnings.length > 0) {
|
|
1391
|
+
console.warn(` kick typegen: ${tokenWarnings.length} token(s) don't match the §22.2 convention:`);
|
|
1392
|
+
for (const warning of tokenWarnings) {
|
|
1393
|
+
const variableNote = warning.variable ? ` [${warning.variable}]` : "";
|
|
1394
|
+
console.warn(` '${warning.token}' (${warning.filePath})${variableNote} — ${warning.reason}`);
|
|
1395
|
+
if (warning.suggestion) console.warn(` → suggestion: ${warning.suggestion}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
964
1398
|
}
|
|
965
1399
|
return {
|
|
966
1400
|
scan,
|
|
967
|
-
result
|
|
1401
|
+
result,
|
|
1402
|
+
tokenWarnings
|
|
968
1403
|
};
|
|
969
1404
|
}
|
|
970
1405
|
//#endregion
|
|
971
1406
|
export { runTypegen };
|
|
972
1407
|
|
|
973
|
-
//# sourceMappingURL=typegen-
|
|
1408
|
+
//# sourceMappingURL=typegen-vI1eqGLK.mjs.map
|