@chemx/starter-kit 26.9.21-8 → 26.9.24-393
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/cli/audit/ast-visitors.js +10 -17
- package/cli/audit/autofix.js +17 -2
- package/cli/audit/clone-detector.js +64 -0
- package/cli/audit/clone-detector.spec.js +46 -0
- package/cli/audit/history.js +22 -6
- package/cli/audit/hook-shape-validator.js +312 -0
- package/cli/audit/prompts.js +13 -10
- package/cli/audit/prompts.spec.js +52 -1
- package/cli/audit/reporter-clones.js +33 -0
- package/cli/audit/reporter-hotspot-graph.js +34 -0
- package/cli/audit/rules-registry.js +6 -1
- package/cli/audit/rules.js +10 -2
- package/cli/audit/rules.spec.js +129 -0
- package/cli/audit-stages.spec.js +30 -0
- package/cli/audit.js +12 -3
- package/cli/commands/cmd-audit.js +56 -12
- package/cli/commands/cmd-project.js +100 -0
- package/cli/commands/cmd-router.js +52 -3
- package/cli/commands-schema.js +28 -0
- package/cli/create.spec.js +2 -2
- package/cli/exploder.js +258 -0
- package/cli/exploder.spec.js +99 -0
- package/cli/generator-compact.spec.js +104 -0
- package/cli/generator-framework.spec.js +72 -0
- package/cli/generator-templates/archetypes/collection-archetypes.js +9 -9
- package/cli/generator-templates/archetypes/index.js +34 -4
- package/cli/generator-templates/archetypes/input-archetypes.js +2 -2
- package/cli/generator-templates/archetypes/layout-archetypes.js +2 -2
- package/cli/generator-templates/archetypes/minimal-archetypes.js +131 -0
- package/cli/generator-templates/archetypes/status-archetypes.js +4 -4
- package/cli/generator-templates/archetypes/vector-matcher.js +50 -0
- package/cli/generator-templates/archetypes/vector-matcher.spec.js +25 -0
- package/cli/generator-templates/compact.js +130 -0
- package/cli/generator-templates/controller-converters.js +93 -0
- package/cli/generator-templates/controller.js +2 -64
- package/cli/generator-templates/react.js +1 -1
- package/cli/generator-templates/svelte.js +1 -1
- package/cli/generator-templates/types.js +2 -2
- package/cli/generator-templates/vue.js +1 -1
- package/cli/generator-templates.js +1 -0
- package/cli/generator-templates.spec.js +6 -1
- package/cli/generator.js +94 -7
- package/cli/help.js +1 -1
- package/cli/index.js +23 -8
- package/cli/installer-templates.js +1 -1
- package/cli/mcp/antigravity.js +5 -2
- package/cli/mcp/installer.js +7 -7
- package/cli/mcp/installer.spec.js +3 -3
- package/cli/mcp/manifests.js +4 -2
- package/cli/mcp/prompts.js +1 -1
- package/cli/mcp/server.js +2 -0
- package/cli/mcp/tools-audit.js +97 -0
- package/cli/mcp/tools-generate.js +62 -0
- package/cli/mcp/tools-patch.js +72 -0
- package/cli/mcp/tools-patterns.js +71 -0
- package/cli/mcp/tools-project.js +75 -0
- package/cli/mcp/tools-project.spec.js +51 -0
- package/cli/mcp/tools-q-modes.js +69 -0
- package/cli/mcp/tools-q.js +58 -0
- package/cli/mcp/tools-read.js +69 -0
- package/cli/mcp/tools-search-util.js +35 -0
- package/cli/mcp/tools-search.js +15 -294
- package/cli/mcp/tools-team-feed.js +57 -0
- package/cli/mcp/tools-team-locks.js +63 -0
- package/cli/mcp/tools-team-mailbox.js +32 -0
- package/cli/mcp/tools-team-tasks.js +85 -0
- package/cli/mcp/tools-team.js +37 -183
- package/cli/mcp/tools-verify.js +47 -0
- package/cli/mcp/tools-write.js +33 -0
- package/cli/mcp/tools.js +79 -372
- package/cli/mutators.js +10 -23
- package/cli/pillars-schema.js +3 -3
- package/cli/project-detector.js +47 -0
- package/cli/reader-shorthand.spec.js +60 -0
- package/cli/reader.js +67 -24
- package/cli/search-bulletproof.spec.js +104 -0
- package/cli/search-db.js +33 -5
- package/cli/search-queries-hotspot-graph.js +65 -0
- package/cli/search-queries-hotspot-graph.spec.js +66 -0
- package/cli/search-queries-similar.js +84 -0
- package/cli/search-queries-similar.spec.js +43 -0
- package/cli/search-schema.js +79 -11
- package/cli/search.js +13 -2
- package/cli/team/index.js +9 -0
- package/cli/team/team-commands.js +106 -64
- package/cli/team/team-concurrency-stress.spec.js +95 -0
- package/cli/team/team-db-lock-promotion.js +15 -11
- package/cli/team/team-db-mailbox.js +86 -0
- package/cli/team/team-db-task-helpers.js +80 -28
- package/cli/team/team-db-tasks.js +13 -5
- package/cli/team/team-flags.js +72 -0
- package/cli/team/team-format.js +87 -46
- package/cli/team/team-mailbox-empirical.spec.js +191 -0
- package/cli/team/team-mailbox.spec.js +93 -0
- package/cli/team/team-memory.js +84 -0
- package/cli/team/team-memory.spec.js +74 -0
- package/cli/team/team-projects-coordinator.js +100 -0
- package/cli/team/team-projects-format.js +45 -0
- package/cli/team/team-projects-memory.js +70 -0
- package/cli/team/team-projects.js +94 -0
- package/cli/team/team-projects.spec.js +116 -0
- package/cli/team/team-schema-memory.js +25 -0
- package/cli/team/team-schema-projects.js +50 -0
- package/cli/team/team-schema.js +33 -14
- package/cli/team/team-tasks-hierarchy.spec.js +176 -0
- package/cli/team/team-tokens.js +95 -0
- package/cli/team/team-triage.js +274 -52
- package/cli/team/team-verification-gate.spec.js +253 -0
- package/cli/team/team.spec.js +1 -1
- package/cli/tesseract-hud.js +59 -0
- package/cli/tesseract-manifesto.js +61 -0
- package/cli/tesseract-state.js +58 -0
- package/cli/tesseract.js +81 -0
- package/cli/tesseract.spec.js +55 -0
- package/cli/trend.js +84 -0
- package/cli/trend.spec.js +51 -0
- package/cli/ui-actions-tasks.js +13 -5
- package/cli/ui-actions.js +10 -2
- package/cli/ui-client-api.js +2 -2
- package/cli/ui-client-script.js +12 -15
- package/cli/ui-e2e-verification.spec.js +13 -3
- package/cli/ui-filetree.spec.js +5 -5
- package/cli/ui-handlers.js +10 -2
- package/cli/ui-html.js +26 -34
- package/cli/ui-index.html +905 -0
- package/cli/ui-kanban.spec.js +12 -12
- package/cli/ui-server.js +19 -3
- package/cli/ui-sse.js +57 -0
- package/cli/ui-sse.spec.js +74 -0
- package/cli/ui-styles.js +195 -81
- package/cli/ui-template-kanban.js +15 -1
- package/cli/ui-vbulletin.spec.js +12 -13
- package/cli/ui.spec.js +12 -5
- package/cli/verify.js +19 -8
- package/package.json +3 -1
- package/scripts/check-framework-generation.mjs +195 -0
- package/scripts/pre-commit.sh +2 -7
- package/scripts/publish-both.mjs +4 -0
package/cli/create.spec.js
CHANGED
|
@@ -63,7 +63,7 @@ describe('Scaffolding Entrypoint Invocations', () => {
|
|
|
63
63
|
const result = spawnSync('node', [CREATE_BIN, '--help'], {
|
|
64
64
|
encoding: 'utf-8',
|
|
65
65
|
cwd: tmpBase,
|
|
66
|
-
timeout:
|
|
66
|
+
timeout: 15000
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
assert.equal(result.status, 0, 'Should exit with code 0 on --help');
|
|
@@ -78,7 +78,7 @@ describe('Scaffolding Entrypoint Invocations', () => {
|
|
|
78
78
|
const result = spawnSync('node', [CREATE_BIN, '--version'], {
|
|
79
79
|
encoding: 'utf-8',
|
|
80
80
|
cwd: tmpBase,
|
|
81
|
-
timeout:
|
|
81
|
+
timeout: 15000
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
assert.equal(result.status, 0, 'Should exit with code 0 on --version');
|
package/cli/exploder.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chemical X Capsule Exploder
|
|
3
|
+
* Atomically unpacks a "liquid" single-file compact capsule into a standard crystalline multi-file directory capsule.
|
|
4
|
+
* Transactional: Uses .temp.bak and rolls back if post-unpack verification fails.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import * as babelParser from '@babel/parser';
|
|
10
|
+
import { toPascalCase } from './generator-templates/naming.js';
|
|
11
|
+
import { buildComponentSpec, buildIndex } from './generator-templates/specs.js';
|
|
12
|
+
import { buildTypesIndex } from './generator-templates/types.js';
|
|
13
|
+
import { detectTestRunner } from './project-detector.js';
|
|
14
|
+
|
|
15
|
+
export const parseCompactFile = (content, ext = 'tsx') => {
|
|
16
|
+
const isTsx = ext === 'tsx' || ext === 'jsx';
|
|
17
|
+
const plugins = ['typescript'];
|
|
18
|
+
if (isTsx) plugins.push('jsx');
|
|
19
|
+
|
|
20
|
+
const ast = babelParser.parse(content, {
|
|
21
|
+
sourceType: 'module',
|
|
22
|
+
plugins
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const lines = content.split('\n');
|
|
26
|
+
const getSlice = (node) => {
|
|
27
|
+
return lines.slice(node.loc.start.line - 1, node.loc.end.line).join('\n');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const typesProps = [];
|
|
31
|
+
const typesState = [];
|
|
32
|
+
let controllerHookCode = null;
|
|
33
|
+
let componentCode = null;
|
|
34
|
+
const reactImports = new Set();
|
|
35
|
+
|
|
36
|
+
for (const node of ast.program.body) {
|
|
37
|
+
if (node.type === 'ImportDeclaration') {
|
|
38
|
+
if (node.source.value === 'react') {
|
|
39
|
+
for (const spec of node.specifiers) {
|
|
40
|
+
if (spec.imported?.name) reactImports.add(spec.imported.name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Capture TypeScript interfaces and type aliases
|
|
47
|
+
const isExportedType = node.type === 'ExportNamedDeclaration' &&
|
|
48
|
+
(node.declaration?.type === 'TSInterfaceDeclaration' || node.declaration?.type === 'TSTypeAliasDeclaration');
|
|
49
|
+
|
|
50
|
+
if (isExportedType) {
|
|
51
|
+
const typeName = node.declaration.id?.name || '';
|
|
52
|
+
const slice = getSlice(node);
|
|
53
|
+
const isPropsOrEmits = typeName.includes('Props') || typeName.includes('Emits');
|
|
54
|
+
if (isPropsOrEmits) {
|
|
55
|
+
typesProps.push(slice);
|
|
56
|
+
} else {
|
|
57
|
+
typesState.push(slice);
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Capture Controller Hook
|
|
63
|
+
const isExportedVar = node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration';
|
|
64
|
+
if (isExportedVar) {
|
|
65
|
+
const decl = node.declaration.declarations[0];
|
|
66
|
+
const varName = decl?.id?.name || '';
|
|
67
|
+
const isControllerHook = varName.startsWith('use') && varName.endsWith('Controller');
|
|
68
|
+
if (isControllerHook) {
|
|
69
|
+
controllerHookCode = getSlice(node);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (varName && !varName.startsWith('use')) {
|
|
73
|
+
componentCode = getSlice(node);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (node.type === 'ExportDefaultDeclaration') {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
typesProps: typesProps.join('\n\n'),
|
|
85
|
+
typesState: typesState.join('\n\n'),
|
|
86
|
+
controllerHookCode,
|
|
87
|
+
componentCode,
|
|
88
|
+
reactImports: Array.from(reactImports)
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const explodeCapsule = (targetFilePath, options = {}) => {
|
|
93
|
+
const cwd = options.cwd || process.cwd();
|
|
94
|
+
const absPath = path.resolve(cwd, targetFilePath);
|
|
95
|
+
|
|
96
|
+
if (!fs.existsSync(absPath)) {
|
|
97
|
+
throw new Error(`Target file not found at ${absPath}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stat = fs.statSync(absPath);
|
|
101
|
+
if (stat.isDirectory()) {
|
|
102
|
+
throw new Error(`Target ${targetFilePath} is already a directory capsule.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fileName = path.basename(absPath);
|
|
106
|
+
const ext = path.extname(absPath).replace('.', '');
|
|
107
|
+
const capsuleName = fileName.replace(/\.[^.]+$/, '');
|
|
108
|
+
const parentDir = path.dirname(absPath);
|
|
109
|
+
const targetDir = path.join(parentDir, capsuleName);
|
|
110
|
+
const bakPath = `${absPath}.temp.bak`;
|
|
111
|
+
|
|
112
|
+
if (fs.existsSync(targetDir)) {
|
|
113
|
+
throw new Error(`Destination directory ${capsuleName} already exists.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
117
|
+
const baseSlug = capsuleName.replace(/^[a-z]+-/, '');
|
|
118
|
+
const pascalName = toPascalCase(baseSlug);
|
|
119
|
+
|
|
120
|
+
if (options.dryRun) {
|
|
121
|
+
parseCompactFile(content, ext);
|
|
122
|
+
return {
|
|
123
|
+
success: true,
|
|
124
|
+
dryRun: true,
|
|
125
|
+
capsuleName,
|
|
126
|
+
targetDir: path.relative(cwd, targetDir),
|
|
127
|
+
files: [
|
|
128
|
+
`${capsuleName}.${ext}`,
|
|
129
|
+
`${capsuleName}.controller.ts`,
|
|
130
|
+
`${capsuleName}.spec.ts`,
|
|
131
|
+
'index.ts',
|
|
132
|
+
'types/props.d.ts',
|
|
133
|
+
'types/state.d.ts',
|
|
134
|
+
'types/index.ts',
|
|
135
|
+
'types.d.ts'
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 1. Transactional Backup Snapshot
|
|
141
|
+
fs.copyFileSync(absPath, bakPath);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const parsed = parseCompactFile(content, ext);
|
|
145
|
+
|
|
146
|
+
// 2. Create Directory Capsule Structure
|
|
147
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
148
|
+
const typesDir = path.join(targetDir, 'types');
|
|
149
|
+
fs.mkdirSync(typesDir, { recursive: true });
|
|
150
|
+
|
|
151
|
+
// 3. Types Files
|
|
152
|
+
let propsContent = parsed.typesProps || `export interface ${pascalName}Props {\n readonly className?: string;\n}\n`;
|
|
153
|
+
const usesReactNode = propsContent.includes('ReactNode') && !propsContent.includes("from 'react'");
|
|
154
|
+
if (usesReactNode) {
|
|
155
|
+
propsContent = `import type { ReactNode } from 'react';\n\n${propsContent}`;
|
|
156
|
+
}
|
|
157
|
+
const stateContent = parsed.typesState || `export interface ${pascalName}State {\n readonly isActive: boolean;\n}\n`;
|
|
158
|
+
|
|
159
|
+
fs.writeFileSync(path.join(typesDir, 'props.d.ts'), `${propsContent}\n`, 'utf-8');
|
|
160
|
+
fs.writeFileSync(path.join(typesDir, 'state.d.ts'), `${stateContent}\n`, 'utf-8');
|
|
161
|
+
fs.writeFileSync(path.join(typesDir, 'index.ts'), buildTypesIndex(['props', 'state']), 'utf-8');
|
|
162
|
+
fs.writeFileSync(path.join(targetDir, 'types.d.ts'), "export * from './types/index';\n", 'utf-8');
|
|
163
|
+
|
|
164
|
+
// 4. Controller File
|
|
165
|
+
const hookCode = parsed.controllerHookCode ||
|
|
166
|
+
`export const use${pascalName}Controller = () => {\n const [isActive, setIsActive] = useState<boolean>(false);\n return { isActive };\n};`;
|
|
167
|
+
const controllerImports = "import { useState, useMemo } from 'react';\n";
|
|
168
|
+
fs.writeFileSync(path.join(targetDir, `${capsuleName}.controller.ts`), `${controllerImports}\n${hookCode}\n`, 'utf-8');
|
|
169
|
+
|
|
170
|
+
// 5. Component File
|
|
171
|
+
const compImports = `import type { ${pascalName}Props } from './types';\nimport { use${pascalName}Controller } from './${capsuleName}.controller';\n\n`;
|
|
172
|
+
const compBody = parsed.componentCode || `export const ${pascalName} = ({ className = '' }: ${pascalName}Props) => {\n return <div className="${capsuleName}" />;\n};`;
|
|
173
|
+
const compContent = `${compImports}${compBody}\n\nexport default ${pascalName};\n`;
|
|
174
|
+
fs.writeFileSync(path.join(targetDir, `${capsuleName}.${ext}`), compContent, 'utf-8');
|
|
175
|
+
|
|
176
|
+
// 6. Index & Spec
|
|
177
|
+
const hasController = Boolean(parsed.controllerHookCode);
|
|
178
|
+
const runner = detectTestRunner(targetDir);
|
|
179
|
+
fs.writeFileSync(path.join(targetDir, 'index.ts'), buildIndex(capsuleName, pascalName, ext, hasController), 'utf-8');
|
|
180
|
+
fs.writeFileSync(path.join(targetDir, `${capsuleName}.spec.ts`), buildComponentSpec(capsuleName, pascalName, hasController, runner), 'utf-8');
|
|
181
|
+
|
|
182
|
+
// 7. Verify Unpacked Directory (Syntactic AST Parse Verification)
|
|
183
|
+
babelParser.parse(fs.readFileSync(path.join(targetDir, `${capsuleName}.${ext}`), 'utf-8'), {
|
|
184
|
+
sourceType: 'module',
|
|
185
|
+
plugins: ['typescript', 'jsx']
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 8. Success: Delete source file and backup
|
|
189
|
+
fs.unlinkSync(absPath);
|
|
190
|
+
if (fs.existsSync(bakPath)) fs.unlinkSync(bakPath);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
success: true,
|
|
194
|
+
capsuleName,
|
|
195
|
+
targetDir: path.relative(cwd, targetDir),
|
|
196
|
+
files: [
|
|
197
|
+
`${capsuleName}.${ext}`,
|
|
198
|
+
`${capsuleName}.controller.ts`,
|
|
199
|
+
`${capsuleName}.spec.ts`,
|
|
200
|
+
'index.ts',
|
|
201
|
+
'types/props.d.ts',
|
|
202
|
+
'types/state.d.ts',
|
|
203
|
+
'types/index.ts',
|
|
204
|
+
'types.d.ts'
|
|
205
|
+
]
|
|
206
|
+
};
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Transactional Rollback
|
|
209
|
+
if (fs.existsSync(targetDir)) {
|
|
210
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
if (fs.existsSync(bakPath)) {
|
|
213
|
+
fs.copyFileSync(bakPath, absPath);
|
|
214
|
+
fs.unlinkSync(bakPath);
|
|
215
|
+
}
|
|
216
|
+
throw new Error(`Explode transaction aborted and rolled back: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const runExplodeCli = async (rawArgs = [], isCli = true) => {
|
|
221
|
+
const isJson = rawArgs.includes('--json');
|
|
222
|
+
const isDryRun = rawArgs.includes('--dry-run') || rawArgs.includes('-n');
|
|
223
|
+
const target = rawArgs.find((a) => !a.startsWith('-'));
|
|
224
|
+
|
|
225
|
+
if (!target) {
|
|
226
|
+
const msg = 'Usage: npx chemx explode <file-path> [--dry-run] [--json]';
|
|
227
|
+
if (isJson) {
|
|
228
|
+
process.stdout.write(JSON.stringify({ error: msg, success: false }) + '\n');
|
|
229
|
+
} else {
|
|
230
|
+
process.stderr.write(`\x1b[31m✕ Error: ${msg}\x1b[0m\n`);
|
|
231
|
+
}
|
|
232
|
+
if (isCli) process.exit(1);
|
|
233
|
+
return { error: msg, success: false };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const result = explodeCapsule(target, { dryRun: isDryRun });
|
|
238
|
+
if (isJson) {
|
|
239
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
240
|
+
} else {
|
|
241
|
+
process.stdout.write(`\n\x1b[1m\x1b[32m✔ Successfully exploded capsule:\x1b[0m \x1b[36m${result.targetDir}/\x1b[0m\n`);
|
|
242
|
+
for (const f of result.files) {
|
|
243
|
+
process.stdout.write(` \x1b[32m✔\x1b[0m ${f}\n`);
|
|
244
|
+
}
|
|
245
|
+
process.stdout.write('\n');
|
|
246
|
+
}
|
|
247
|
+
if (isCli) process.exit(0);
|
|
248
|
+
return result;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (isJson) {
|
|
251
|
+
process.stdout.write(JSON.stringify({ error: err.message, success: false }) + '\n');
|
|
252
|
+
} else {
|
|
253
|
+
process.stderr.write(`\x1b[31m✕ Error: ${err.message}\x1b[0m\n`);
|
|
254
|
+
}
|
|
255
|
+
if (isCli) process.exit(1);
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseCompactFile, explodeCapsule } from './exploder.js';
|
|
6
|
+
|
|
7
|
+
const SAMPLE_COMPACT_CODE = `import type { ReactNode } from 'react';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
|
|
10
|
+
export interface CounterProps {
|
|
11
|
+
readonly className?: string;
|
|
12
|
+
readonly children?: ReactNode;
|
|
13
|
+
readonly initialCount?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CounterState {
|
|
17
|
+
readonly count: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useCounterController = (options: { initialCount?: number } = {}) => {
|
|
21
|
+
const [count, setCount] = useState<number>(options.initialCount || 0);
|
|
22
|
+
const increment = () => setCount((c) => c + 1);
|
|
23
|
+
return { count, increment };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Counter = ({ className = '', initialCount = 0 }: CounterProps) => {
|
|
27
|
+
const { count, increment } = useCounterController({ initialCount });
|
|
28
|
+
return (
|
|
29
|
+
<div className={\`m-counter \${className}\`.trim()}>
|
|
30
|
+
<button type="button" onClick={increment}>Count: {count}</button>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default Counter;
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
test('exploder: parseCompactFile extracts types, controller, and component sections', () => {
|
|
39
|
+
const parsed = parseCompactFile(SAMPLE_COMPACT_CODE, 'tsx');
|
|
40
|
+
assert.ok(parsed.typesProps.includes('CounterProps'), 'Must extract CounterProps');
|
|
41
|
+
assert.ok(parsed.typesState.includes('CounterState'), 'Must extract CounterState');
|
|
42
|
+
assert.ok(parsed.controllerHookCode.includes('useCounterController'), 'Must extract controller hook');
|
|
43
|
+
assert.ok(parsed.componentCode.includes('export const Counter'), 'Must extract component body');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('exploder: explodeCapsule unpacks single file into multi-file capsule and removes .temp.bak', () => {
|
|
47
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-explode');
|
|
48
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
49
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const compactFile = path.join(tmpDir, 'm-counter.tsx');
|
|
52
|
+
fs.writeFileSync(compactFile, SAMPLE_COMPACT_CODE, 'utf-8');
|
|
53
|
+
|
|
54
|
+
const result = explodeCapsule(compactFile, { cwd: tmpDir });
|
|
55
|
+
assert.strictEqual(result.success, true);
|
|
56
|
+
assert.strictEqual(result.capsuleName, 'm-counter');
|
|
57
|
+
|
|
58
|
+
// Verify single file is gone and bak is cleaned up
|
|
59
|
+
assert.strictEqual(fs.existsSync(compactFile), false, 'Original file should be replaced');
|
|
60
|
+
assert.strictEqual(fs.existsSync(`${compactFile}.temp.bak`), false, 'Temp bak must be removed on success');
|
|
61
|
+
|
|
62
|
+
// Verify unpacked capsule structure
|
|
63
|
+
const capsuleDir = path.join(tmpDir, 'm-counter');
|
|
64
|
+
assert.ok(fs.existsSync(capsuleDir), 'Directory capsule must exist');
|
|
65
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'm-counter.tsx')), 'Component must exist');
|
|
66
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'm-counter.controller.ts')), 'Controller must exist');
|
|
67
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'm-counter.spec.ts')), 'Spec must exist');
|
|
68
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'index.ts')), 'Index barrel must exist');
|
|
69
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'types/props.d.ts')), 'Props type must exist');
|
|
70
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'types/state.d.ts')), 'State type must exist');
|
|
71
|
+
assert.ok(fs.existsSync(path.join(capsuleDir, 'types.d.ts')), 'Types barrel must exist');
|
|
72
|
+
|
|
73
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('exploder: rolls back and restores from .temp.bak on failure', () => {
|
|
77
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-explode-fail');
|
|
78
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
79
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
80
|
+
|
|
81
|
+
// Invalid syntax that fails verification
|
|
82
|
+
const invalidCode = 'export const Bad = () => { return <div missing closing tag>; };';
|
|
83
|
+
const targetFile = path.join(tmpDir, 'm-bad.tsx');
|
|
84
|
+
fs.writeFileSync(targetFile, invalidCode, 'utf-8');
|
|
85
|
+
|
|
86
|
+
assert.throws(() => {
|
|
87
|
+
explodeCapsule(targetFile, { cwd: tmpDir });
|
|
88
|
+
}, /Explode transaction aborted/);
|
|
89
|
+
|
|
90
|
+
// Original file must be restored
|
|
91
|
+
assert.ok(fs.existsSync(targetFile), 'Original file must be restored on failure');
|
|
92
|
+
assert.strictEqual(fs.readFileSync(targetFile, 'utf-8'), invalidCode);
|
|
93
|
+
// Backup must be cleaned up
|
|
94
|
+
assert.strictEqual(fs.existsSync(`${targetFile}.temp.bak`), false, 'Temp bak must be cleaned up');
|
|
95
|
+
// Capsule dir must not exist
|
|
96
|
+
assert.strictEqual(fs.existsSync(path.join(tmpDir, 'm-bad')), false, 'Failed capsule dir must be removed');
|
|
97
|
+
|
|
98
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { runGenerateWizard, createCapsuleFiles } from './generator.js';
|
|
6
|
+
import { detectStylingStack } from './project-detector.js';
|
|
7
|
+
import { resolveArchetype } from './generator-templates/archetypes/index.js';
|
|
8
|
+
|
|
9
|
+
test('detectStylingStack: detects tailwind, scss, or css appropriately', () => {
|
|
10
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-detect-styling');
|
|
11
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
12
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
// 1. Plain CSS fallback
|
|
15
|
+
const plain = detectStylingStack(tmpDir);
|
|
16
|
+
assert.strictEqual(plain.hasTailwind, false);
|
|
17
|
+
assert.strictEqual(plain.hasScss, false);
|
|
18
|
+
assert.strictEqual(plain.styleFlavor, 'css');
|
|
19
|
+
|
|
20
|
+
// 2. Tailwind via package.json
|
|
21
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({
|
|
22
|
+
dependencies: { tailwindcss: '^4.0.0' }
|
|
23
|
+
}));
|
|
24
|
+
const twPkg = detectStylingStack(tmpDir);
|
|
25
|
+
assert.strictEqual(twPkg.hasTailwind, true);
|
|
26
|
+
assert.strictEqual(twPkg.styleFlavor, 'tailwind');
|
|
27
|
+
|
|
28
|
+
// 3. Tailwind via config file
|
|
29
|
+
fs.unlinkSync(path.join(tmpDir, 'package.json'));
|
|
30
|
+
fs.writeFileSync(path.join(tmpDir, 'tailwind.config.js'), 'export default {};');
|
|
31
|
+
const twCfg = detectStylingStack(tmpDir);
|
|
32
|
+
assert.strictEqual(twCfg.hasTailwind, true);
|
|
33
|
+
assert.strictEqual(twCfg.styleFlavor, 'tailwind');
|
|
34
|
+
|
|
35
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('archetypes: resolveArchetype resolves minimal, controls, and canvas archetypes', () => {
|
|
39
|
+
const minimal = resolveArchetype('m-card', '', 'minimal');
|
|
40
|
+
assert.strictEqual(minimal.id, 'minimal');
|
|
41
|
+
|
|
42
|
+
const bare = resolveArchetype('m-card', '', 'bare');
|
|
43
|
+
assert.strictEqual(bare.id, 'minimal');
|
|
44
|
+
|
|
45
|
+
const controls = resolveArchetype('m-dock', '', 'controls');
|
|
46
|
+
assert.strictEqual(controls.id, 'controls');
|
|
47
|
+
|
|
48
|
+
const canvas = resolveArchetype('m-stage', '', 'canvas');
|
|
49
|
+
assert.strictEqual(canvas.id, 'canvas');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('generator: --compact generates single clean file with inlined types and controller', async () => {
|
|
53
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-gen-compact');
|
|
54
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
55
|
+
|
|
56
|
+
const res = await runGenerateWizard([
|
|
57
|
+
'molecule',
|
|
58
|
+
'time-controls',
|
|
59
|
+
'--compact',
|
|
60
|
+
'--template=minimal',
|
|
61
|
+
'-y',
|
|
62
|
+
`--dir=${tmpDir}`,
|
|
63
|
+
'--framework=react'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
assert.strictEqual(res.success, true);
|
|
67
|
+
assert.strictEqual(res.compact, true);
|
|
68
|
+
assert.strictEqual(res.capsuleName, 'm-time-controls');
|
|
69
|
+
|
|
70
|
+
const singleFilePath = path.join(tmpDir, 'm-time-controls.tsx');
|
|
71
|
+
assert.ok(fs.existsSync(singleFilePath), 'Compact file must exist');
|
|
72
|
+
|
|
73
|
+
const content = fs.readFileSync(singleFilePath, 'utf-8');
|
|
74
|
+
assert.ok(content.includes('interface TimeControlsProps'), 'Must inline props');
|
|
75
|
+
assert.ok(content.includes('interface TimeControlsState'), 'Must inline state');
|
|
76
|
+
assert.ok(content.includes('useTimeControlsController'), 'Must inline controller hook');
|
|
77
|
+
assert.ok(content.includes('export const TimeControls'), 'Must export component');
|
|
78
|
+
|
|
79
|
+
const lineCount = content.split('\n').length;
|
|
80
|
+
assert.ok(lineCount < 80, `Compact file must be < 80 lines (got ${lineCount})`);
|
|
81
|
+
|
|
82
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('generator: skips scss creation when tailwind is detected or --css=none', async () => {
|
|
86
|
+
const tmpDir = path.resolve(process.cwd(), 'scratch/test-gen-tailwind');
|
|
87
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
88
|
+
|
|
89
|
+
const res = await runGenerateWizard([
|
|
90
|
+
'molecule',
|
|
91
|
+
'quick-card',
|
|
92
|
+
'--css=tailwind',
|
|
93
|
+
'-y',
|
|
94
|
+
`--dir=${tmpDir}`,
|
|
95
|
+
'--framework=react'
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
assert.strictEqual(res.success, true);
|
|
99
|
+
const capsuleDir = path.join(tmpDir, 'm-quick-card');
|
|
100
|
+
const scssFile = path.join(capsuleDir, '_m-quick-card.scss');
|
|
101
|
+
assert.strictEqual(fs.existsSync(scssFile), false, 'SCSS file must not exist for tailwind');
|
|
102
|
+
|
|
103
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import ts from 'typescript';
|
|
5
6
|
import { runGenerateWizard } from './generator.js';
|
|
6
7
|
import { auditFile } from './audit.js';
|
|
7
8
|
|
|
@@ -115,6 +116,15 @@ test('generator integration: Vue capsule with --desc generates Vue reactivity an
|
|
|
115
116
|
assert.ok(!controllerContent.includes("from 'react'"), 'Vue controller must NOT import from react');
|
|
116
117
|
assert.ok(!controllerContent.includes('useState'), 'Vue controller must NOT use useState');
|
|
117
118
|
|
|
119
|
+
// Assert method collision is prevented
|
|
120
|
+
assert.ok(!controllerContent.includes('.filter.value'), 'Must not collide Array.filter method with filter.value');
|
|
121
|
+
assert.ok(controllerContent.includes('items.value.filter'), 'Must unwrap items.value before Array.filter');
|
|
122
|
+
assert.ok(controllerContent.includes("filter.value === 'active'"), 'Must unwrap filter state variable comparison');
|
|
123
|
+
|
|
124
|
+
// Assert controller parses with zero TypeScript syntax errors
|
|
125
|
+
const sf = ts.createSourceFile('test.ts', controllerContent, ts.ScriptTarget.Latest, true);
|
|
126
|
+
assert.strictEqual(sf.parseDiagnostics?.length || 0, 0, 'Generated Vue controller must have zero TypeScript syntax errors');
|
|
127
|
+
|
|
118
128
|
// Assert no old generic placeholder properties
|
|
119
129
|
assert.ok(!viewContent.includes('descriptor.className'));
|
|
120
130
|
assert.ok(!viewContent.includes('descriptor.text'));
|
|
@@ -161,6 +171,10 @@ test('generator integration: Svelte capsule with --desc generates matching view
|
|
|
161
171
|
assert.ok(!controllerContent.includes("from 'react'"), 'Svelte controller must NOT import from react');
|
|
162
172
|
assert.ok(!controllerContent.includes('useState'), 'Svelte controller must NOT use useState');
|
|
163
173
|
|
|
174
|
+
// Assert Svelte controller parses with zero TypeScript syntax errors
|
|
175
|
+
const sfSvelte = ts.createSourceFile('test.ts', controllerContent, ts.ScriptTarget.Latest, true);
|
|
176
|
+
assert.strictEqual(sfSvelte.parseDiagnostics?.length || 0, 0, 'Generated Svelte controller must have zero TypeScript syntax errors');
|
|
177
|
+
|
|
164
178
|
const returnedKeys = parseControllerReturns(controllerContent);
|
|
165
179
|
const destructuredKeys = parseViewDestructured(viewContent);
|
|
166
180
|
|
|
@@ -241,3 +255,61 @@ test('generator integration: auto-detects framework from .chemx/config.json when
|
|
|
241
255
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
242
256
|
});
|
|
243
257
|
|
|
258
|
+
test('generator integration: mechanical contract diff and syntax check across all frameworks with --desc', async () => {
|
|
259
|
+
const frameworks = ['react', 'vue', 'svelte'];
|
|
260
|
+
const testDesc = 'add, toggle, and remove tasks with an input field';
|
|
261
|
+
|
|
262
|
+
for (const fw of frameworks) {
|
|
263
|
+
const tmpDir = path.resolve(process.cwd(), `scratch/test-contract-diff-${fw}`);
|
|
264
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
265
|
+
|
|
266
|
+
const res = await runGenerateWizard([
|
|
267
|
+
'molecule',
|
|
268
|
+
'task-list',
|
|
269
|
+
'-y',
|
|
270
|
+
`--dir=${tmpDir}`,
|
|
271
|
+
`--framework=${fw}`,
|
|
272
|
+
`--desc=${testDesc}`
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
assert.strictEqual(res.success, true, `Scaffolding must succeed for ${fw}`);
|
|
276
|
+
const capsuleDir = path.join(tmpDir, 'm-task-list');
|
|
277
|
+
const ext = fw === 'react' ? 'tsx' : fw;
|
|
278
|
+
const viewPath = path.join(capsuleDir, `m-task-list.${ext}`);
|
|
279
|
+
const controllerPath = path.join(capsuleDir, 'm-task-list.controller.ts');
|
|
280
|
+
|
|
281
|
+
assert.ok(fs.existsSync(viewPath), `View file must exist for ${fw}`);
|
|
282
|
+
assert.ok(fs.existsSync(controllerPath), `Controller file must exist for ${fw}`);
|
|
283
|
+
|
|
284
|
+
const viewContent = fs.readFileSync(viewPath, 'utf8');
|
|
285
|
+
const controllerContent = fs.readFileSync(controllerPath, 'utf8');
|
|
286
|
+
|
|
287
|
+
// 1. Mechanical check: TS syntax validation
|
|
288
|
+
const sf = ts.createSourceFile('test.ts', controllerContent, ts.ScriptTarget.Latest, true);
|
|
289
|
+
assert.strictEqual(
|
|
290
|
+
sf.parseDiagnostics?.length || 0,
|
|
291
|
+
0,
|
|
292
|
+
`Controller for ${fw} must have zero TypeScript syntax errors`
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// 2. Mechanical check: diff controller return shape against view destructure
|
|
296
|
+
const returnedKeys = parseControllerReturns(controllerContent);
|
|
297
|
+
const destructuredKeys = parseViewDestructured(viewContent);
|
|
298
|
+
|
|
299
|
+
assert.ok(destructuredKeys.size > 0, `View for ${fw} must destructure controller properties`);
|
|
300
|
+
for (const key of destructuredKeys) {
|
|
301
|
+
assert.ok(
|
|
302
|
+
returnedKeys.has(key),
|
|
303
|
+
`[${fw}] View property "${key}" must be present in controller return object: ${[...returnedKeys].join(', ')}`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3. Audit check: CONTROLLER_VIEW_MISMATCH must report 0 violations
|
|
308
|
+
const violations = auditFile(viewPath, `m-task-list.${ext}`);
|
|
309
|
+
const mismatchViolations = violations.filter((v) => v.rule === 'CONTROLLER_VIEW_MISMATCH');
|
|
310
|
+
assert.strictEqual(mismatchViolations.length, 0, `Audit must report 0 mismatch violations for ${fw}`);
|
|
311
|
+
|
|
312
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
@@ -7,7 +7,7 @@ export const COLLECTION_ARCHETYPES = [
|
|
|
7
7
|
id: 'task-list',
|
|
8
8
|
name: 'Collection & Task List',
|
|
9
9
|
keywords: ['task', 'todo', 'tasklist', 'checklist', 'item', 'queue', 'list', 'add', 'toggle', 'remove', 'delete'],
|
|
10
|
-
destructure: 'items, activeCount, setFilter, toggleItem, removeItem, addItem',
|
|
10
|
+
destructure: 'items, filter, activeCount, setFilter, toggleItem, removeItem, addItem',
|
|
11
11
|
buildState: (name, pascal) => `export interface ${pascal}Item {
|
|
12
12
|
readonly id: string;
|
|
13
13
|
readonly title: string;
|
|
@@ -151,7 +151,7 @@ export const use${pascal}Controller = (options: { rows?: readonly ${pascal}Row[]
|
|
|
151
151
|
}
|
|
152
152
|
};
|
|
153
153
|
|
|
154
|
-
return { sortColumn, sortDirection, page, sortBy, setPage };
|
|
154
|
+
return { rows: options.rows || [], sortColumn, sortDirection, page, sortBy, setPage };
|
|
155
155
|
};
|
|
156
156
|
`,
|
|
157
157
|
buildReactBody: (name, pascal) => ` <div className="${name}__header">
|
|
@@ -189,7 +189,7 @@ export interface ${pascal}State {
|
|
|
189
189
|
buildController: (name, pascal) => `import { useState } from 'react';
|
|
190
190
|
import type { ${pascal}Node } from './types';
|
|
191
191
|
|
|
192
|
-
export const use${pascal}Controller = (options: { onSelect?: (id: string) => void } = {}) => {
|
|
192
|
+
export const use${pascal}Controller = (options: { nodes?: readonly ${pascal}Node[]; onSelect?: (id: string) => void } = {}) => {
|
|
193
193
|
const [expandedIds, setExpandedIds] = useState<readonly string[]>([]);
|
|
194
194
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
195
195
|
|
|
@@ -202,7 +202,7 @@ export const use${pascal}Controller = (options: { onSelect?: (id: string) => voi
|
|
|
202
202
|
options.onSelect?.(id);
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
-
return { expandedIds, selectedId, toggleNode, selectNode };
|
|
205
|
+
return { nodes: options.nodes || [], expandedIds, selectedId, toggleNode, selectNode };
|
|
206
206
|
};
|
|
207
207
|
`,
|
|
208
208
|
buildReactBody: (name, pascal) => ` <div className="${name}__tree">
|
|
@@ -235,11 +235,11 @@ export interface ${pascal}State {
|
|
|
235
235
|
`,
|
|
236
236
|
buildController: (name, pascal) => `import { useState } from 'react';
|
|
237
237
|
|
|
238
|
-
export const use${pascal}Controller = () => {
|
|
238
|
+
export const use${pascal}Controller = (options: { series?: readonly ${pascal}DataPoint[]; timeRange?: '1h' | '24h' | '7d' | '30d' } = {}) => {
|
|
239
239
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
240
240
|
const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
|
|
241
241
|
|
|
242
|
-
return { activeIndex, timeRange, setActiveIndex, setTimeRange };
|
|
242
|
+
return { series: options.series || [], activeIndex, timeRange, setActiveIndex, setTimeRange };
|
|
243
243
|
};
|
|
244
244
|
`,
|
|
245
245
|
buildReactBody: (name, pascal) => ` <div className="${name}__toolbar">
|
|
@@ -274,9 +274,9 @@ export interface ${pascal}Column {
|
|
|
274
274
|
}
|
|
275
275
|
`,
|
|
276
276
|
buildController: (name, pascal) => `import { useState } from 'react';
|
|
277
|
-
import type { ${pascal}Card } from './types';
|
|
277
|
+
import type { ${pascal}Card, ${pascal}Column } from './types';
|
|
278
278
|
|
|
279
|
-
export const use${pascal}Controller = (options: { cards?: readonly ${pascal}Card[]; onCardMove?: (id: string, colId: string) => void } = {}) => {
|
|
279
|
+
export const use${pascal}Controller = (options: { columns?: readonly ${pascal}Column[]; cards?: readonly ${pascal}Card[]; onCardMove?: (id: string, colId: string) => void } = {}) => {
|
|
280
280
|
const [cards, setCards] = useState<readonly ${pascal}Card[]>(options.cards || []);
|
|
281
281
|
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
|
282
282
|
|
|
@@ -286,7 +286,7 @@ export const use${pascal}Controller = (options: { cards?: readonly ${pascal}Card
|
|
|
286
286
|
options.onCardMove?.(cardId, toColumnId);
|
|
287
287
|
};
|
|
288
288
|
|
|
289
|
-
return { cards, activeCardId, setActiveCardId, moveCard };
|
|
289
|
+
return { columns: options.columns || [], cards, activeCardId, setActiveCardId, moveCard };
|
|
290
290
|
};
|
|
291
291
|
`,
|
|
292
292
|
buildReactBody: (name, pascal) => ` <div className="${name}__columns">
|