@chemx/starter-kit 26.9.16-41 → 26.9.17-309
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 +4 -0
- package/assets/chemx-starter-kit-logo.gif +0 -0
- package/blueprints/_view-template.scss +24 -0
- package/blueprints/atoms/a-button.vue +43 -0
- package/blueprints/molecule-capsule/_m-sample-card.scss +75 -0
- package/blueprints/molecule-capsule/index.ts +2 -0
- package/blueprints/molecule-capsule/m-chemx-badge/_m-chemx-badge.scss +76 -0
- package/blueprints/molecule-capsule/m-chemx-badge/index.ts +3 -0
- package/blueprints/molecule-capsule/m-chemx-badge/m-chemx-badge.spec.ts +74 -0
- package/blueprints/molecule-capsule/m-chemx-badge/m-chemx-badge.tsx +39 -0
- package/blueprints/molecule-capsule/m-chemx-badge/m-chemx-badge.vue +42 -0
- package/blueprints/molecule-capsule/m-chemx-badge/types.d.ts +12 -0
- package/blueprints/molecule-capsule/m-sample-card.spec.ts +74 -0
- package/blueprints/molecule-capsule/m-sample-card.tsx +72 -0
- package/blueprints/molecule-capsule/m-tab-button/_m-tab-button.scss +49 -0
- package/blueprints/molecule-capsule/m-tab-button/index.ts +3 -0
- package/blueprints/molecule-capsule/m-tab-button/m-tab-button.controller.ts +43 -0
- package/blueprints/molecule-capsule/m-tab-button/m-tab-button.spec.ts +40 -0
- package/blueprints/molecule-capsule/m-tab-button/m-tab-button.vue +38 -0
- package/blueprints/molecule-capsule/m-tab-button/types.d.ts +14 -0
- package/blueprints/molecule-capsule/types.d.ts +12 -0
- package/blueprints/shims-vue.d.ts +4 -0
- package/blueprints/view-template.tsx +20 -0
- package/blueprints/workflows/chemx-audit.yml +79 -0
- package/cli/audit/autofix.js +4 -5
- package/cli/audit/pattern-detector.js +90 -3
- package/cli/audit/pattern-detector.spec.js +36 -1
- package/cli/audit/prompts.js +10 -12
- package/cli/audit/roadmap.js +5 -20
- package/cli/audit/rules-predicates.js +3 -2
- package/cli/audit/rules.js +4 -0
- package/cli/audit-preflight-git.js +54 -0
- package/cli/audit-preflight-scope.js +87 -0
- package/cli/audit-preflight.d.ts +16 -0
- package/cli/audit-preflight.js +85 -0
- package/cli/audit-preflight.spec.js +115 -0
- package/cli/audit.js +47 -22
- package/cli/generator-templates/controller.js +36 -0
- package/cli/generator-templates/hooks.js +60 -0
- package/cli/generator-templates/naming.js +14 -0
- package/cli/generator-templates/react.js +63 -0
- package/cli/generator-templates/specs.js +38 -0
- package/cli/generator-templates/styles.js +64 -0
- package/cli/generator-templates/svelte.js +66 -0
- package/cli/generator-templates/types.js +29 -0
- package/cli/generator-templates/views.js +76 -0
- package/cli/generator-templates/vue.js +62 -0
- package/cli/generator-templates.d.ts +35 -0
- package/cli/generator-templates.js +28 -480
- package/cli/generator-templates.spec.js +108 -0
- package/cli/generator.js +2 -2
- package/cli/generator.spec.js +61 -1
- package/cli/help.js +9 -0
- package/cli/index.js +44 -7
- package/cli/installer-templates.js +67 -13
- package/cli/license.js +22 -1
- package/cli/mcp/installer.js +31 -21
- package/cli/mcp/resources.js +114 -7
- package/cli/mcp/server.spec.js +34 -0
- package/cli/mcp/tools.js +14 -4
- package/cli/postinstall.js +27 -0
- package/cli/project-detector.js +20 -2
- package/cli/project-detector.spec.js +19 -0
- package/cli/reader.js +18 -4
- package/cli/scaffold.js +19 -3
- package/cli/terminal.js +36 -1
- package/docs/CHANGELOG.md +10 -0
- package/package.json +8 -5
- package/scripts/postinstall.mjs +27 -0
- package/scripts/pre-commit.sh +135 -0
- package/scripts/publish-both.mjs +108 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface PreflightResult {
|
|
2
|
+
readonly targetDir: string;
|
|
3
|
+
readonly fast: boolean;
|
|
4
|
+
readonly fileList: readonly string[] | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PreflightOptions {
|
|
8
|
+
readonly customDir?: string | null;
|
|
9
|
+
readonly defaultDir?: string;
|
|
10
|
+
readonly cwd?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export declare function detectCandidateDirectories(cwd?: string): string[];
|
|
14
|
+
export declare function resolveGitAuditScope(cwd?: string): { ok: boolean; files: string[] };
|
|
15
|
+
export declare function getGitChangedFiles(cwd?: string): string[];
|
|
16
|
+
export declare function runAuditPreflight(rawArgs: readonly string[], options?: PreflightOptions): Promise<PreflightResult>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { hasGum, gumChoose, promptQuestion } from './terminal.js';
|
|
2
|
+
import { promptAuditScope } from './audit-preflight-scope.js';
|
|
3
|
+
import { isNonInteractiveSession } from './audit/rules-predicates.js';
|
|
4
|
+
import { resolveGitAuditScope } from './audit-preflight-git.js';
|
|
5
|
+
|
|
6
|
+
export { detectCandidateDirectories } from './audit-preflight-scope.js';
|
|
7
|
+
export { resolveGitAuditScope, getGitChangedFiles } from './audit-preflight-git.js';
|
|
8
|
+
|
|
9
|
+
const DEPTH_OPTIONS = [
|
|
10
|
+
'🔬 Full AST Audit (Deep molecular analysis, AST visitors, slop, patterns)',
|
|
11
|
+
'⚡ Fast Scan (Instant line-budget, token burn, and file metrics)'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const promptAuditDepth = async (rawArgs) => {
|
|
15
|
+
const hasFastFlag = rawArgs.includes('--fast') || rawArgs.includes('--quick');
|
|
16
|
+
const hasFullFlag = rawArgs.includes('--full') || rawArgs.includes('--deep');
|
|
17
|
+
|
|
18
|
+
if (hasFastFlag) return true;
|
|
19
|
+
if (hasFullFlag) return false;
|
|
20
|
+
|
|
21
|
+
let depthChoice = '';
|
|
22
|
+
if (hasGum()) {
|
|
23
|
+
depthChoice = gumChoose(DEPTH_OPTIONS, 'Select Audit Depth:');
|
|
24
|
+
} else {
|
|
25
|
+
process.stdout.write('\n' + DEPTH_OPTIONS.map((opt, i) => ` ${i + 1}. ${opt}`).join('\n') + '\n\n');
|
|
26
|
+
const ans = await promptQuestion(`Select audit depth [1-${DEPTH_OPTIONS.length}]: `);
|
|
27
|
+
const index = parseInt(ans, 10) - 1;
|
|
28
|
+
depthChoice = DEPTH_OPTIONS[index] || '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isCancelled = !depthChoice;
|
|
32
|
+
if (isCancelled) return null;
|
|
33
|
+
|
|
34
|
+
return depthChoice.includes('Fast');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const runAuditPreflight = async (rawArgs, options = {}) => {
|
|
38
|
+
const { customDir = null, defaultDir = 'src', cwd = process.cwd() } = options;
|
|
39
|
+
|
|
40
|
+
const isNonInteractive = isNonInteractiveSession(rawArgs);
|
|
41
|
+
const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
42
|
+
const hasYesFlag = rawArgs.includes('-y') || rawArgs.includes('--yes');
|
|
43
|
+
const isInteractive = !isNonInteractive && hasTty && !hasYesFlag;
|
|
44
|
+
|
|
45
|
+
const hasExplicitDir = Boolean(customDir) || rawArgs.some((arg) => arg.startsWith('--dir='));
|
|
46
|
+
const hasExplicitGit = rawArgs.includes('--git') || rawArgs.includes('--changed');
|
|
47
|
+
const hasExplicitFast = rawArgs.includes('--fast') || rawArgs.includes('--quick');
|
|
48
|
+
const hasExplicitFull = rawArgs.includes('--full') || rawArgs.includes('--deep');
|
|
49
|
+
const isFullySpecified = (hasExplicitDir || hasExplicitGit) && (hasExplicitFast || hasExplicitFull);
|
|
50
|
+
|
|
51
|
+
if (!isInteractive || isFullySpecified) {
|
|
52
|
+
const fast = hasExplicitFast;
|
|
53
|
+
const fileList = hasExplicitGit ? resolveGitAuditScope(cwd).files : null;
|
|
54
|
+
return { targetDir: customDir || defaultDir, fast, fileList };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.stdout.write('\n\x1b[1m\x1b[38;5;45m⚡ Chemical X Protocol\x1b[0m \x1b[38;5;220mInteractive Pre-Flight\x1b[0m\n\n');
|
|
58
|
+
|
|
59
|
+
let targetDir = customDir || defaultDir;
|
|
60
|
+
let fileList = null;
|
|
61
|
+
|
|
62
|
+
const needsScopePrompt = !hasExplicitDir && !hasExplicitGit;
|
|
63
|
+
if (needsScopePrompt) {
|
|
64
|
+
const scopeResult = await promptAuditScope(defaultDir, rawArgs, cwd);
|
|
65
|
+
if (!scopeResult) {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
targetDir = scopeResult.targetDir;
|
|
69
|
+
fileList = scopeResult.fileList;
|
|
70
|
+
} else if (hasExplicitGit) {
|
|
71
|
+
fileList = resolveGitAuditScope(cwd).files;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const depthResult = await promptAuditDepth(rawArgs);
|
|
75
|
+
if (depthResult === null) {
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
const fast = depthResult;
|
|
79
|
+
|
|
80
|
+
const modeLabel = fast ? 'Fast line-budget scan' : 'Full molecular AST audit';
|
|
81
|
+
const targetLabel = fileList ? `${fileList.length} git modified file(s)` : targetDir;
|
|
82
|
+
process.stdout.write(`\n\x1b[36m⚡ Starting ${modeLabel} for ${targetLabel}...\x1b[0m\n\n`);
|
|
83
|
+
|
|
84
|
+
return { targetDir, fast, fileList };
|
|
85
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { isSourceFilePath, parseGitStatusOutput } from './audit-preflight-git.js';
|
|
4
|
+
import { detectCandidateDirectories } from './audit-preflight-scope.js';
|
|
5
|
+
import { runAuditPreflight } from './audit-preflight.js';
|
|
6
|
+
import { scanTree, runAudit } from './audit.js';
|
|
7
|
+
import { auditCode } from './audit/rules.js';
|
|
8
|
+
|
|
9
|
+
test('isSourceFilePath: correctly validates source files and excludes tests', () => {
|
|
10
|
+
assert.strictEqual(isSourceFilePath('src/app.tsx'), true);
|
|
11
|
+
assert.strictEqual(isSourceFilePath('cli/index.js'), true);
|
|
12
|
+
assert.strictEqual(isSourceFilePath('components/card.vue'), true);
|
|
13
|
+
assert.strictEqual(isSourceFilePath('ui/button.svelte'), true);
|
|
14
|
+
assert.strictEqual(isSourceFilePath('src/app.test.tsx'), false);
|
|
15
|
+
assert.strictEqual(isSourceFilePath('src/app.spec.js'), false);
|
|
16
|
+
assert.strictEqual(isSourceFilePath('dist/bundle.min.js'), false);
|
|
17
|
+
assert.strictEqual(isSourceFilePath('types/index.d.ts'), false);
|
|
18
|
+
assert.strictEqual(isSourceFilePath('README.md'), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parseGitStatusOutput: extracts clean file paths from git porcelain output', () => {
|
|
22
|
+
const output = [
|
|
23
|
+
' M src/components/card.tsx',
|
|
24
|
+
'A cli/new-command.js',
|
|
25
|
+
'?? scripts/scratch.ts',
|
|
26
|
+
' D old-file.js',
|
|
27
|
+
'"quoted/path/with space.ts"'
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
const parsed = parseGitStatusOutput(output);
|
|
31
|
+
assert.strictEqual(parsed.length, 5);
|
|
32
|
+
assert.strictEqual(parsed[0], 'src/components/card.tsx');
|
|
33
|
+
assert.strictEqual(parsed[1], 'cli/new-command.js');
|
|
34
|
+
assert.strictEqual(parsed[2], 'scripts/scratch.ts');
|
|
35
|
+
assert.strictEqual(parsed[3], 'old-file.js');
|
|
36
|
+
assert.strictEqual(parsed[4], 'quoted/path/with space.ts');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('detectCandidateDirectories: identifies existing directory targets', () => {
|
|
40
|
+
const detected = detectCandidateDirectories(process.cwd());
|
|
41
|
+
assert.ok(Array.isArray(detected));
|
|
42
|
+
assert.ok(detected.includes('cli'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('runAuditPreflight: non-interactive mode resolves immediately without questions', async () => {
|
|
46
|
+
const result = await runAuditPreflight(['--ci'], {
|
|
47
|
+
defaultDir: 'cli',
|
|
48
|
+
cwd: process.cwd()
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
52
|
+
assert.strictEqual(result.fast, false);
|
|
53
|
+
assert.strictEqual(result.fileList, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('runAuditPreflight: explicit fast and customDir flags bypass questions', async () => {
|
|
57
|
+
const result = await runAuditPreflight(['--fast', '--dir=cli'], {
|
|
58
|
+
customDir: 'cli',
|
|
59
|
+
defaultDir: 'src',
|
|
60
|
+
cwd: process.cwd()
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
64
|
+
assert.strictEqual(result.fast, true);
|
|
65
|
+
assert.strictEqual(result.fileList, null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('runAuditPreflight: yes flag accepts defaults immediately', async () => {
|
|
69
|
+
const result = await runAuditPreflight(['-y'], {
|
|
70
|
+
defaultDir: 'cli',
|
|
71
|
+
cwd: process.cwd()
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
75
|
+
assert.strictEqual(result.fast, false);
|
|
76
|
+
assert.strictEqual(result.fileList, null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('auditCode: fast mode executes line checks and bypasses AST parser', () => {
|
|
80
|
+
const longFile = 'const a = 1;\n'.repeat(600);
|
|
81
|
+
const violations = auditCode(longFile, 'src/monolith.ts', 'src/monolith.ts', { fast: true });
|
|
82
|
+
|
|
83
|
+
assert.ok(violations.length > 0);
|
|
84
|
+
const hasLineBudget = violations.some((v) => v.rule === 'LINE_BUDGET_FILE');
|
|
85
|
+
assert.strictEqual(hasLineBudget, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('scanTree: fileList restricts scanning strictly to specified files', () => {
|
|
89
|
+
const result = scanTree('cli', process.cwd(), {
|
|
90
|
+
fileList: ['cli/installer-templates.js']
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.strictEqual(result.fileStats.length, 1);
|
|
94
|
+
assert.strictEqual(result.fileStats[0].relativePath, 'cli/installer-templates.js');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('runAudit: fast mode returns valid health metrics with zero AST traversal', () => {
|
|
98
|
+
const report = runAudit('cli', { fast: true });
|
|
99
|
+
|
|
100
|
+
assert.ok(report.scannedFiles > 0);
|
|
101
|
+
assert.ok(report.health);
|
|
102
|
+
assert.ok(typeof report.health.score === 'number');
|
|
103
|
+
assert.ok(typeof report.health.grade === 'string');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('runAuditPreflight: headless flag resolves immediately without interactive prompts', async () => {
|
|
107
|
+
const result = await runAuditPreflight(['--headless'], {
|
|
108
|
+
defaultDir: 'cli',
|
|
109
|
+
cwd: process.cwd()
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.strictEqual(result.targetDir, 'cli');
|
|
113
|
+
assert.strictEqual(result.fast, false);
|
|
114
|
+
assert.strictEqual(result.fileList, null);
|
|
115
|
+
});
|
package/cli/audit.js
CHANGED
|
@@ -97,11 +97,48 @@ export const auditFile = (filePath, relativePath) => {
|
|
|
97
97
|
return auditCode(content, filePath, relativePath);
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
const auditFileEntry = (fullPath, relPath, scanOptions) => {
|
|
101
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
102
|
+
const lines = content.split('\n');
|
|
103
|
+
const baseName = path.basename(fullPath);
|
|
104
|
+
const isMolecule = relPath.includes('molecules') || relPath.includes('/m-') || baseName.startsWith('m-');
|
|
105
|
+
|
|
106
|
+
const fileStat = {
|
|
107
|
+
fullPath,
|
|
108
|
+
relativePath: relPath,
|
|
109
|
+
lineCount: lines.length,
|
|
110
|
+
charCount: content.length,
|
|
111
|
+
isMolecule
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const hookMatches = content.match(/\buse[A-Z0-9]\w*\b/g);
|
|
115
|
+
const hookCount = hookMatches ? hookMatches.length : 0;
|
|
116
|
+
const fileViolations = auditCode(content, fullPath, relPath, {
|
|
117
|
+
patternRegistry: scanOptions.patternRegistry,
|
|
118
|
+
fast: scanOptions.fast
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return { fileStat, hookCount, fileViolations };
|
|
122
|
+
};
|
|
123
|
+
|
|
100
124
|
export const scanTree = (targetDir, baseDir, scanOptions = {}) => {
|
|
101
125
|
let violations = [];
|
|
102
126
|
let fileStats = [];
|
|
103
127
|
let totalHooks = 0;
|
|
104
|
-
|
|
128
|
+
|
|
129
|
+
if (scanOptions.fileList && scanOptions.fileList.length > 0) {
|
|
130
|
+
for (const item of scanOptions.fileList) {
|
|
131
|
+
const fullPath = path.isAbsolute(item) ? item : path.resolve(baseDir, item);
|
|
132
|
+
const relPath = path.relative(baseDir, fullPath);
|
|
133
|
+
if (fs.existsSync(fullPath) && isSourceFile(path.basename(fullPath))) {
|
|
134
|
+
const result = auditFileEntry(fullPath, relPath, scanOptions);
|
|
135
|
+
violations = violations.concat(result.fileViolations);
|
|
136
|
+
fileStats.push(result.fileStat);
|
|
137
|
+
totalHooks += result.hookCount;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { violations, fileStats, totalHooks };
|
|
141
|
+
}
|
|
105
142
|
|
|
106
143
|
if (!fs.existsSync(targetDir)) {
|
|
107
144
|
return { violations, fileStats, totalHooks };
|
|
@@ -120,26 +157,10 @@ export const scanTree = (targetDir, baseDir, scanOptions = {}) => {
|
|
|
120
157
|
totalHooks += sub.totalHooks;
|
|
121
158
|
}
|
|
122
159
|
} else if (isSourceFile(entry.name)) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
fileStats.push({
|
|
128
|
-
fullPath,
|
|
129
|
-
relativePath: relPath,
|
|
130
|
-
lineCount: lines.length,
|
|
131
|
-
charCount: content.length,
|
|
132
|
-
isMolecule
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
// Count hook declarations / invocations for metric
|
|
136
|
-
const hookMatches = content.match(/\buse[A-Z0-9]\w*\b/g);
|
|
137
|
-
if (hookMatches) {
|
|
138
|
-
totalHooks += hookMatches.length;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const fileViolations = auditCode(content, fullPath, relPath, { patternRegistry });
|
|
142
|
-
violations = violations.concat(fileViolations);
|
|
160
|
+
const result = auditFileEntry(fullPath, relPath, scanOptions);
|
|
161
|
+
violations = violations.concat(result.fileViolations);
|
|
162
|
+
fileStats.push(result.fileStat);
|
|
163
|
+
totalHooks += result.hookCount;
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
|
|
@@ -155,7 +176,11 @@ export const runAudit = (targetDir = 'src', options = {}) => {
|
|
|
155
176
|
const cwd = process.cwd();
|
|
156
177
|
const absoluteTarget = path.resolve(cwd, targetDir);
|
|
157
178
|
const patternRegistry = createPatternRegistry();
|
|
158
|
-
const { violations, fileStats, totalHooks } = scanTree(absoluteTarget, cwd, {
|
|
179
|
+
const { violations, fileStats, totalHooks } = scanTree(absoluteTarget, cwd, {
|
|
180
|
+
patternRegistry,
|
|
181
|
+
fast: Boolean(options.fast),
|
|
182
|
+
fileList: options.fileList || null
|
|
183
|
+
});
|
|
159
184
|
|
|
160
185
|
const scannedFiles = fileStats.length;
|
|
161
186
|
const totalLoc = fileStats.reduce((acc, f) => acc + f.lineCount, 0);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const buildController = (name, pascalName) => `import { useState, useMemo } from 'react';
|
|
2
|
+
import type { ${pascalName}State, ${pascalName}Descriptor } from './types';
|
|
3
|
+
|
|
4
|
+
interface ControllerOptions {
|
|
5
|
+
readonly initialState?: ${pascalName}State;
|
|
6
|
+
readonly onAction?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const use${pascalName}Controller = (options: ControllerOptions = {}) => {
|
|
10
|
+
const [state, setState] = useState<${pascalName}State>(
|
|
11
|
+
options.initialState || { status: 'idle' }
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const isIdle = state.status === 'idle';
|
|
15
|
+
const isPending = state.status === 'loading';
|
|
16
|
+
const canProceed = isIdle && !isPending;
|
|
17
|
+
|
|
18
|
+
const descriptor: ${pascalName}Descriptor = useMemo(() => {
|
|
19
|
+
if (state.status === 'loading') {
|
|
20
|
+
return { text: 'Loading...', className: '${name}__badge ${name}__badge--pending' };
|
|
21
|
+
}
|
|
22
|
+
if (state.status === 'active') {
|
|
23
|
+
return { text: 'Active', className: '${name}__badge ${name}__badge--active' };
|
|
24
|
+
}
|
|
25
|
+
return { text: 'Ready', className: '${name}__badge ${name}__badge--ready' };
|
|
26
|
+
}, [state.status]);
|
|
27
|
+
|
|
28
|
+
const handleAction = () => {
|
|
29
|
+
if (!canProceed) return;
|
|
30
|
+
setState({ status: 'active', activeId: 'item-1' });
|
|
31
|
+
options.onAction?.();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return { state, canProceed, descriptor, handleAction };
|
|
35
|
+
};
|
|
36
|
+
`;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const buildHook = (name, camelName, pascalName) => `import { useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import type { ${pascalName}HookOptions, ${pascalName}HookReturn } from './types';
|
|
3
|
+
|
|
4
|
+
export const ${camelName} = (options: ${pascalName}HookOptions = {}): ${pascalName}HookReturn => {
|
|
5
|
+
const [data, setData] = useState<string | null>(null);
|
|
6
|
+
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
7
|
+
const [error, setError] = useState<string | null>(null);
|
|
8
|
+
|
|
9
|
+
const execute = useCallback(async () => {
|
|
10
|
+
setIsLoading(true);
|
|
11
|
+
setError(null);
|
|
12
|
+
try {
|
|
13
|
+
setData('ready');
|
|
14
|
+
options.onSuccess?.();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
+
setError(message);
|
|
18
|
+
} finally {
|
|
19
|
+
setIsLoading(false);
|
|
20
|
+
}
|
|
21
|
+
}, [options]);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (options.immediate) {
|
|
25
|
+
execute();
|
|
26
|
+
}
|
|
27
|
+
}, [options.immediate, execute]);
|
|
28
|
+
|
|
29
|
+
return { data, isLoading, error, execute };
|
|
30
|
+
};
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
export const buildHookOptionsType = (pascalName) => `export interface ${pascalName}HookOptions {
|
|
34
|
+
readonly immediate?: boolean;
|
|
35
|
+
readonly onSuccess?: () => void;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export const buildHookReturnType = (pascalName) => `export interface ${pascalName}HookReturn {
|
|
40
|
+
readonly data: string | null;
|
|
41
|
+
readonly isLoading: boolean;
|
|
42
|
+
readonly error: string | null;
|
|
43
|
+
readonly execute: () => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export const buildHookIndex = (name, camelName) => `export { ${camelName} } from './${name}';
|
|
48
|
+
export type * from './types';
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
export const buildHookSpec = (name, camelName) => `import { describe, it } from 'node:test';
|
|
52
|
+
import assert from 'node:assert/strict';
|
|
53
|
+
import { ${camelName} } from './${name}.ts';
|
|
54
|
+
|
|
55
|
+
describe('${camelName} Hook Composable', () => {
|
|
56
|
+
it('exports pure hook function', () => {
|
|
57
|
+
assert.equal(typeof ${camelName}, 'function');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
`;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const toPascalCase = (str) =>
|
|
2
|
+
str
|
|
3
|
+
.replace(/^(?:[a-z]|use|v)-/, '')
|
|
4
|
+
.split('-')
|
|
5
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
6
|
+
.join('');
|
|
7
|
+
|
|
8
|
+
export const toCamelCase = (str) => {
|
|
9
|
+
const p = toPascalCase(str);
|
|
10
|
+
if (str.startsWith('use-') || str.startsWith('use')) {
|
|
11
|
+
return `use${p}`;
|
|
12
|
+
}
|
|
13
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
14
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const buildReactComponent = (name, pascalName, options = {}) => {
|
|
2
|
+
const atomsPackage = options.atomsPackage || null;
|
|
3
|
+
const hasController = options.hasController !== false;
|
|
4
|
+
const atomImport = atomsPackage ? `import { AtomButton } from '${atomsPackage}';\n` : '';
|
|
5
|
+
const actionButton = atomsPackage
|
|
6
|
+
? ` <AtomButton
|
|
7
|
+
disabled={!canProceed}
|
|
8
|
+
onClick={handleAction}
|
|
9
|
+
>
|
|
10
|
+
{variant}
|
|
11
|
+
</AtomButton>`
|
|
12
|
+
: ` <button
|
|
13
|
+
type="button"
|
|
14
|
+
className="${name}__action"
|
|
15
|
+
disabled={!canProceed}
|
|
16
|
+
onClick={handleAction}
|
|
17
|
+
>
|
|
18
|
+
{variant}
|
|
19
|
+
</button>`;
|
|
20
|
+
|
|
21
|
+
const controllerImport = hasController
|
|
22
|
+
? `import { use${pascalName}Controller } from './${name}.controller';\n`
|
|
23
|
+
: '';
|
|
24
|
+
|
|
25
|
+
const controllerLogic = hasController
|
|
26
|
+
? ` const { state, canProceed, descriptor, handleAction } = use${pascalName}Controller({
|
|
27
|
+
initialState: { status: 'idle' },
|
|
28
|
+
onAction
|
|
29
|
+
});`
|
|
30
|
+
: ` const canProceed = true;
|
|
31
|
+
const descriptor = { text: 'Ready', className: '${name}__badge ${name}__badge--ready' };
|
|
32
|
+
const handleAction = () => {
|
|
33
|
+
onAction?.();
|
|
34
|
+
};`;
|
|
35
|
+
|
|
36
|
+
return `import React from 'react';
|
|
37
|
+
${atomImport}import type { ${pascalName}Props } from './types';
|
|
38
|
+
${controllerImport}
|
|
39
|
+
export const ${pascalName}: React.FC<${pascalName}Props> = ({
|
|
40
|
+
title,
|
|
41
|
+
subtitle,
|
|
42
|
+
variant = 'standard',
|
|
43
|
+
onAction
|
|
44
|
+
}) => {
|
|
45
|
+
${controllerLogic}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="${name}">
|
|
49
|
+
<div className="${name}__header">
|
|
50
|
+
<h3 className="${name}__title">{title}</h3>
|
|
51
|
+
{subtitle && <p className="${name}__subtitle">{subtitle}</p>}
|
|
52
|
+
<span className={descriptor.className}>{descriptor.text}</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="${name}__body">
|
|
55
|
+
${actionButton}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default ${pascalName};
|
|
62
|
+
`;
|
|
63
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const buildComponentSpec = (name, pascalName, hasController = true) => {
|
|
2
|
+
if (hasController) {
|
|
3
|
+
return `import { describe, it } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { use${pascalName}Controller } from './${name}.controller.ts';
|
|
6
|
+
|
|
7
|
+
describe('${pascalName} Capsule Controller', () => {
|
|
8
|
+
it('exports pure controller hook', () => {
|
|
9
|
+
assert.equal(typeof use${pascalName}Controller, 'function');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return `import { describe, it } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
|
|
18
|
+
describe('${pascalName} Atom Foundation', () => {
|
|
19
|
+
it('defines foundational UI atom contract', () => {
|
|
20
|
+
assert.ok(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const buildIndex = (name, pascalName, ext, hasController = true) => {
|
|
27
|
+
const compExport = ext === 'vue' || ext === 'svelte'
|
|
28
|
+
? `export { default as ${pascalName} } from './${name}.${ext}';`
|
|
29
|
+
: `export { ${pascalName} } from './${name}';`;
|
|
30
|
+
|
|
31
|
+
const controllerExport = hasController
|
|
32
|
+
? `export { use${pascalName}Controller } from './${name}.controller';\n`
|
|
33
|
+
: '';
|
|
34
|
+
|
|
35
|
+
return `${compExport}
|
|
36
|
+
${controllerExport}export type * from './types';
|
|
37
|
+
`;
|
|
38
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const buildScss = (name) => `.${name} {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
padding: 16px;
|
|
5
|
+
background: rgba(19, 30, 58, 0.7);
|
|
6
|
+
backdrop-filter: blur(12px);
|
|
7
|
+
border: 1px solid rgba(98, 201, 255, 0.15);
|
|
8
|
+
border-radius: 8px;
|
|
9
|
+
|
|
10
|
+
&__header {
|
|
11
|
+
display: flex;
|
|
12
|
+
justify-content: space-between;
|
|
13
|
+
align-items: center;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&__title {
|
|
17
|
+
margin: 0;
|
|
18
|
+
font-size: 16px;
|
|
19
|
+
color: #ffffff;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&__subtitle {
|
|
23
|
+
margin: 4px 0 0;
|
|
24
|
+
font-size: 12px;
|
|
25
|
+
color: #94a3b8;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
&__badge {
|
|
29
|
+
font-size: 11px;
|
|
30
|
+
padding: 2px 8px;
|
|
31
|
+
border-radius: 4px;
|
|
32
|
+
background: #0b1329;
|
|
33
|
+
|
|
34
|
+
&--ready { color: #62c9ff; }
|
|
35
|
+
&--active { color: #4ade80; }
|
|
36
|
+
&--pending { color: #facc15; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&__body {
|
|
40
|
+
margin-top: 14px;
|
|
41
|
+
display: flex;
|
|
42
|
+
justify-content: flex-end;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&__action {
|
|
46
|
+
padding: 6px 14px;
|
|
47
|
+
border-radius: 4px;
|
|
48
|
+
background: #1e293b;
|
|
49
|
+
border: 1px solid #334155;
|
|
50
|
+
color: #ffffff;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
|
|
53
|
+
&:hover:not(:disabled) {
|
|
54
|
+
background: #334155;
|
|
55
|
+
border-color: #62c9ff;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&:disabled {
|
|
59
|
+
opacity: 0.5;
|
|
60
|
+
cursor: not-allowed;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const buildSvelteComponent = (name, pascalName, options = {}) => {
|
|
2
|
+
const atomsPackage = options.atomsPackage || null;
|
|
3
|
+
const hasController = options.hasController !== false;
|
|
4
|
+
const atomImport = atomsPackage ? `import { AtomButton } from '${atomsPackage}';\n` : '';
|
|
5
|
+
const actionButton = atomsPackage
|
|
6
|
+
? ` <AtomButton
|
|
7
|
+
disabled={!controller.canProceed}
|
|
8
|
+
onclick={controller.handleAction}
|
|
9
|
+
>
|
|
10
|
+
{variant}
|
|
11
|
+
</AtomButton>`
|
|
12
|
+
: ` <button
|
|
13
|
+
type="button"
|
|
14
|
+
class="${name}__action"
|
|
15
|
+
disabled={!controller.canProceed}
|
|
16
|
+
onclick={controller.handleAction}
|
|
17
|
+
>
|
|
18
|
+
{variant}
|
|
19
|
+
</button>`;
|
|
20
|
+
|
|
21
|
+
const controllerImport = hasController
|
|
22
|
+
? `import { create${pascalName}Controller } from './${name}.controller';\n`
|
|
23
|
+
: '';
|
|
24
|
+
|
|
25
|
+
const controllerLogic = hasController
|
|
26
|
+
? `const controller = create${pascalName}Controller({
|
|
27
|
+
initialState: { status: 'idle' },
|
|
28
|
+
onAction
|
|
29
|
+
});`
|
|
30
|
+
: `const controller = {
|
|
31
|
+
canProceed: true,
|
|
32
|
+
descriptor: { text: 'Ready', className: '${name}__badge ${name}__badge--ready' },
|
|
33
|
+
handleAction: () => onAction?.()
|
|
34
|
+
};`;
|
|
35
|
+
|
|
36
|
+
return `<script lang="ts">
|
|
37
|
+
import type { ${pascalName}Props } from './types';
|
|
38
|
+
${atomImport}${controllerImport}
|
|
39
|
+
const {
|
|
40
|
+
title,
|
|
41
|
+
subtitle = '',
|
|
42
|
+
variant = 'standard',
|
|
43
|
+
onAction
|
|
44
|
+
}: ${pascalName}Props = $props();
|
|
45
|
+
|
|
46
|
+
${controllerLogic}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<div class="${name}">
|
|
50
|
+
<div class="${name}__header">
|
|
51
|
+
<h3 class="${name}__title">{title}</h3>
|
|
52
|
+
{#if subtitle}
|
|
53
|
+
<p class="${name}__subtitle">{subtitle}</p>
|
|
54
|
+
{/if}
|
|
55
|
+
<span class={controller.descriptor.className}>{controller.descriptor.text}</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="${name}__body">
|
|
58
|
+
${actionButton}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<style lang="scss">
|
|
63
|
+
@use './_${name}.scss';
|
|
64
|
+
</style>
|
|
65
|
+
`;
|
|
66
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const buildPropsType = (name, pascalName) => `export interface ${pascalName}Props {
|
|
2
|
+
readonly title: string;
|
|
3
|
+
readonly subtitle?: string;
|
|
4
|
+
readonly variant?: 'standard' | 'highlight';
|
|
5
|
+
readonly onAction?: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ${pascalName}Emits {
|
|
9
|
+
(e: 'action', title: string): void;
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
export const buildStateType = (name, pascalName) => `export type ${pascalName}State =
|
|
14
|
+
| { readonly status: 'idle' }
|
|
15
|
+
| { readonly status: 'loading'; readonly progress: number }
|
|
16
|
+
| { readonly status: 'active'; readonly activeId: string }
|
|
17
|
+
| { readonly status: 'fault'; readonly faultMessage: string };
|
|
18
|
+
|
|
19
|
+
export interface ${pascalName}Descriptor {
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly className: string;
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export const buildTypesIndex = (fileNames = ['props', 'state']) =>
|
|
26
|
+
fileNames.map((f) => `export type * from './${f}.d.ts';`).join('\n') + '\n';
|
|
27
|
+
|
|
28
|
+
export const buildTypes = (name, pascalName) => `${buildStateType(name, pascalName)}
|
|
29
|
+
${buildPropsType(name, pascalName)}`;
|