@oamm/textor 1.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 +399 -0
- package/dist/bin/textor.d.ts +2 -0
- package/dist/bin/textor.js +3819 -0
- package/dist/bin/textor.js.map +1 -0
- package/dist/index.cjs +3202 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +3190 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,3819 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { mkdir, writeFile, readFile, unlink, rm, rmdir, rename, readdir, stat, open } from 'fs/promises';
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { exec } from 'child_process';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR$1 = '.textor';
|
|
11
|
+
const CONFIG_FILE = 'config.json';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} TextorConfig
|
|
15
|
+
* @property {Object} paths
|
|
16
|
+
* @property {string} paths.pages
|
|
17
|
+
* @property {string} paths.features
|
|
18
|
+
* @property {string} paths.components
|
|
19
|
+
* @property {string} paths.layouts
|
|
20
|
+
* @property {Object} routing
|
|
21
|
+
* @property {string} routing.mode
|
|
22
|
+
* @property {string} routing.indexFile
|
|
23
|
+
* @property {Object} importAliases
|
|
24
|
+
* @property {string} importAliases.layouts
|
|
25
|
+
* @property {string} importAliases.features
|
|
26
|
+
* @property {Object} naming
|
|
27
|
+
* @property {string} naming.routeExtension
|
|
28
|
+
* @property {string} naming.featureExtension
|
|
29
|
+
* @property {string} naming.componentExtension
|
|
30
|
+
* @property {string} naming.hookExtension
|
|
31
|
+
* @property {string} naming.testExtension
|
|
32
|
+
* @property {Object} signatures
|
|
33
|
+
* @property {string} signatures.astro
|
|
34
|
+
* @property {string} signatures.typescript
|
|
35
|
+
* @property {string} signatures.javascript
|
|
36
|
+
* @property {Object} features
|
|
37
|
+
* @property {string} features.entry
|
|
38
|
+
* @property {boolean} features.createSubComponentsDir
|
|
39
|
+
* @property {boolean} features.createScriptsDir
|
|
40
|
+
* @property {string} features.scriptsIndexFile
|
|
41
|
+
* @property {Object} components
|
|
42
|
+
* @property {boolean} components.createSubComponentsDir
|
|
43
|
+
* @property {boolean} components.createContext
|
|
44
|
+
* @property {boolean} components.createHook
|
|
45
|
+
* @property {boolean} components.createTests
|
|
46
|
+
* @property {boolean} components.createConfig
|
|
47
|
+
* @property {boolean} components.createConstants
|
|
48
|
+
* @property {boolean} components.createTypes
|
|
49
|
+
* @property {Object} formatting
|
|
50
|
+
* @property {string} formatting.tool
|
|
51
|
+
* @property {Object} git
|
|
52
|
+
* @property {boolean} git.requireCleanRepo
|
|
53
|
+
* @property {boolean} git.stageChanges
|
|
54
|
+
* @property {Object} presets
|
|
55
|
+
* @property {string} defaultPreset
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default configuration for Textor
|
|
60
|
+
* @type {TextorConfig}
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_CONFIG = {
|
|
63
|
+
paths: {
|
|
64
|
+
pages: 'src/pages',
|
|
65
|
+
features: 'src/features',
|
|
66
|
+
components: 'src/components',
|
|
67
|
+
layouts: 'src/layouts'
|
|
68
|
+
},
|
|
69
|
+
routing: {
|
|
70
|
+
mode: 'flat', // 'flat' | 'nested'
|
|
71
|
+
indexFile: 'index.astro'
|
|
72
|
+
},
|
|
73
|
+
importAliases: {},
|
|
74
|
+
naming: {
|
|
75
|
+
routeExtension: '.astro',
|
|
76
|
+
featureExtension: '.astro',
|
|
77
|
+
componentExtension: '.tsx',
|
|
78
|
+
hookExtension: '.ts',
|
|
79
|
+
testExtension: '.test.tsx'
|
|
80
|
+
},
|
|
81
|
+
signatures: {
|
|
82
|
+
astro: '<!-- @generated by Textor -->',
|
|
83
|
+
typescript: '// @generated by Textor',
|
|
84
|
+
javascript: '// @generated by Textor',
|
|
85
|
+
tsx: '// @generated by Textor'
|
|
86
|
+
},
|
|
87
|
+
features: {
|
|
88
|
+
framework: 'astro',
|
|
89
|
+
entry: 'pascal', // 'index' | 'pascal'
|
|
90
|
+
createSubComponentsDir: true,
|
|
91
|
+
createScriptsDir: true,
|
|
92
|
+
scriptsIndexFile: 'scripts/index.ts',
|
|
93
|
+
createApi: false,
|
|
94
|
+
createServices: false,
|
|
95
|
+
createSchemas: false,
|
|
96
|
+
createHooks: false,
|
|
97
|
+
createContext: false,
|
|
98
|
+
createTests: false,
|
|
99
|
+
createTypes: false,
|
|
100
|
+
createReadme: false,
|
|
101
|
+
createStories: false,
|
|
102
|
+
createIndex: false
|
|
103
|
+
},
|
|
104
|
+
components: {
|
|
105
|
+
framework: 'react',
|
|
106
|
+
createSubComponentsDir: true,
|
|
107
|
+
createContext: true,
|
|
108
|
+
createHook: true,
|
|
109
|
+
createTests: true,
|
|
110
|
+
createConfig: true,
|
|
111
|
+
createConstants: true,
|
|
112
|
+
createTypes: true,
|
|
113
|
+
createApi: false,
|
|
114
|
+
createServices: false,
|
|
115
|
+
createSchemas: false,
|
|
116
|
+
createReadme: false,
|
|
117
|
+
createStories: false
|
|
118
|
+
},
|
|
119
|
+
formatting: {
|
|
120
|
+
tool: 'none' // 'prettier' | 'biome' | 'none'
|
|
121
|
+
},
|
|
122
|
+
hashing: {
|
|
123
|
+
normalization: 'normalizeEOL', // 'none' | 'normalizeEOL' | 'stripGeneratedRegions'
|
|
124
|
+
useMarkers: false
|
|
125
|
+
},
|
|
126
|
+
git: {
|
|
127
|
+
requireCleanRepo: false,
|
|
128
|
+
stageChanges: false
|
|
129
|
+
},
|
|
130
|
+
kindRules: [],
|
|
131
|
+
presets: {
|
|
132
|
+
minimal: {
|
|
133
|
+
features: {
|
|
134
|
+
createSubComponentsDir: false,
|
|
135
|
+
createScriptsDir: false
|
|
136
|
+
},
|
|
137
|
+
components: {
|
|
138
|
+
createSubComponentsDir: false,
|
|
139
|
+
createContext: false,
|
|
140
|
+
createHook: false,
|
|
141
|
+
createTests: false,
|
|
142
|
+
createConfig: false,
|
|
143
|
+
createConstants: false,
|
|
144
|
+
createTypes: false
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
standard: {
|
|
148
|
+
features: {
|
|
149
|
+
createSubComponentsDir: true,
|
|
150
|
+
createScriptsDir: true
|
|
151
|
+
},
|
|
152
|
+
components: {
|
|
153
|
+
createSubComponentsDir: true,
|
|
154
|
+
createContext: true,
|
|
155
|
+
createHook: true,
|
|
156
|
+
createTests: true,
|
|
157
|
+
createConfig: true,
|
|
158
|
+
createConstants: true,
|
|
159
|
+
createTypes: true
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
senior: {
|
|
163
|
+
features: {
|
|
164
|
+
createSubComponentsDir: true,
|
|
165
|
+
createScriptsDir: true,
|
|
166
|
+
createApi: true,
|
|
167
|
+
createServices: true,
|
|
168
|
+
createSchemas: true,
|
|
169
|
+
createHooks: true,
|
|
170
|
+
createContext: true,
|
|
171
|
+
createTests: true,
|
|
172
|
+
createTypes: true,
|
|
173
|
+
createReadme: true,
|
|
174
|
+
createStories: true,
|
|
175
|
+
createIndex: true
|
|
176
|
+
},
|
|
177
|
+
components: {
|
|
178
|
+
createSubComponentsDir: true,
|
|
179
|
+
createContext: true,
|
|
180
|
+
createHook: true,
|
|
181
|
+
createTests: true,
|
|
182
|
+
createConfig: true,
|
|
183
|
+
createConstants: true,
|
|
184
|
+
createTypes: true,
|
|
185
|
+
createApi: true,
|
|
186
|
+
createServices: true,
|
|
187
|
+
createSchemas: true,
|
|
188
|
+
createReadme: true,
|
|
189
|
+
createStories: true
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
defaultPreset: 'standard'
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Gets the absolute path to the .textor directory.
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
function getConfigDir() {
|
|
201
|
+
return path.join(process.cwd(), CONFIG_DIR$1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Gets the absolute path to the config.json file.
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function getConfigPath() {
|
|
209
|
+
return path.join(getConfigDir(), CONFIG_FILE);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Loads the configuration from config.json and merges it with DEFAULT_CONFIG.
|
|
214
|
+
* @returns {Promise<TextorConfig>}
|
|
215
|
+
* @throws {Error} If configuration not found or invalid
|
|
216
|
+
*/
|
|
217
|
+
async function loadConfig() {
|
|
218
|
+
const configPath = getConfigPath();
|
|
219
|
+
|
|
220
|
+
if (!existsSync(configPath)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Textor configuration not found at ${configPath}\n` +
|
|
223
|
+
`Run 'textor init' to create it.`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const content = await readFile(configPath, 'utf-8');
|
|
229
|
+
const config = JSON.parse(content);
|
|
230
|
+
const merged = deepMerge(DEFAULT_CONFIG, config);
|
|
231
|
+
validateConfig(merged);
|
|
232
|
+
return merged;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error instanceof SyntaxError) {
|
|
235
|
+
throw new Error(`Failed to parse config: Invalid JSON at ${configPath}`);
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`Failed to load config: ${error.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Saves the configuration to config.json.
|
|
243
|
+
* @param {TextorConfig} config Configuration to save
|
|
244
|
+
* @param {boolean} [force=false] Whether to overwrite existing config
|
|
245
|
+
* @returns {Promise<string>} Path to the saved config file
|
|
246
|
+
* @throws {Error} If config exists and force is false
|
|
247
|
+
*/
|
|
248
|
+
async function saveConfig(config, force = false) {
|
|
249
|
+
const configPath = getConfigPath();
|
|
250
|
+
|
|
251
|
+
if (existsSync(configPath) && !force) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Configuration already exists at ${configPath}\n` +
|
|
254
|
+
`Use --force to overwrite.`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
validateConfig(config);
|
|
259
|
+
|
|
260
|
+
const configDir = getConfigDir();
|
|
261
|
+
if (!existsSync(configDir)) {
|
|
262
|
+
await mkdir(configDir, { recursive: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
266
|
+
return configPath;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Deeply merges source object into target object.
|
|
271
|
+
* @param {Object} target
|
|
272
|
+
* @param {Object} source
|
|
273
|
+
* @returns {Object} Merged object
|
|
274
|
+
*/
|
|
275
|
+
function deepMerge(target, source) {
|
|
276
|
+
const result = { ...target };
|
|
277
|
+
|
|
278
|
+
for (const key in source) {
|
|
279
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
280
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
281
|
+
result[key] = deepMerge(target[key] || {}, source[key]);
|
|
282
|
+
} else {
|
|
283
|
+
result[key] = source[key];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Validates the configuration object structure.
|
|
293
|
+
* @param {any} config
|
|
294
|
+
* @throws {Error} If config is invalid
|
|
295
|
+
*/
|
|
296
|
+
function validateConfig(config) {
|
|
297
|
+
if (!config || typeof config !== 'object') {
|
|
298
|
+
throw new Error('Invalid configuration: must be an object');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const requiredSections = ['paths', 'naming', 'signatures', 'importAliases'];
|
|
302
|
+
for (const section of requiredSections) {
|
|
303
|
+
if (!config[section] || typeof config[section] !== 'object') {
|
|
304
|
+
throw new Error(`Invalid configuration: missing or invalid "${section}" section`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (config.kindRules && !Array.isArray(config.kindRules)) {
|
|
309
|
+
throw new Error('Invalid configuration: "kindRules" must be an array');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate paths are strings
|
|
313
|
+
for (const [key, value] of Object.entries(config.paths)) {
|
|
314
|
+
if (typeof value !== 'string') {
|
|
315
|
+
throw new Error(`Invalid configuration: "paths.${key}" must be a string`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Validate naming extensions start with dot
|
|
320
|
+
for (const [key, value] of Object.entries(config.naming)) {
|
|
321
|
+
if (typeof value === 'string' && !value.startsWith('.') && value !== '') {
|
|
322
|
+
throw new Error(`Invalid configuration: "naming.${key}" should start with a dot (e.g., ".astro")`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resolves a configured path key to an absolute path.
|
|
329
|
+
* @param {TextorConfig} config
|
|
330
|
+
* @param {keyof TextorConfig['paths']} pathKey
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
function resolvePath(config, pathKey) {
|
|
334
|
+
const configuredPath = config.paths[pathKey];
|
|
335
|
+
if (!configuredPath) {
|
|
336
|
+
throw new Error(`Path "${pathKey}" not found in configuration`);
|
|
337
|
+
}
|
|
338
|
+
return path.resolve(process.cwd(), configuredPath);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Merges CLI options, preset defaults, and global config.
|
|
343
|
+
* @param {Object} cmdOptions
|
|
344
|
+
* @param {TextorConfig} config
|
|
345
|
+
* @param {'features' | 'components'} type
|
|
346
|
+
*/
|
|
347
|
+
function getEffectiveOptions(cmdOptions, config, type) {
|
|
348
|
+
const presetName = cmdOptions.preset || config.defaultPreset || 'standard';
|
|
349
|
+
const preset = config.presets[presetName] || config.presets['standard'] || {};
|
|
350
|
+
const presetTypeOptions = preset[type] || {};
|
|
351
|
+
const configTypeOptions = config[type] || {};
|
|
352
|
+
|
|
353
|
+
const merged = { ...configTypeOptions, ...presetTypeOptions };
|
|
354
|
+
|
|
355
|
+
// Explicit CLI flags should override
|
|
356
|
+
// Commander uses camelCase for flags like --no-sub-components-dir -> subComponentsDir
|
|
357
|
+
for (const key in merged) {
|
|
358
|
+
if (cmdOptions[key] !== undefined) {
|
|
359
|
+
merged[key] = cmdOptions[key];
|
|
360
|
+
} else if (key.startsWith('create')) {
|
|
361
|
+
// Try mapping short flags to "create" prefix
|
|
362
|
+
// e.g., CLI --api (cmdOptions.api) -> config createApi
|
|
363
|
+
const shortKey = key.slice(6).charAt(0).toLowerCase() + key.slice(7);
|
|
364
|
+
if (cmdOptions[shortKey] !== undefined) {
|
|
365
|
+
merged[key] = cmdOptions[shortKey];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return merged;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function initCommand(options) {
|
|
374
|
+
try {
|
|
375
|
+
const configPath = await saveConfig(DEFAULT_CONFIG, options.force);
|
|
376
|
+
|
|
377
|
+
console.log('✓ Textor configuration created at:', configPath);
|
|
378
|
+
console.log('\nDefault configuration:');
|
|
379
|
+
console.log(JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
380
|
+
console.log('\nYou can now use Textor commands like:');
|
|
381
|
+
console.log(' textor add-section /users users/catalog --layout Main');
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error('Error:', error.message);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function toPascalCase(input) {
|
|
389
|
+
return input
|
|
390
|
+
.split(/[/\\_-]/)
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.map(segment => {
|
|
393
|
+
if (segment === segment.toUpperCase() && segment.length > 1) {
|
|
394
|
+
segment = segment.toLowerCase();
|
|
395
|
+
}
|
|
396
|
+
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
397
|
+
})
|
|
398
|
+
.join('');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function getFeatureComponentName(featurePath) {
|
|
402
|
+
return toPascalCase(featurePath);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getHookFunctionName(componentName) {
|
|
406
|
+
return 'use' + componentName;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getHookFileName(componentName, extension = '.ts') {
|
|
410
|
+
return getHookFunctionName(componentName) + extension;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function normalizeComponentName(name) {
|
|
414
|
+
return toPascalCase(name);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function normalizeRoute(route) {
|
|
418
|
+
let normalized = route.trim();
|
|
419
|
+
|
|
420
|
+
if (!normalized.startsWith('/')) {
|
|
421
|
+
normalized = '/' + normalized;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
425
|
+
normalized = normalized.slice(0, -1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return normalized;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function routeToFilePath(route, options = {}) {
|
|
432
|
+
const { extension = '.astro', mode = 'flat', indexFile = 'index.astro' } = options;
|
|
433
|
+
const normalized = normalizeRoute(route);
|
|
434
|
+
|
|
435
|
+
if (normalized === '/') {
|
|
436
|
+
return indexFile;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const routePath = normalized.slice(1);
|
|
440
|
+
if (mode === 'nested') {
|
|
441
|
+
return path.join(routePath, indexFile);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return routePath + extension;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function featureToDirectoryPath(featurePath) {
|
|
448
|
+
return featurePath.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function getFeatureFileName(featurePath, options = {}) {
|
|
452
|
+
const { extension = '.astro', strategy = 'index' } = options;
|
|
453
|
+
if (strategy === 'pascal') {
|
|
454
|
+
return getFeatureComponentName(featurePath) + extension;
|
|
455
|
+
}
|
|
456
|
+
return 'index' + extension;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Calculates a relative import path from one file to another.
|
|
461
|
+
* @param {string} fromFile The absolute path of the file containing the import
|
|
462
|
+
* @param {string} toFile The absolute path of the file being imported
|
|
463
|
+
* @returns {string} The relative import path
|
|
464
|
+
*/
|
|
465
|
+
function getRelativeImportPath(fromFile, toFile) {
|
|
466
|
+
let relativePath = path.relative(path.dirname(fromFile), toFile);
|
|
467
|
+
|
|
468
|
+
// Convert backslashes to forward slashes for imports
|
|
469
|
+
relativePath = relativePath.split(path.sep).join('/');
|
|
470
|
+
|
|
471
|
+
// Ensure it starts with ./ or ../
|
|
472
|
+
if (!relativePath.startsWith('.')) {
|
|
473
|
+
relativePath = './' + relativePath;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return relativePath;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const execAsync$1 = promisify(exec);
|
|
480
|
+
|
|
481
|
+
function calculateHash(content, normalization = 'normalizeEOL') {
|
|
482
|
+
let normalizedContent = content;
|
|
483
|
+
|
|
484
|
+
if (normalization === 'stripGeneratedRegions') {
|
|
485
|
+
// Extract all content between :begin and :end markers
|
|
486
|
+
const beginMarker = /@generated by Textor:begin/g;
|
|
487
|
+
const endMarker = /@generated by Textor:end/g;
|
|
488
|
+
|
|
489
|
+
const regions = [];
|
|
490
|
+
let match;
|
|
491
|
+
const beginIndices = [];
|
|
492
|
+
while ((match = beginMarker.exec(content)) !== null) {
|
|
493
|
+
beginIndices.push(match.index + match[0].length);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const endIndices = [];
|
|
497
|
+
while ((match = endMarker.exec(content)) !== null) {
|
|
498
|
+
endIndices.push(match.index);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (beginIndices.length > 0 && beginIndices.length === endIndices.length) {
|
|
502
|
+
for (let i = 0; i < beginIndices.length; i++) {
|
|
503
|
+
regions.push(content.slice(beginIndices[i], endIndices[i]));
|
|
504
|
+
}
|
|
505
|
+
normalizedContent = regions.join('\n');
|
|
506
|
+
}
|
|
507
|
+
// Fall back to full content if markers are missing or mismatched
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (normalization === 'normalizeEOL' || normalization === 'stripGeneratedRegions') {
|
|
511
|
+
normalizedContent = normalizedContent.replace(/\r\n/g, '\n');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return createHash('sha256').update(normalizedContent).digest('hex');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function isTextorGenerated(filePath, customSignatures = []) {
|
|
518
|
+
if (!existsSync(filePath)) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const content = await readFile(filePath, 'utf-8');
|
|
524
|
+
const signatures = ['@generated by Textor', ...customSignatures];
|
|
525
|
+
return signatures.some(sig => content.includes(sig));
|
|
526
|
+
} catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function verifyFileIntegrity(filePath, expectedHash, options = {}) {
|
|
532
|
+
const {
|
|
533
|
+
force = false,
|
|
534
|
+
acceptChanges = false,
|
|
535
|
+
normalization = 'normalizeEOL',
|
|
536
|
+
owner = null,
|
|
537
|
+
actualOwner = null
|
|
538
|
+
} = options;
|
|
539
|
+
|
|
540
|
+
if (force) return { valid: true };
|
|
541
|
+
|
|
542
|
+
if (owner && actualOwner && owner !== actualOwner) {
|
|
543
|
+
return {
|
|
544
|
+
valid: false,
|
|
545
|
+
reason: 'wrong-owner',
|
|
546
|
+
message: `Refusing to operate on ${filePath} - owned by ${actualOwner}, but requested by ${owner}. Use --force to override.`
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const isGenerated = await isTextorGenerated(filePath);
|
|
551
|
+
if (!isGenerated) {
|
|
552
|
+
return {
|
|
553
|
+
valid: false,
|
|
554
|
+
reason: 'not-generated',
|
|
555
|
+
message: `Refusing to operate on ${filePath} - not generated by Textor. Use --force to override.`
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!expectedHash) {
|
|
560
|
+
return {
|
|
561
|
+
valid: false,
|
|
562
|
+
reason: 'not-in-state',
|
|
563
|
+
message: `Refusing to operate on ${filePath} - not found in Textor state. Use --force to override.`
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!acceptChanges) {
|
|
568
|
+
const content = await readFile(filePath, 'utf-8');
|
|
569
|
+
const currentHash = calculateHash(content, normalization);
|
|
570
|
+
if (currentHash !== expectedHash) {
|
|
571
|
+
return {
|
|
572
|
+
valid: false,
|
|
573
|
+
reason: 'hash-mismatch',
|
|
574
|
+
message: `Refusing to operate on ${filePath} - content has been modified. Use --accept-changes or --force to override.`
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return { valid: true };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function safeDelete(filePath, options = {}) {
|
|
583
|
+
const { force = false, expectedHash = null, acceptChanges = false, owner = null, actualOwner = null } = options;
|
|
584
|
+
|
|
585
|
+
if (!existsSync(filePath)) {
|
|
586
|
+
return { deleted: false, reason: 'not-found' };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const integrity = await verifyFileIntegrity(filePath, expectedHash, {
|
|
590
|
+
force,
|
|
591
|
+
acceptChanges,
|
|
592
|
+
owner,
|
|
593
|
+
actualOwner
|
|
594
|
+
});
|
|
595
|
+
if (!integrity.valid) {
|
|
596
|
+
return { deleted: false, reason: integrity.reason, message: integrity.message };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
await unlink(filePath);
|
|
600
|
+
return { deleted: true };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function ensureNotExists(filePath, force = false) {
|
|
604
|
+
if (existsSync(filePath)) {
|
|
605
|
+
if (!force) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
`File already exists: ${filePath}\n` +
|
|
608
|
+
`Use --force to overwrite.`
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function ensureDir(dirPath) {
|
|
615
|
+
await mkdir(dirPath, { recursive: true });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function isSafeToDeleteDir(dirPath, stateFiles = {}, options = {}) {
|
|
619
|
+
try {
|
|
620
|
+
const files = await readdir(dirPath);
|
|
621
|
+
|
|
622
|
+
const results = await Promise.all(
|
|
623
|
+
files.map(async file => {
|
|
624
|
+
const filePath = path.join(dirPath, file);
|
|
625
|
+
const stats = await stat(filePath);
|
|
626
|
+
|
|
627
|
+
if (stats.isDirectory()) {
|
|
628
|
+
return await isSafeToDeleteDir(filePath, stateFiles, options);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const normalizedPath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
632
|
+
const fileState = stateFiles[normalizedPath];
|
|
633
|
+
const integrity = await verifyFileIntegrity(filePath, fileState?.hash, {
|
|
634
|
+
...options,
|
|
635
|
+
actualOwner: fileState?.owner
|
|
636
|
+
});
|
|
637
|
+
return integrity.valid;
|
|
638
|
+
})
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
return results.every(Boolean);
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function safeDeleteDir(dirPath, options = {}) {
|
|
648
|
+
const { force = false, stateFiles = {} } = options;
|
|
649
|
+
if (!existsSync(dirPath)) {
|
|
650
|
+
return { deleted: false, reason: 'not-found' };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const isSafe = force || await isSafeToDeleteDir(dirPath, stateFiles, options);
|
|
654
|
+
|
|
655
|
+
if (isSafe) {
|
|
656
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
657
|
+
return { deleted: true };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
deleted: false,
|
|
662
|
+
reason: 'contains-non-generated-or-modified',
|
|
663
|
+
message: `Directory contains non-generated or modified files: ${dirPath}. Use --force to override.`
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function writeFileWithSignature(filePath, content, signature, normalization = 'normalizeEOL') {
|
|
668
|
+
await ensureDir(path.dirname(filePath));
|
|
669
|
+
|
|
670
|
+
let finalContent = signature + '\n' + content;
|
|
671
|
+
|
|
672
|
+
if (signature.includes(':begin')) {
|
|
673
|
+
const endSignature = signature.replace(':begin', ':end');
|
|
674
|
+
finalContent = signature + '\n' + content + '\n' + endSignature;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
await writeFile(filePath, finalContent, 'utf-8');
|
|
678
|
+
|
|
679
|
+
return calculateHash(finalContent, normalization);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function getSignature(config, type) {
|
|
683
|
+
const base = config.signatures[type] || config.signatures.typescript;
|
|
684
|
+
if (config.hashing?.useMarkers) {
|
|
685
|
+
return base.replace('Textor', 'Textor:begin');
|
|
686
|
+
}
|
|
687
|
+
return base;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function updateSignature(filePath, oldPath, newPath) {
|
|
691
|
+
if (!existsSync(filePath)) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let content = await readFile(filePath, 'utf-8');
|
|
696
|
+
|
|
697
|
+
content = content.replace(
|
|
698
|
+
new RegExp(oldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
|
699
|
+
newPath
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
await writeFile(filePath, content, 'utf-8');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function safeMove(fromPath, toPath, options = {}) {
|
|
706
|
+
const {
|
|
707
|
+
force = false,
|
|
708
|
+
expectedHash = null,
|
|
709
|
+
acceptChanges = false,
|
|
710
|
+
normalization = 'normalizeEOL',
|
|
711
|
+
owner = null,
|
|
712
|
+
actualOwner = null
|
|
713
|
+
} = options;
|
|
714
|
+
|
|
715
|
+
if (!existsSync(fromPath)) {
|
|
716
|
+
throw new Error(`Source file not found: ${fromPath}`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (existsSync(toPath) && !force) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
`Destination already exists: ${toPath}\n` +
|
|
722
|
+
`Use --force to overwrite.`
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const integrity = await verifyFileIntegrity(fromPath, expectedHash, {
|
|
727
|
+
force,
|
|
728
|
+
acceptChanges,
|
|
729
|
+
normalization,
|
|
730
|
+
owner,
|
|
731
|
+
actualOwner
|
|
732
|
+
});
|
|
733
|
+
if (!integrity.valid) {
|
|
734
|
+
throw new Error(integrity.message);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
await ensureDir(path.dirname(toPath));
|
|
738
|
+
await rename(fromPath, toPath);
|
|
739
|
+
|
|
740
|
+
await updateSignature(toPath, fromPath, toPath);
|
|
741
|
+
|
|
742
|
+
// Return new hash because updateSignature might have changed it
|
|
743
|
+
const content = await readFile(toPath, 'utf-8');
|
|
744
|
+
return calculateHash(content, normalization);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function isEmptyDir(dirPath) {
|
|
748
|
+
if (!existsSync(dirPath)) {
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const files = await readdir(dirPath);
|
|
753
|
+
return files.length === 0;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function scanDirectory(dir, fileSet) {
|
|
757
|
+
const files = await readdir(dir);
|
|
758
|
+
for (const file of files) {
|
|
759
|
+
const fullPath = path.join(dir, file);
|
|
760
|
+
const stats = await stat(fullPath);
|
|
761
|
+
if (stats.isDirectory()) {
|
|
762
|
+
if (file === 'node_modules' || file === '.git' || file === '.textor') continue;
|
|
763
|
+
await scanDirectory(fullPath, fileSet);
|
|
764
|
+
} else {
|
|
765
|
+
const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
766
|
+
fileSet.add(relativePath);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function inferKind(filePath, config) {
|
|
772
|
+
const normalizedFilePath = path.resolve(filePath).replace(/\\/g, '/');
|
|
773
|
+
const relativeFromCwd = path.relative(process.cwd(), normalizedFilePath).replace(/\\/g, '/');
|
|
774
|
+
|
|
775
|
+
// Check kindRules first (precedence)
|
|
776
|
+
if (config.kindRules && Array.isArray(config.kindRules)) {
|
|
777
|
+
for (const rule of config.kindRules) {
|
|
778
|
+
if (rule.match && rule.kind) {
|
|
779
|
+
// Simple glob-to-regex conversion for ** and *
|
|
780
|
+
const regexStr = rule.match
|
|
781
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
|
|
782
|
+
.replace(/\*\*/g, '(.+)') // ** matches anything
|
|
783
|
+
.replace(/\*/g, '([^/]+)'); // * matches one segment
|
|
784
|
+
|
|
785
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
786
|
+
if (regex.test(relativeFromCwd) || regex.test(normalizedFilePath)) {
|
|
787
|
+
return rule.kind;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const pagesRoot = path.resolve(process.cwd(), config.paths.pages || 'src/pages').replace(/\\/g, '/');
|
|
794
|
+
const featuresRoot = path.resolve(process.cwd(), config.paths.features || 'src/features').replace(/\\/g, '/');
|
|
795
|
+
const componentsRoot = path.resolve(process.cwd(), config.paths.components || 'src/components').replace(/\\/g, '/');
|
|
796
|
+
|
|
797
|
+
const ext = path.extname(normalizedFilePath);
|
|
798
|
+
|
|
799
|
+
if (normalizedFilePath.startsWith(pagesRoot)) {
|
|
800
|
+
if (ext === '.ts' || ext === '.js') return 'endpoint';
|
|
801
|
+
return 'route';
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (normalizedFilePath.startsWith(featuresRoot)) {
|
|
805
|
+
const relativePath = path.relative(featuresRoot, normalizedFilePath).replace(/\\/g, '/');
|
|
806
|
+
const parts = relativePath.split('/');
|
|
807
|
+
|
|
808
|
+
// If it's directly in the features root
|
|
809
|
+
if (parts.length === 1) return 'feature';
|
|
810
|
+
|
|
811
|
+
const fileName = parts[parts.length - 1];
|
|
812
|
+
const featureDir = path.dirname(relativePath).replace(/\\/g, '/');
|
|
813
|
+
|
|
814
|
+
// Main feature file can be FeatureName.astro or index.astro
|
|
815
|
+
const featureName = toPascalCase(featureDir);
|
|
816
|
+
const possiblePascalName = featureName + (config.naming.featureExtension || '.astro');
|
|
817
|
+
const possibleIndexName = 'index' + (config.naming.featureExtension || '.astro');
|
|
818
|
+
|
|
819
|
+
if (path.dirname(relativePath) !== '.' && (fileName === possiblePascalName || fileName === possibleIndexName)) {
|
|
820
|
+
// It's in the top level of its feature directory
|
|
821
|
+
if (parts.length === (featureDir.split('/').length + 1)) {
|
|
822
|
+
return 'feature';
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return 'feature-file';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (normalizedFilePath.startsWith(componentsRoot)) {
|
|
830
|
+
const relativePath = path.relative(componentsRoot, normalizedFilePath).replace(/\\/g, '/');
|
|
831
|
+
const parts = relativePath.split('/');
|
|
832
|
+
|
|
833
|
+
if (parts.length === 1) return 'component';
|
|
834
|
+
|
|
835
|
+
const componentDir = parts[0];
|
|
836
|
+
const fileName = parts[parts.length - 1];
|
|
837
|
+
const componentName = toPascalCase(componentDir);
|
|
838
|
+
|
|
839
|
+
const possibleComponentName = componentName + (config.naming.componentExtension || '.tsx');
|
|
840
|
+
const possibleIndexName = 'index' + (config.naming.componentExtension || '.tsx');
|
|
841
|
+
|
|
842
|
+
if (parts.length === 2 && (fileName === possibleComponentName || fileName === possibleIndexName)) {
|
|
843
|
+
return 'component';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return 'component-file';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return 'unknown';
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Safely joins path segments and ensures the result is within the basePath.
|
|
854
|
+
* @param {string} basePath The base directory that must contain the result
|
|
855
|
+
* @param {...string} segments Path segments to join
|
|
856
|
+
* @returns {string} The joined path
|
|
857
|
+
* @throws {Error} If a path traversal attempt is detected
|
|
858
|
+
*/
|
|
859
|
+
function secureJoin(basePath, ...segments) {
|
|
860
|
+
const joinedPath = path.join(basePath, ...segments);
|
|
861
|
+
const resolvedBase = path.resolve(basePath);
|
|
862
|
+
const resolvedJoined = path.resolve(joinedPath);
|
|
863
|
+
|
|
864
|
+
const relative = path.relative(resolvedBase, resolvedJoined);
|
|
865
|
+
|
|
866
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
867
|
+
throw new Error(`Security error: Path traversal attempt detected: ${joinedPath} is outside of ${basePath}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return joinedPath;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function cleanupEmptyDirs(dirPath, rootPath) {
|
|
874
|
+
const normalizedDir = path.resolve(dirPath);
|
|
875
|
+
const normalizedRoot = path.resolve(rootPath);
|
|
876
|
+
|
|
877
|
+
if (normalizedDir === normalizedRoot || !normalizedDir.startsWith(normalizedRoot)) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (await isEmptyDir(normalizedDir)) {
|
|
882
|
+
await rmdir(normalizedDir);
|
|
883
|
+
await cleanupEmptyDirs(path.dirname(normalizedDir), rootPath);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Formats a list of files using the specified tool.
|
|
889
|
+
* @param {string[]} filePaths
|
|
890
|
+
* @param {'prettier' | 'biome' | 'none'} tool
|
|
891
|
+
*/
|
|
892
|
+
async function formatFiles(filePaths, tool) {
|
|
893
|
+
if (tool === 'none' || !filePaths.length) return;
|
|
894
|
+
|
|
895
|
+
// Quote paths to handle spaces
|
|
896
|
+
const paths = filePaths.map(p => `"${p}"`).join(' ');
|
|
897
|
+
|
|
898
|
+
if (tool === 'prettier') {
|
|
899
|
+
try {
|
|
900
|
+
await execAsync$1(`npx prettier --write ${paths}`);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
// Silently fail if prettier is not available or fails
|
|
903
|
+
}
|
|
904
|
+
} else if (tool === 'biome') {
|
|
905
|
+
try {
|
|
906
|
+
await execAsync$1(`npx biome format --write ${paths}`);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
// Silently fail
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
var filesystem = /*#__PURE__*/Object.freeze({
|
|
914
|
+
__proto__: null,
|
|
915
|
+
calculateHash: calculateHash,
|
|
916
|
+
cleanupEmptyDirs: cleanupEmptyDirs,
|
|
917
|
+
ensureDir: ensureDir,
|
|
918
|
+
ensureNotExists: ensureNotExists,
|
|
919
|
+
formatFiles: formatFiles,
|
|
920
|
+
getSignature: getSignature,
|
|
921
|
+
inferKind: inferKind,
|
|
922
|
+
isEmptyDir: isEmptyDir,
|
|
923
|
+
isTextorGenerated: isTextorGenerated,
|
|
924
|
+
safeDelete: safeDelete,
|
|
925
|
+
safeDeleteDir: safeDeleteDir,
|
|
926
|
+
safeMove: safeMove,
|
|
927
|
+
scanDirectory: scanDirectory,
|
|
928
|
+
secureJoin: secureJoin,
|
|
929
|
+
updateSignature: updateSignature,
|
|
930
|
+
verifyFileIntegrity: verifyFileIntegrity,
|
|
931
|
+
writeFileWithSignature: writeFileWithSignature
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
function getTemplateOverride(templateName, extension, data = {}) {
|
|
935
|
+
const overridePath = path.join(process.cwd(), '.textor', 'templates', `${templateName}${extension}`);
|
|
936
|
+
if (existsSync(overridePath)) {
|
|
937
|
+
let content = readFileSync(overridePath, 'utf-8');
|
|
938
|
+
for (const [key, value] of Object.entries(data)) {
|
|
939
|
+
content = content.replace(new RegExp(`{{${key}}}`, 'g'), () => value || '');
|
|
940
|
+
}
|
|
941
|
+
return content;
|
|
942
|
+
}
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Route Template Variables:
|
|
948
|
+
* - layoutName: The name of the layout component
|
|
949
|
+
* - layoutImportPath: Path to import the layout
|
|
950
|
+
* - featureImportPath: Path to import the feature component
|
|
951
|
+
* - featureComponentName: Name of the feature component
|
|
952
|
+
*/
|
|
953
|
+
function generateRouteTemplate(layoutName, layoutImportPath, featureImportPath, featureComponentName) {
|
|
954
|
+
const override = getTemplateOverride('route', '.astro', {
|
|
955
|
+
layoutName,
|
|
956
|
+
layoutImportPath,
|
|
957
|
+
featureImportPath,
|
|
958
|
+
featureComponentName
|
|
959
|
+
});
|
|
960
|
+
if (override) return override;
|
|
961
|
+
|
|
962
|
+
if (layoutName === 'none') {
|
|
963
|
+
return `---
|
|
964
|
+
import ${featureComponentName} from '${featureImportPath}';
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
<${featureComponentName} />
|
|
968
|
+
`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return `---
|
|
972
|
+
import ${layoutName} from '${layoutImportPath}';
|
|
973
|
+
import ${featureComponentName} from '${featureImportPath}';
|
|
974
|
+
---
|
|
975
|
+
|
|
976
|
+
<${layoutName}>
|
|
977
|
+
<${featureComponentName} />
|
|
978
|
+
</${layoutName}>
|
|
979
|
+
`;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Feature Template Variables:
|
|
984
|
+
* - componentName: Name of the feature component
|
|
985
|
+
* - scriptImportPath: Path to the feature's client-side script
|
|
986
|
+
*/
|
|
987
|
+
function generateFeatureTemplate(componentName, scriptImportPath, framework = 'astro') {
|
|
988
|
+
const extension = framework === 'astro' ? '.astro' : '.tsx';
|
|
989
|
+
const override = getTemplateOverride('feature', extension, { componentName, scriptImportPath });
|
|
990
|
+
if (override) return override;
|
|
991
|
+
|
|
992
|
+
if (framework === 'react') {
|
|
993
|
+
return `export type ${componentName}Props = {
|
|
994
|
+
// Add props here
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export default function ${componentName}({ }: ${componentName}Props) {
|
|
998
|
+
return (
|
|
999
|
+
<div className="${componentName.toLowerCase()}">
|
|
1000
|
+
<h1>${componentName}</h1>
|
|
1001
|
+
</div>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const scriptTag = scriptImportPath ? `\n<script src="${scriptImportPath}"></script>` : '';
|
|
1008
|
+
|
|
1009
|
+
return `---
|
|
1010
|
+
// Feature: ${componentName}
|
|
1011
|
+
---
|
|
1012
|
+
|
|
1013
|
+
<div class="${componentName.toLowerCase()}">
|
|
1014
|
+
<h1>${componentName}</h1>
|
|
1015
|
+
</div>${scriptTag}
|
|
1016
|
+
`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Scripts Index Template (no variables)
|
|
1021
|
+
*/
|
|
1022
|
+
function generateScriptsIndexTemplate() {
|
|
1023
|
+
const override = getTemplateOverride('scripts-index', '.ts');
|
|
1024
|
+
if (override) return override;
|
|
1025
|
+
|
|
1026
|
+
return `export {};
|
|
1027
|
+
`;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Component Template Variables:
|
|
1032
|
+
* - componentName: Name of the component
|
|
1033
|
+
*/
|
|
1034
|
+
function generateComponentTemplate(componentName, framework = 'react') {
|
|
1035
|
+
const extension = framework === 'astro' ? '.astro' : '.tsx';
|
|
1036
|
+
const override = getTemplateOverride('component', extension, { componentName });
|
|
1037
|
+
if (override) return override;
|
|
1038
|
+
|
|
1039
|
+
if (framework === 'react') {
|
|
1040
|
+
return `export type ${componentName}Props = {
|
|
1041
|
+
// Add props here
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export default function ${componentName}({ }: ${componentName}Props) {
|
|
1045
|
+
return (
|
|
1046
|
+
<div className="${componentName.toLowerCase()}">
|
|
1047
|
+
{/* ${componentName} implementation */}
|
|
1048
|
+
</div>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return `---
|
|
1055
|
+
export type Props = {
|
|
1056
|
+
// Add props here
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const props = Astro.props;
|
|
1060
|
+
---
|
|
1061
|
+
|
|
1062
|
+
<div class="${componentName.toLowerCase()}">
|
|
1063
|
+
<!-- ${componentName} implementation -->
|
|
1064
|
+
</div>
|
|
1065
|
+
`;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Hook Template Variables:
|
|
1070
|
+
* - componentName: Name of the component
|
|
1071
|
+
* - hookName: Name of the hook function (e.g., useButton)
|
|
1072
|
+
*/
|
|
1073
|
+
function generateHookTemplate(componentName, hookName) {
|
|
1074
|
+
const override = getTemplateOverride('hook', '.ts', { componentName, hookName });
|
|
1075
|
+
if (override) return override;
|
|
1076
|
+
|
|
1077
|
+
return `import { useState } from 'react';
|
|
1078
|
+
|
|
1079
|
+
export function ${hookName}() {
|
|
1080
|
+
// Add hook logic here
|
|
1081
|
+
|
|
1082
|
+
return {
|
|
1083
|
+
// Return hook values
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Context Template Variables:
|
|
1091
|
+
* - componentName: Name of the component
|
|
1092
|
+
*/
|
|
1093
|
+
function generateContextTemplate(componentName) {
|
|
1094
|
+
const override = getTemplateOverride('context', '.tsx', { componentName });
|
|
1095
|
+
if (override) return override;
|
|
1096
|
+
|
|
1097
|
+
return `import { createContext, useContext } from 'react';
|
|
1098
|
+
|
|
1099
|
+
//@ts-ignore
|
|
1100
|
+
type ${componentName}ContextValue = {
|
|
1101
|
+
// Add context value types here
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const ${componentName}Context = createContext<${componentName}ContextValue | undefined>(undefined);
|
|
1105
|
+
|
|
1106
|
+
export function ${componentName}Provider({ children }: { children: React.ReactNode }) {
|
|
1107
|
+
const value: ${componentName}ContextValue = {
|
|
1108
|
+
// Provide context values
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
return (
|
|
1112
|
+
<${componentName}Context.Provider value={value}>
|
|
1113
|
+
{children}
|
|
1114
|
+
</${componentName}Context.Provider>
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export function use${componentName}Context() {
|
|
1119
|
+
const context = useContext(${componentName}Context);
|
|
1120
|
+
if (context === undefined) {
|
|
1121
|
+
throw new Error('use${componentName}Context must be used within ${componentName}Provider');
|
|
1122
|
+
}
|
|
1123
|
+
return context;
|
|
1124
|
+
}
|
|
1125
|
+
`;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Test Template Variables:
|
|
1130
|
+
* - componentName: Name of the component
|
|
1131
|
+
* - componentPath: Relative path to the component file
|
|
1132
|
+
*/
|
|
1133
|
+
function generateTestTemplate(componentName, componentPath) {
|
|
1134
|
+
const override = getTemplateOverride('test', '.tsx', { componentName, componentPath });
|
|
1135
|
+
if (override) return override;
|
|
1136
|
+
|
|
1137
|
+
return `import { describe, it, expect } from 'vitest';
|
|
1138
|
+
import { render, screen } from '@testing-library/react';
|
|
1139
|
+
import ${componentName} from '${componentPath}';
|
|
1140
|
+
|
|
1141
|
+
describe('${componentName}', () => {
|
|
1142
|
+
it('renders without crashing', () => {
|
|
1143
|
+
render(<${componentName} />);
|
|
1144
|
+
expect(screen.getByText('${componentName}')).toBeInTheDocument();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Config Template Variables:
|
|
1152
|
+
* - componentName: Name of the component
|
|
1153
|
+
*/
|
|
1154
|
+
function generateConfigTemplate(componentName) {
|
|
1155
|
+
const override = getTemplateOverride('config', '.ts', { componentName });
|
|
1156
|
+
if (override) return override;
|
|
1157
|
+
|
|
1158
|
+
return `export const ${componentName}Config = {
|
|
1159
|
+
// Add configuration here
|
|
1160
|
+
};
|
|
1161
|
+
`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Constants Template Variables:
|
|
1166
|
+
* - componentName: Name of the component
|
|
1167
|
+
*/
|
|
1168
|
+
function generateConstantsTemplate(componentName) {
|
|
1169
|
+
const override = getTemplateOverride('constants', '.ts', { componentName });
|
|
1170
|
+
if (override) return override;
|
|
1171
|
+
|
|
1172
|
+
return `export const ${componentName.toUpperCase()}_CONSTANTS = {
|
|
1173
|
+
// Add constants here
|
|
1174
|
+
};
|
|
1175
|
+
`;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Index Template Variables:
|
|
1180
|
+
* - componentName: Name of the component
|
|
1181
|
+
* - componentExtension: File extension of the component
|
|
1182
|
+
*/
|
|
1183
|
+
function generateIndexTemplate(componentName, componentExtension) {
|
|
1184
|
+
const override = getTemplateOverride('index', '.ts', { componentName, componentExtension });
|
|
1185
|
+
if (override) return override;
|
|
1186
|
+
|
|
1187
|
+
return `export { default as ${componentName} } from './${componentName}${componentExtension}';
|
|
1188
|
+
export * from './types';
|
|
1189
|
+
`;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Types Template Variables:
|
|
1194
|
+
* - componentName: Name of the component
|
|
1195
|
+
*/
|
|
1196
|
+
function generateTypesTemplate(componentName) {
|
|
1197
|
+
const override = getTemplateOverride('types', '.ts', { componentName });
|
|
1198
|
+
if (override) return override;
|
|
1199
|
+
|
|
1200
|
+
return `export type ${componentName}Props = {
|
|
1201
|
+
// Add props types here
|
|
1202
|
+
};
|
|
1203
|
+
`;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* API Template Variables:
|
|
1208
|
+
* - componentName: Name of the component
|
|
1209
|
+
*/
|
|
1210
|
+
function generateApiTemplate(componentName) {
|
|
1211
|
+
const override = getTemplateOverride('api', '.ts', { componentName });
|
|
1212
|
+
if (override) return override;
|
|
1213
|
+
|
|
1214
|
+
return `export function GET({ params, request }) {
|
|
1215
|
+
return new Response(
|
|
1216
|
+
JSON.stringify({
|
|
1217
|
+
name: "${componentName}",
|
|
1218
|
+
url: "https://astro.build/",
|
|
1219
|
+
}),
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
`;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Endpoint Template Variables:
|
|
1227
|
+
* - componentName: Name of the component
|
|
1228
|
+
*/
|
|
1229
|
+
function generateEndpointTemplate(componentName) {
|
|
1230
|
+
const override = getTemplateOverride('endpoint', '.ts', { componentName });
|
|
1231
|
+
if (override) return override;
|
|
1232
|
+
|
|
1233
|
+
return `export function GET({ params, request }) {
|
|
1234
|
+
return new Response(
|
|
1235
|
+
JSON.stringify({
|
|
1236
|
+
name: "${componentName}",
|
|
1237
|
+
url: "https://astro.build/",
|
|
1238
|
+
}),
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Service Template Variables:
|
|
1246
|
+
* - componentName: Name of the component
|
|
1247
|
+
*/
|
|
1248
|
+
function generateServiceTemplate(componentName) {
|
|
1249
|
+
const override = getTemplateOverride('service', '.ts', { componentName });
|
|
1250
|
+
if (override) return override;
|
|
1251
|
+
|
|
1252
|
+
return `// ${componentName} business logic and transformers
|
|
1253
|
+
export async function get${componentName}Data() {
|
|
1254
|
+
// Encapsulated logic for data processing
|
|
1255
|
+
return [];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export function transform${componentName}Data(data: any) {
|
|
1259
|
+
// Domain-specific data transformations
|
|
1260
|
+
return data;
|
|
1261
|
+
}
|
|
1262
|
+
`;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Schema Template Variables:
|
|
1267
|
+
* - componentName: Name of the component
|
|
1268
|
+
*/
|
|
1269
|
+
function generateSchemaTemplate(componentName) {
|
|
1270
|
+
const override = getTemplateOverride('schema', '.ts', { componentName });
|
|
1271
|
+
if (override) return override;
|
|
1272
|
+
|
|
1273
|
+
return `import { z } from 'zod';
|
|
1274
|
+
|
|
1275
|
+
export const ${componentName}Schema = z.object({
|
|
1276
|
+
id: z.string().uuid(),
|
|
1277
|
+
createdAt: z.string().datetime(),
|
|
1278
|
+
updatedAt: z.string().datetime().optional(),
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
export type ${componentName} = z.infer<typeof ${componentName}Schema>;
|
|
1282
|
+
`;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Readme Template Variables:
|
|
1287
|
+
* - componentName: Name of the component
|
|
1288
|
+
*/
|
|
1289
|
+
function generateReadmeTemplate(componentName) {
|
|
1290
|
+
const override = getTemplateOverride('readme', '.md', { componentName });
|
|
1291
|
+
if (override) return override;
|
|
1292
|
+
|
|
1293
|
+
return `# ${componentName}
|
|
1294
|
+
|
|
1295
|
+
## Description
|
|
1296
|
+
Brief description of what this feature/component does.
|
|
1297
|
+
|
|
1298
|
+
## Props/Usage
|
|
1299
|
+
How to use this and what are its requirements.
|
|
1300
|
+
`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Stories Template Variables:
|
|
1305
|
+
* - componentName: Name of the component
|
|
1306
|
+
* - componentPath: Relative path to the component file
|
|
1307
|
+
*/
|
|
1308
|
+
function generateStoriesTemplate(componentName, componentPath) {
|
|
1309
|
+
const override = getTemplateOverride('stories', '.tsx', { componentName, componentPath });
|
|
1310
|
+
if (override) return override;
|
|
1311
|
+
|
|
1312
|
+
return `import type { Meta, StoryObj } from '@storybook/react';
|
|
1313
|
+
import ${componentName} from '${componentPath}';
|
|
1314
|
+
|
|
1315
|
+
const meta: Meta<typeof ${componentName}> = {
|
|
1316
|
+
title: 'Components/${componentName}',
|
|
1317
|
+
component: ${componentName},
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
export default meta;
|
|
1321
|
+
type Story = StoryObj<typeof ${componentName}>;
|
|
1322
|
+
|
|
1323
|
+
export const Default: Story = {
|
|
1324
|
+
args: {
|
|
1325
|
+
// Default props
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const CONFIG_DIR = '.textor';
|
|
1332
|
+
const STATE_FILE = 'state.json';
|
|
1333
|
+
|
|
1334
|
+
function getStatePath() {
|
|
1335
|
+
return path.join(process.cwd(), CONFIG_DIR, STATE_FILE);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async function loadState() {
|
|
1339
|
+
const statePath = getStatePath();
|
|
1340
|
+
if (!existsSync(statePath)) {
|
|
1341
|
+
return { sections: [], components: [], files: {} };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
try {
|
|
1345
|
+
const content = await readFile(statePath, 'utf-8');
|
|
1346
|
+
const state = JSON.parse(content);
|
|
1347
|
+
if (!state.files) state.files = {};
|
|
1348
|
+
return state;
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
return { sections: [], components: [], files: {} };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
let saveQueue = Promise.resolve();
|
|
1355
|
+
|
|
1356
|
+
async function saveState(state) {
|
|
1357
|
+
const result = saveQueue.then(async () => {
|
|
1358
|
+
const statePath = getStatePath();
|
|
1359
|
+
const dir = path.dirname(statePath);
|
|
1360
|
+
if (!existsSync(dir)) {
|
|
1361
|
+
await mkdir(dir, { recursive: true });
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const tempPath = statePath + '.' + Math.random().toString(36).slice(2) + '.tmp';
|
|
1365
|
+
const content = JSON.stringify(state, null, 2);
|
|
1366
|
+
|
|
1367
|
+
const handle = await open(tempPath, 'w');
|
|
1368
|
+
await handle.writeFile(content, 'utf-8');
|
|
1369
|
+
await handle.sync();
|
|
1370
|
+
await handle.close();
|
|
1371
|
+
|
|
1372
|
+
await rename(tempPath, statePath);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
saveQueue = result.catch(() => {});
|
|
1376
|
+
return result;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function registerFile(filePath, { kind, template, hash, templateVersion = '1.0.0', owner = null }) {
|
|
1380
|
+
const state = await loadState();
|
|
1381
|
+
const normalizedPath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
1382
|
+
|
|
1383
|
+
state.files[normalizedPath] = {
|
|
1384
|
+
kind,
|
|
1385
|
+
template,
|
|
1386
|
+
hash,
|
|
1387
|
+
templateVersion,
|
|
1388
|
+
owner,
|
|
1389
|
+
timestamp: new Date().toISOString()
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1392
|
+
await saveState(state);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
async function addSectionToState(section) {
|
|
1396
|
+
const state = await loadState();
|
|
1397
|
+
// Avoid duplicates by route
|
|
1398
|
+
state.sections = state.sections.filter(s => s.route !== section.route);
|
|
1399
|
+
state.sections.push(section);
|
|
1400
|
+
await saveState(state);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
async function addComponentToState(component) {
|
|
1404
|
+
const state = await loadState();
|
|
1405
|
+
// Avoid duplicates by name
|
|
1406
|
+
state.components = state.components.filter(c => c.name !== component.name);
|
|
1407
|
+
state.components.push(component);
|
|
1408
|
+
await saveState(state);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function findSection(state, identifier) {
|
|
1412
|
+
return state.sections.find(s => s.route === identifier || s.name === identifier || s.featurePath === identifier);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function findComponent(state, name) {
|
|
1416
|
+
return state.components.find(c => c.name === name);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function reconstructComponents(files, config) {
|
|
1420
|
+
const componentsRoot = (config.paths.components || 'src/components').replace(/\\/g, '/');
|
|
1421
|
+
const components = new Map();
|
|
1422
|
+
|
|
1423
|
+
for (const filePath in files) {
|
|
1424
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
1425
|
+
if (normalizedPath === componentsRoot || normalizedPath.startsWith(componentsRoot + '/')) {
|
|
1426
|
+
const relativePath = normalizedPath === componentsRoot ? '' : normalizedPath.slice(componentsRoot.length + 1);
|
|
1427
|
+
if (relativePath === '') continue; // skip the root itself if it's in files for some reason
|
|
1428
|
+
|
|
1429
|
+
const parts = relativePath.split('/');
|
|
1430
|
+
if (parts.length >= 1) {
|
|
1431
|
+
const componentName = parts[0];
|
|
1432
|
+
const componentPath = `${componentsRoot}/${componentName}`;
|
|
1433
|
+
if (!components.has(componentName)) {
|
|
1434
|
+
components.set(componentName, {
|
|
1435
|
+
name: componentName,
|
|
1436
|
+
path: componentPath
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
return Array.from(components.values());
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function reconstructSections(state, config) {
|
|
1447
|
+
const pagesRoot = (config.paths.pages || 'src/pages').replace(/\\/g, '/');
|
|
1448
|
+
const featuresRoot = (config.paths.features || 'src/features').replace(/\\/g, '/');
|
|
1449
|
+
const files = state.files;
|
|
1450
|
+
|
|
1451
|
+
// Keep existing sections if their files still exist
|
|
1452
|
+
const validSections = (state.sections || []).filter(section => {
|
|
1453
|
+
// Check if route file exists in state.files
|
|
1454
|
+
const routeFile = Object.keys(files).find(f => {
|
|
1455
|
+
const normalizedF = f.replace(/\\/g, '/');
|
|
1456
|
+
const routePath = section.route === '/' ? 'index' : section.route.slice(1);
|
|
1457
|
+
return normalizedF.startsWith(pagesRoot + '/' + routePath + '.') ||
|
|
1458
|
+
normalizedF === pagesRoot + '/' + routePath + '/index.astro'; // nested mode
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
// Check if feature directory has at least one file in state.files
|
|
1462
|
+
const hasFeatureFiles = Object.keys(files).some(f =>
|
|
1463
|
+
f.replace(/\\/g, '/').startsWith(section.featurePath.replace(/\\/g, '/') + '/')
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
return routeFile && hasFeatureFiles;
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
const sections = new Map();
|
|
1470
|
+
validSections.forEach(s => sections.set(s.route, s));
|
|
1471
|
+
|
|
1472
|
+
// Try to discover new sections
|
|
1473
|
+
for (const filePath in files) {
|
|
1474
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
1475
|
+
if (normalizedPath.startsWith(pagesRoot + '/')) {
|
|
1476
|
+
const relativePath = normalizedPath.slice(pagesRoot.length + 1);
|
|
1477
|
+
const route = '/' + relativePath.replace(/\.(astro|ts|js|tsx|jsx)$/, '').replace(/\/index$/, '');
|
|
1478
|
+
const finalRoute = route === '' ? '/' : route;
|
|
1479
|
+
|
|
1480
|
+
if (!sections.has(finalRoute)) {
|
|
1481
|
+
// Try to find a matching feature by name
|
|
1482
|
+
const routeName = path.basename(finalRoute === '/' ? 'index' : finalRoute);
|
|
1483
|
+
// Look for a directory in features with same name or similar
|
|
1484
|
+
const possibleFeaturePath = Object.keys(files).find(f => {
|
|
1485
|
+
const nf = f.replace(/\\/g, '/');
|
|
1486
|
+
return nf.startsWith(featuresRoot + '/') && nf.includes('/' + routeName + '/');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
if (possibleFeaturePath) {
|
|
1490
|
+
const featurePathParts = possibleFeaturePath.replace(/\\/g, '/').split('/');
|
|
1491
|
+
const featuresBase = path.basename(featuresRoot);
|
|
1492
|
+
const featureIndex = featurePathParts.indexOf(featuresBase) + 1;
|
|
1493
|
+
|
|
1494
|
+
if (featureIndex > 0 && featureIndex < featurePathParts.length) {
|
|
1495
|
+
const featureName = featurePathParts[featureIndex];
|
|
1496
|
+
const featurePath = `${featuresRoot}/${featureName}`;
|
|
1497
|
+
|
|
1498
|
+
sections.set(finalRoute, {
|
|
1499
|
+
name: featureName,
|
|
1500
|
+
route: finalRoute,
|
|
1501
|
+
featurePath: featurePath,
|
|
1502
|
+
extension: path.extname(filePath)
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
return Array.from(sections.values());
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const execAsync = promisify(exec);
|
|
1514
|
+
|
|
1515
|
+
async function isRepoClean() {
|
|
1516
|
+
try {
|
|
1517
|
+
const { stdout } = await execAsync('git status --porcelain');
|
|
1518
|
+
return stdout.trim() === '';
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
// If not a git repo, we consider it clean or at least we can't check
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
async function stageFiles(filePaths) {
|
|
1526
|
+
if (!filePaths.length) return;
|
|
1527
|
+
try {
|
|
1528
|
+
const paths = filePaths.map(p => `"${p}"`).join(' ');
|
|
1529
|
+
await execAsync(`git add ${paths}`);
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
// Ignore errors if git is not available
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async function addSectionCommand(route, featurePath, options) {
|
|
1536
|
+
try {
|
|
1537
|
+
const config = await loadConfig();
|
|
1538
|
+
const effectiveOptions = getEffectiveOptions(options, config, 'features');
|
|
1539
|
+
|
|
1540
|
+
const normalizedRoute = normalizeRoute(route);
|
|
1541
|
+
const normalizedFeaturePath = featureToDirectoryPath(featurePath);
|
|
1542
|
+
|
|
1543
|
+
const pagesRoot = resolvePath(config, 'pages');
|
|
1544
|
+
const featuresRoot = resolvePath(config, 'features');
|
|
1545
|
+
const layoutsRoot = resolvePath(config, 'layouts');
|
|
1546
|
+
|
|
1547
|
+
const routeExtension = options.endpoint ? '.ts' : config.naming.routeExtension;
|
|
1548
|
+
|
|
1549
|
+
// Check if we should use nested mode even if config says flat
|
|
1550
|
+
// (because the directory already exists, suggesting it should be an index file)
|
|
1551
|
+
let effectiveRoutingMode = config.routing.mode;
|
|
1552
|
+
if (effectiveRoutingMode === 'flat') {
|
|
1553
|
+
const routeDirName = routeToFilePath(normalizedRoute, {
|
|
1554
|
+
extension: '',
|
|
1555
|
+
mode: 'flat'
|
|
1556
|
+
});
|
|
1557
|
+
const routeDirPath = secureJoin(pagesRoot, routeDirName);
|
|
1558
|
+
if (existsSync(routeDirPath)) {
|
|
1559
|
+
effectiveRoutingMode = 'nested';
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const routeFileName = routeToFilePath(normalizedRoute, {
|
|
1564
|
+
extension: routeExtension,
|
|
1565
|
+
mode: effectiveRoutingMode,
|
|
1566
|
+
indexFile: config.routing.indexFile
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
const featureComponentName = getFeatureComponentName(normalizedFeaturePath);
|
|
1570
|
+
const featureFileName = getFeatureFileName(normalizedFeaturePath, {
|
|
1571
|
+
extension: config.naming.featureExtension,
|
|
1572
|
+
strategy: effectiveOptions.entry
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
const routeFilePath = secureJoin(pagesRoot, routeFileName);
|
|
1576
|
+
const featureDirPath = secureJoin(featuresRoot, normalizedFeaturePath);
|
|
1577
|
+
const featureFilePath = secureJoin(featureDirPath, featureFileName);
|
|
1578
|
+
const scriptsIndexPath = secureJoin(featureDirPath, config.features.scriptsIndexFile);
|
|
1579
|
+
const subComponentsDir = secureJoin(featureDirPath, 'sub-components');
|
|
1580
|
+
const testsDir = secureJoin(featureDirPath, '__tests__');
|
|
1581
|
+
const contextDirInside = secureJoin(featureDirPath, 'context');
|
|
1582
|
+
const hooksDirInside = secureJoin(featureDirPath, 'hooks');
|
|
1583
|
+
const typesDirInside = secureJoin(featureDirPath, 'types');
|
|
1584
|
+
const apiDirInside = secureJoin(featureDirPath, 'api');
|
|
1585
|
+
const servicesDirInside = secureJoin(featureDirPath, 'services');
|
|
1586
|
+
const schemasDirInside = secureJoin(featureDirPath, 'schemas');
|
|
1587
|
+
|
|
1588
|
+
const {
|
|
1589
|
+
framework,
|
|
1590
|
+
createSubComponentsDir: shouldCreateSubComponentsDir,
|
|
1591
|
+
createScriptsDir: shouldCreateScriptsDir,
|
|
1592
|
+
createApi: shouldCreateApi,
|
|
1593
|
+
createServices: shouldCreateServices,
|
|
1594
|
+
createSchemas: shouldCreateSchemas,
|
|
1595
|
+
createHooks: shouldCreateHooks,
|
|
1596
|
+
createContext: shouldCreateContext,
|
|
1597
|
+
createTests: shouldCreateTests,
|
|
1598
|
+
createTypes: shouldCreateTypes,
|
|
1599
|
+
createReadme: shouldCreateReadme,
|
|
1600
|
+
createStories: shouldCreateStories,
|
|
1601
|
+
createIndex: shouldCreateIndex
|
|
1602
|
+
} = effectiveOptions;
|
|
1603
|
+
|
|
1604
|
+
const indexFilePath = path.join(featureDirPath, 'index.ts');
|
|
1605
|
+
const contextFilePath = path.join(contextDirInside, `${featureComponentName}Context.tsx`);
|
|
1606
|
+
const hookFilePath = path.join(hooksDirInside, getHookFileName(featureComponentName, config.naming.hookExtension));
|
|
1607
|
+
const testFilePath = path.join(testsDir, `${featureComponentName}${config.naming.testExtension}`);
|
|
1608
|
+
const typesFilePath = path.join(typesDirInside, 'index.ts');
|
|
1609
|
+
const apiFilePath = path.join(apiDirInside, 'index.ts');
|
|
1610
|
+
const servicesFilePath = path.join(servicesDirInside, 'index.ts');
|
|
1611
|
+
const schemasFilePath = path.join(schemasDirInside, 'index.ts');
|
|
1612
|
+
const readmeFilePath = path.join(featureDirPath, 'README.md');
|
|
1613
|
+
const storiesFilePath = path.join(featureDirPath, `${featureComponentName}.stories.tsx`);
|
|
1614
|
+
|
|
1615
|
+
const routeParts = normalizedRoute.split('/').filter(Boolean);
|
|
1616
|
+
const reorganizations = [];
|
|
1617
|
+
|
|
1618
|
+
if (routeParts.length > 1 && config.routing.mode === 'flat') {
|
|
1619
|
+
const possibleExtensions = ['.astro', '.ts', '.js', '.md', '.mdx', '.html'];
|
|
1620
|
+
for (let i = 1; i < routeParts.length; i++) {
|
|
1621
|
+
const parentRoute = '/' + routeParts.slice(0, i).join('/');
|
|
1622
|
+
|
|
1623
|
+
for (const ext of possibleExtensions) {
|
|
1624
|
+
const parentRouteFileName = routeToFilePath(parentRoute, {
|
|
1625
|
+
extension: ext,
|
|
1626
|
+
mode: 'flat'
|
|
1627
|
+
});
|
|
1628
|
+
const parentRouteFilePath = secureJoin(pagesRoot, parentRouteFileName);
|
|
1629
|
+
|
|
1630
|
+
if (existsSync(parentRouteFilePath)) {
|
|
1631
|
+
const indexFile = ext === '.astro' ? config.routing.indexFile : `index${ext}`;
|
|
1632
|
+
const newParentRouteFileName = routeToFilePath(parentRoute, {
|
|
1633
|
+
extension: ext,
|
|
1634
|
+
mode: 'nested',
|
|
1635
|
+
indexFile: indexFile
|
|
1636
|
+
});
|
|
1637
|
+
const newParentRouteFilePath = secureJoin(pagesRoot, newParentRouteFileName);
|
|
1638
|
+
|
|
1639
|
+
if (!existsSync(newParentRouteFilePath)) {
|
|
1640
|
+
reorganizations.push({
|
|
1641
|
+
from: parentRouteFilePath,
|
|
1642
|
+
to: newParentRouteFilePath,
|
|
1643
|
+
route: parentRoute
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (options.dryRun) {
|
|
1652
|
+
console.log('Dry run - would create:');
|
|
1653
|
+
console.log(` Route: ${routeFilePath}`);
|
|
1654
|
+
console.log(` Feature: ${featureFilePath}`);
|
|
1655
|
+
|
|
1656
|
+
for (const reorg of reorganizations) {
|
|
1657
|
+
console.log(` Reorganize: ${reorg.from} -> ${reorg.to}`);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if (shouldCreateIndex) console.log(` Index: ${indexFilePath}`);
|
|
1661
|
+
if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
|
|
1662
|
+
if (shouldCreateScriptsDir) console.log(` Scripts: ${scriptsIndexPath}`);
|
|
1663
|
+
if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
|
|
1664
|
+
if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
|
|
1665
|
+
if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
|
|
1666
|
+
if (shouldCreateHooks) console.log(` Hooks: ${hookFilePath}`);
|
|
1667
|
+
if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
|
|
1668
|
+
if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
|
|
1669
|
+
if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
|
|
1670
|
+
if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
|
|
1671
|
+
if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
|
|
1672
|
+
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (reorganizations.length > 0) {
|
|
1677
|
+
const state = await loadState();
|
|
1678
|
+
for (const reorg of reorganizations) {
|
|
1679
|
+
await ensureDir(path.dirname(reorg.to));
|
|
1680
|
+
await rename(reorg.from, reorg.to);
|
|
1681
|
+
|
|
1682
|
+
const oldRelative = path.relative(process.cwd(), reorg.from).replace(/\\/g, '/');
|
|
1683
|
+
const newRelative = path.relative(process.cwd(), reorg.to).replace(/\\/g, '/');
|
|
1684
|
+
|
|
1685
|
+
if (state.files[oldRelative]) {
|
|
1686
|
+
state.files[newRelative] = { ...state.files[oldRelative] };
|
|
1687
|
+
delete state.files[oldRelative];
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Update imports in the moved file
|
|
1691
|
+
await updateImportsInFile(reorg.to, reorg.from, reorg.to);
|
|
1692
|
+
|
|
1693
|
+
console.log(`✓ Reorganized ${oldRelative} to ${newRelative}`);
|
|
1694
|
+
}
|
|
1695
|
+
await saveState(state);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
await ensureNotExists(routeFilePath, options.force);
|
|
1699
|
+
await ensureNotExists(featureFilePath, options.force);
|
|
1700
|
+
|
|
1701
|
+
if (shouldCreateIndex) await ensureNotExists(indexFilePath, options.force);
|
|
1702
|
+
if (shouldCreateContext) await ensureNotExists(contextFilePath, options.force);
|
|
1703
|
+
if (shouldCreateHooks) await ensureNotExists(hookFilePath, options.force);
|
|
1704
|
+
if (shouldCreateTests) await ensureNotExists(testFilePath, options.force);
|
|
1705
|
+
if (shouldCreateTypes) await ensureNotExists(typesFilePath, options.force);
|
|
1706
|
+
if (shouldCreateApi) await ensureNotExists(apiFilePath, options.force);
|
|
1707
|
+
if (shouldCreateServices) await ensureNotExists(servicesFilePath, options.force);
|
|
1708
|
+
if (shouldCreateSchemas) await ensureNotExists(schemasFilePath, options.force);
|
|
1709
|
+
if (shouldCreateReadme) await ensureNotExists(readmeFilePath, options.force);
|
|
1710
|
+
if (shouldCreateStories) await ensureNotExists(storiesFilePath, options.force);
|
|
1711
|
+
if (shouldCreateScriptsDir) await ensureNotExists(scriptsIndexPath, options.force);
|
|
1712
|
+
|
|
1713
|
+
let layoutImportPath = null;
|
|
1714
|
+
if (options.layout !== 'none') {
|
|
1715
|
+
if (config.importAliases.layouts) {
|
|
1716
|
+
layoutImportPath = `${config.importAliases.layouts}/${options.layout}.astro`;
|
|
1717
|
+
} else {
|
|
1718
|
+
const layoutFilePath = secureJoin(layoutsRoot, `${options.layout}.astro`);
|
|
1719
|
+
layoutImportPath = getRelativeImportPath(routeFilePath, layoutFilePath);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
let featureImportPath;
|
|
1724
|
+
if (config.importAliases.features) {
|
|
1725
|
+
const entryPart = effectiveOptions.entry === 'index' ? '' : `/${featureComponentName}`;
|
|
1726
|
+
// In Astro, we can often omit the extension for .tsx files, but not for .astro files if using aliases sometimes.
|
|
1727
|
+
// However, to be safe, we use the configured extension.
|
|
1728
|
+
featureImportPath = `${config.importAliases.features}/${normalizedFeaturePath}${entryPart}${config.naming.featureExtension}`;
|
|
1729
|
+
} else {
|
|
1730
|
+
const relativeFeatureFile = getRelativeImportPath(routeFilePath, featureFilePath);
|
|
1731
|
+
// Remove extension for import
|
|
1732
|
+
featureImportPath = relativeFeatureFile.replace(/\.[^/.]+$/, '');
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
let scriptImportPath;
|
|
1736
|
+
if (shouldCreateScriptsDir) {
|
|
1737
|
+
scriptImportPath = getRelativeImportPath(featureFilePath, scriptsIndexPath);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
let routeContent;
|
|
1741
|
+
let routeSignature;
|
|
1742
|
+
|
|
1743
|
+
if (options.endpoint) {
|
|
1744
|
+
routeContent = generateEndpointTemplate(featureComponentName);
|
|
1745
|
+
routeSignature = getSignature(config, 'typescript');
|
|
1746
|
+
} else {
|
|
1747
|
+
routeContent = generateRouteTemplate(
|
|
1748
|
+
options.layout,
|
|
1749
|
+
layoutImportPath,
|
|
1750
|
+
featureImportPath,
|
|
1751
|
+
featureComponentName
|
|
1752
|
+
);
|
|
1753
|
+
routeSignature = getSignature(config, 'astro');
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const featureContent = generateFeatureTemplate(featureComponentName, scriptImportPath, framework);
|
|
1757
|
+
|
|
1758
|
+
const routeHash = await writeFileWithSignature(
|
|
1759
|
+
routeFilePath,
|
|
1760
|
+
routeContent,
|
|
1761
|
+
routeSignature,
|
|
1762
|
+
config.hashing?.normalization
|
|
1763
|
+
);
|
|
1764
|
+
await registerFile(routeFilePath, {
|
|
1765
|
+
kind: 'route',
|
|
1766
|
+
template: options.endpoint ? 'endpoint' : 'route',
|
|
1767
|
+
hash: routeHash,
|
|
1768
|
+
owner: normalizedRoute
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
const writtenFiles = [routeFilePath];
|
|
1772
|
+
|
|
1773
|
+
await ensureDir(featureDirPath);
|
|
1774
|
+
|
|
1775
|
+
if (shouldCreateSubComponentsDir) await ensureDir(subComponentsDir);
|
|
1776
|
+
if (shouldCreateApi) await ensureDir(apiDirInside);
|
|
1777
|
+
if (shouldCreateServices) await ensureDir(servicesDirInside);
|
|
1778
|
+
if (shouldCreateSchemas) await ensureDir(schemasDirInside);
|
|
1779
|
+
if (shouldCreateHooks) await ensureDir(hooksDirInside);
|
|
1780
|
+
if (shouldCreateContext) await ensureDir(contextDirInside);
|
|
1781
|
+
if (shouldCreateTests) await ensureDir(testsDir);
|
|
1782
|
+
if (shouldCreateTypes) await ensureDir(typesDirInside);
|
|
1783
|
+
|
|
1784
|
+
const featureSignature = getSignature(config, config.naming.featureExtension === '.astro' ? 'astro' : 'tsx');
|
|
1785
|
+
|
|
1786
|
+
const featureHash = await writeFileWithSignature(
|
|
1787
|
+
featureFilePath,
|
|
1788
|
+
featureContent,
|
|
1789
|
+
featureSignature,
|
|
1790
|
+
config.hashing?.normalization
|
|
1791
|
+
);
|
|
1792
|
+
await registerFile(featureFilePath, {
|
|
1793
|
+
kind: 'feature',
|
|
1794
|
+
template: 'feature',
|
|
1795
|
+
hash: featureHash,
|
|
1796
|
+
owner: normalizedRoute
|
|
1797
|
+
});
|
|
1798
|
+
writtenFiles.push(featureFilePath);
|
|
1799
|
+
|
|
1800
|
+
if (shouldCreateScriptsDir) {
|
|
1801
|
+
const hash = await writeFileWithSignature(
|
|
1802
|
+
scriptsIndexPath,
|
|
1803
|
+
generateScriptsIndexTemplate(),
|
|
1804
|
+
getSignature(config, 'typescript'),
|
|
1805
|
+
config.hashing?.normalization
|
|
1806
|
+
);
|
|
1807
|
+
await registerFile(scriptsIndexPath, {
|
|
1808
|
+
kind: 'feature-file',
|
|
1809
|
+
template: 'scripts-index',
|
|
1810
|
+
hash,
|
|
1811
|
+
owner: normalizedRoute
|
|
1812
|
+
});
|
|
1813
|
+
writtenFiles.push(scriptsIndexPath);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (shouldCreateIndex) {
|
|
1817
|
+
const indexContent = generateIndexTemplate(featureComponentName, config.naming.featureExtension);
|
|
1818
|
+
const hash = await writeFileWithSignature(
|
|
1819
|
+
indexFilePath,
|
|
1820
|
+
indexContent,
|
|
1821
|
+
getSignature(config, 'typescript'),
|
|
1822
|
+
config.hashing?.normalization
|
|
1823
|
+
);
|
|
1824
|
+
await registerFile(indexFilePath, {
|
|
1825
|
+
kind: 'feature-file',
|
|
1826
|
+
template: 'index',
|
|
1827
|
+
hash,
|
|
1828
|
+
owner: normalizedRoute
|
|
1829
|
+
});
|
|
1830
|
+
writtenFiles.push(indexFilePath);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
if (shouldCreateApi) {
|
|
1834
|
+
const apiContent = generateApiTemplate(featureComponentName);
|
|
1835
|
+
const hash = await writeFileWithSignature(
|
|
1836
|
+
apiFilePath,
|
|
1837
|
+
apiContent,
|
|
1838
|
+
getSignature(config, 'typescript'),
|
|
1839
|
+
config.hashing?.normalization
|
|
1840
|
+
);
|
|
1841
|
+
await registerFile(apiFilePath, {
|
|
1842
|
+
kind: 'feature-file',
|
|
1843
|
+
template: 'api',
|
|
1844
|
+
hash,
|
|
1845
|
+
owner: normalizedRoute
|
|
1846
|
+
});
|
|
1847
|
+
writtenFiles.push(apiFilePath);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (shouldCreateServices) {
|
|
1851
|
+
const servicesContent = generateServiceTemplate(featureComponentName);
|
|
1852
|
+
const hash = await writeFileWithSignature(
|
|
1853
|
+
servicesFilePath,
|
|
1854
|
+
servicesContent,
|
|
1855
|
+
getSignature(config, 'typescript'),
|
|
1856
|
+
config.hashing?.normalization
|
|
1857
|
+
);
|
|
1858
|
+
await registerFile(servicesFilePath, {
|
|
1859
|
+
kind: 'feature-file',
|
|
1860
|
+
template: 'service',
|
|
1861
|
+
hash,
|
|
1862
|
+
owner: normalizedRoute
|
|
1863
|
+
});
|
|
1864
|
+
writtenFiles.push(servicesFilePath);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (shouldCreateSchemas) {
|
|
1868
|
+
const schemasContent = generateSchemaTemplate(featureComponentName);
|
|
1869
|
+
const hash = await writeFileWithSignature(
|
|
1870
|
+
schemasFilePath,
|
|
1871
|
+
schemasContent,
|
|
1872
|
+
getSignature(config, 'typescript'),
|
|
1873
|
+
config.hashing?.normalization
|
|
1874
|
+
);
|
|
1875
|
+
await registerFile(schemasFilePath, {
|
|
1876
|
+
kind: 'feature-file',
|
|
1877
|
+
template: 'schema',
|
|
1878
|
+
hash,
|
|
1879
|
+
owner: normalizedRoute
|
|
1880
|
+
});
|
|
1881
|
+
writtenFiles.push(schemasFilePath);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
if (shouldCreateHooks) {
|
|
1885
|
+
const hookName = getHookFunctionName(featureComponentName);
|
|
1886
|
+
const hookContent = generateHookTemplate(featureComponentName, hookName);
|
|
1887
|
+
const hash = await writeFileWithSignature(
|
|
1888
|
+
hookFilePath,
|
|
1889
|
+
hookContent,
|
|
1890
|
+
getSignature(config, 'typescript'),
|
|
1891
|
+
config.hashing?.normalization
|
|
1892
|
+
);
|
|
1893
|
+
await registerFile(hookFilePath, {
|
|
1894
|
+
kind: 'feature-file',
|
|
1895
|
+
template: 'hook',
|
|
1896
|
+
hash,
|
|
1897
|
+
owner: normalizedRoute
|
|
1898
|
+
});
|
|
1899
|
+
writtenFiles.push(hookFilePath);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
if (shouldCreateContext) {
|
|
1903
|
+
const contextContent = generateContextTemplate(featureComponentName);
|
|
1904
|
+
const hash = await writeFileWithSignature(
|
|
1905
|
+
contextFilePath,
|
|
1906
|
+
contextContent,
|
|
1907
|
+
getSignature(config, 'typescript'),
|
|
1908
|
+
config.hashing?.normalization
|
|
1909
|
+
);
|
|
1910
|
+
await registerFile(contextFilePath, {
|
|
1911
|
+
kind: 'feature-file',
|
|
1912
|
+
template: 'context',
|
|
1913
|
+
hash,
|
|
1914
|
+
owner: normalizedRoute
|
|
1915
|
+
});
|
|
1916
|
+
writtenFiles.push(contextFilePath);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (shouldCreateTests) {
|
|
1920
|
+
const relativeFeaturePath = `./${path.basename(featureFilePath)}`;
|
|
1921
|
+
const testContent = generateTestTemplate(featureComponentName, relativeFeaturePath);
|
|
1922
|
+
const hash = await writeFileWithSignature(
|
|
1923
|
+
testFilePath,
|
|
1924
|
+
testContent,
|
|
1925
|
+
getSignature(config, 'typescript'),
|
|
1926
|
+
config.hashing?.normalization
|
|
1927
|
+
);
|
|
1928
|
+
await registerFile(testFilePath, {
|
|
1929
|
+
kind: 'feature-file',
|
|
1930
|
+
template: 'test',
|
|
1931
|
+
hash,
|
|
1932
|
+
owner: normalizedRoute
|
|
1933
|
+
});
|
|
1934
|
+
writtenFiles.push(testFilePath);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
if (shouldCreateTypes) {
|
|
1938
|
+
const typesContent = generateTypesTemplate(featureComponentName);
|
|
1939
|
+
const hash = await writeFileWithSignature(
|
|
1940
|
+
typesFilePath,
|
|
1941
|
+
typesContent,
|
|
1942
|
+
getSignature(config, 'typescript'),
|
|
1943
|
+
config.hashing?.normalization
|
|
1944
|
+
);
|
|
1945
|
+
await registerFile(typesFilePath, {
|
|
1946
|
+
kind: 'feature-file',
|
|
1947
|
+
template: 'types',
|
|
1948
|
+
hash,
|
|
1949
|
+
owner: normalizedRoute
|
|
1950
|
+
});
|
|
1951
|
+
writtenFiles.push(typesFilePath);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
if (shouldCreateReadme) {
|
|
1955
|
+
const readmeContent = generateReadmeTemplate(featureComponentName);
|
|
1956
|
+
const hash = await writeFileWithSignature(
|
|
1957
|
+
readmeFilePath,
|
|
1958
|
+
readmeContent,
|
|
1959
|
+
getSignature(config, 'astro'),
|
|
1960
|
+
config.hashing?.normalization
|
|
1961
|
+
);
|
|
1962
|
+
await registerFile(readmeFilePath, {
|
|
1963
|
+
kind: 'feature-file',
|
|
1964
|
+
template: 'readme',
|
|
1965
|
+
hash,
|
|
1966
|
+
owner: normalizedRoute
|
|
1967
|
+
});
|
|
1968
|
+
writtenFiles.push(readmeFilePath);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
if (shouldCreateStories) {
|
|
1972
|
+
const relativePath = `./${path.basename(featureFilePath)}`;
|
|
1973
|
+
const storiesContent = generateStoriesTemplate(featureComponentName, relativePath);
|
|
1974
|
+
const hash = await writeFileWithSignature(
|
|
1975
|
+
storiesFilePath,
|
|
1976
|
+
storiesContent,
|
|
1977
|
+
getSignature(config, 'typescript'),
|
|
1978
|
+
config.hashing?.normalization
|
|
1979
|
+
);
|
|
1980
|
+
await registerFile(storiesFilePath, {
|
|
1981
|
+
kind: 'feature-file',
|
|
1982
|
+
template: 'stories',
|
|
1983
|
+
hash,
|
|
1984
|
+
owner: normalizedRoute
|
|
1985
|
+
});
|
|
1986
|
+
writtenFiles.push(storiesFilePath);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Formatting
|
|
1990
|
+
if (config.formatting.tool !== 'none') {
|
|
1991
|
+
await formatFiles(writtenFiles, config.formatting.tool);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
console.log('✓ Section created successfully:');
|
|
1995
|
+
console.log(` Route: ${routeFilePath}`);
|
|
1996
|
+
console.log(` Feature: ${featureFilePath}`);
|
|
1997
|
+
|
|
1998
|
+
if (shouldCreateIndex) console.log(` Index: ${indexFilePath}`);
|
|
1999
|
+
if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
|
|
2000
|
+
if (shouldCreateScriptsDir) console.log(` Scripts: ${scriptsIndexPath}`);
|
|
2001
|
+
if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
|
|
2002
|
+
if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
|
|
2003
|
+
if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
|
|
2004
|
+
if (shouldCreateHooks) console.log(` Hooks: ${hookFilePath}`);
|
|
2005
|
+
if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
|
|
2006
|
+
if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
|
|
2007
|
+
if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
|
|
2008
|
+
if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
|
|
2009
|
+
if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
|
|
2010
|
+
|
|
2011
|
+
await addSectionToState({
|
|
2012
|
+
name: options.name || featureComponentName,
|
|
2013
|
+
route: normalizedRoute,
|
|
2014
|
+
featurePath: normalizedFeaturePath,
|
|
2015
|
+
layout: options.layout,
|
|
2016
|
+
extension: routeExtension
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
if (config.git?.stageChanges) {
|
|
2020
|
+
await stageFiles(writtenFiles);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
console.error('Error:', error.message);
|
|
2025
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
2026
|
+
process.exit(1);
|
|
2027
|
+
}
|
|
2028
|
+
throw error;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async function updateImportsInFile(filePath, oldFilePath, newFilePath) {
|
|
2033
|
+
if (!existsSync(filePath)) return;
|
|
2034
|
+
|
|
2035
|
+
let content = await readFile(filePath, 'utf-8');
|
|
2036
|
+
const oldDir = path.dirname(oldFilePath);
|
|
2037
|
+
const newDir = path.dirname(newFilePath);
|
|
2038
|
+
|
|
2039
|
+
if (oldDir === newDir) return;
|
|
2040
|
+
|
|
2041
|
+
// Find all relative imports
|
|
2042
|
+
const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
|
|
2043
|
+
let match;
|
|
2044
|
+
const replacements = [];
|
|
2045
|
+
|
|
2046
|
+
while ((match = relativeImportRegex.exec(content)) !== null) {
|
|
2047
|
+
const relativePath = match[1];
|
|
2048
|
+
const absoluteTarget = path.resolve(oldDir, relativePath);
|
|
2049
|
+
const newRelativePath = getRelativeImportPath(newFilePath, absoluteTarget);
|
|
2050
|
+
|
|
2051
|
+
replacements.push({
|
|
2052
|
+
full: match[0],
|
|
2053
|
+
oldRel: relativePath,
|
|
2054
|
+
newRel: newRelativePath
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
for (const repl of replacements) {
|
|
2059
|
+
content = content.replace(repl.full, `from '${repl.newRel}'`);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
await writeFile(filePath, content, 'utf-8');
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
async function removeSectionCommand(route, featurePath, options) {
|
|
2066
|
+
try {
|
|
2067
|
+
const config = await loadConfig();
|
|
2068
|
+
|
|
2069
|
+
if (config.git?.requireCleanRepo && !await isRepoClean()) {
|
|
2070
|
+
throw new Error('Git repository is not clean. Please commit or stash your changes before proceeding.');
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
const state = await loadState();
|
|
2074
|
+
|
|
2075
|
+
let targetRoute = route;
|
|
2076
|
+
let targetFeaturePath = featurePath;
|
|
2077
|
+
let section = findSection(state, route);
|
|
2078
|
+
|
|
2079
|
+
if (!targetFeaturePath) {
|
|
2080
|
+
if (section) {
|
|
2081
|
+
targetRoute = section.route;
|
|
2082
|
+
targetFeaturePath = section.featurePath;
|
|
2083
|
+
} else {
|
|
2084
|
+
throw new Error(`Section not found for identifier: ${route}. Please provide both route and featurePath.`);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const normalizedRoute = normalizeRoute(targetRoute);
|
|
2089
|
+
const normalizedFeaturePath = featureToDirectoryPath(targetFeaturePath);
|
|
2090
|
+
|
|
2091
|
+
const routeExtension = (section && section.extension) || config.naming.routeExtension;
|
|
2092
|
+
const routeFileName = routeToFilePath(normalizedRoute, {
|
|
2093
|
+
extension: routeExtension,
|
|
2094
|
+
mode: config.routing.mode,
|
|
2095
|
+
indexFile: config.routing.indexFile
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
const pagesRoot = resolvePath(config, 'pages');
|
|
2099
|
+
const featuresRoot = resolvePath(config, 'features');
|
|
2100
|
+
|
|
2101
|
+
const routeFilePath = secureJoin(pagesRoot, routeFileName);
|
|
2102
|
+
const featureDirPath = secureJoin(featuresRoot, normalizedFeaturePath);
|
|
2103
|
+
|
|
2104
|
+
const deletedFiles = [];
|
|
2105
|
+
const skippedFiles = [];
|
|
2106
|
+
const deletedDirs = [];
|
|
2107
|
+
|
|
2108
|
+
if (options.dryRun) {
|
|
2109
|
+
console.log('Dry run - would delete:');
|
|
2110
|
+
|
|
2111
|
+
if (!options.keepRoute) {
|
|
2112
|
+
console.log(` Route: ${routeFilePath}`);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (!options.keepFeature) {
|
|
2116
|
+
console.log(` Feature: ${featureDirPath}/`);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
if (!options.keepRoute) {
|
|
2123
|
+
const normalizedPath = path.relative(process.cwd(), routeFilePath).replace(/\\/g, '/');
|
|
2124
|
+
const fileState = state.files[normalizedPath];
|
|
2125
|
+
const result = await safeDelete(routeFilePath, {
|
|
2126
|
+
force: options.force,
|
|
2127
|
+
expectedHash: fileState?.hash,
|
|
2128
|
+
acceptChanges: options.acceptChanges,
|
|
2129
|
+
normalization: config.hashing?.normalization,
|
|
2130
|
+
owner: normalizedRoute,
|
|
2131
|
+
actualOwner: fileState?.owner
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
if (result.deleted) {
|
|
2135
|
+
deletedFiles.push(routeFilePath);
|
|
2136
|
+
delete state.files[normalizedPath];
|
|
2137
|
+
} else if (result.message) {
|
|
2138
|
+
skippedFiles.push({ path: routeFilePath, reason: result.message });
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (!options.keepFeature) {
|
|
2143
|
+
const result = await safeDeleteDir(featureDirPath, {
|
|
2144
|
+
force: options.force,
|
|
2145
|
+
stateFiles: state.files,
|
|
2146
|
+
acceptChanges: options.acceptChanges,
|
|
2147
|
+
normalization: config.hashing?.normalization,
|
|
2148
|
+
owner: normalizedRoute
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
if (result.deleted) {
|
|
2152
|
+
deletedDirs.push(featureDirPath);
|
|
2153
|
+
// Unregister all files that were in this directory
|
|
2154
|
+
const dirPrefix = path.relative(process.cwd(), featureDirPath).replace(/\\/g, '/') + '/';
|
|
2155
|
+
for (const f in state.files) {
|
|
2156
|
+
if (f.startsWith(dirPrefix)) {
|
|
2157
|
+
delete state.files[f];
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
} else if (result.message) {
|
|
2161
|
+
skippedFiles.push({ path: featureDirPath, reason: result.message });
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
if (!options.keepRoute && deletedFiles.includes(routeFilePath)) {
|
|
2166
|
+
await cleanupEmptyDirs(path.dirname(routeFilePath), pagesRoot);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (!options.keepFeature && deletedDirs.includes(featureDirPath)) {
|
|
2170
|
+
await cleanupEmptyDirs(path.dirname(featureDirPath), featuresRoot);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
if (deletedFiles.length > 0 || deletedDirs.length > 0) {
|
|
2174
|
+
console.log('✓ Deleted:');
|
|
2175
|
+
deletedFiles.forEach(file => console.log(` ${file}`));
|
|
2176
|
+
deletedDirs.forEach(dir => console.log(` ${dir}/`));
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (skippedFiles.length > 0) {
|
|
2180
|
+
console.log('\n⚠ Skipped:');
|
|
2181
|
+
skippedFiles.forEach(item => {
|
|
2182
|
+
console.log(` ${item.path}`);
|
|
2183
|
+
console.log(` Reason: ${item.reason}`);
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
if (deletedFiles.length === 0 && deletedDirs.length === 0 && skippedFiles.length === 0) {
|
|
2188
|
+
console.log('No files to delete.');
|
|
2189
|
+
} else {
|
|
2190
|
+
state.sections = state.sections.filter(s => s.route !== normalizedRoute);
|
|
2191
|
+
await saveState(state);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
console.error('Error:', error.message);
|
|
2196
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
2197
|
+
process.exit(1);
|
|
2198
|
+
}
|
|
2199
|
+
throw error;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* Move a section (route + feature).
|
|
2205
|
+
*
|
|
2206
|
+
* SCOPE GUARANTEES:
|
|
2207
|
+
* - Automatically updates imports in the moved route file.
|
|
2208
|
+
* - Automatically updates internal imports/references within the moved feature directory.
|
|
2209
|
+
* - Repo-wide scan for import updates is available via the --scan flag.
|
|
2210
|
+
*
|
|
2211
|
+
* NON-GOALS (What it won't rewrite):
|
|
2212
|
+
* - String literals (unless they match the component name exactly in a JSX context).
|
|
2213
|
+
* - Markdown documentation (except for those registered in state).
|
|
2214
|
+
* - Dynamic imports with complex template literals.
|
|
2215
|
+
*/
|
|
2216
|
+
async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, options) {
|
|
2217
|
+
try {
|
|
2218
|
+
const config = await loadConfig();
|
|
2219
|
+
|
|
2220
|
+
if (config.git?.requireCleanRepo && !await isRepoClean()) {
|
|
2221
|
+
throw new Error('Git repository is not clean. Please commit or stash your changes before proceeding.');
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const state = await loadState();
|
|
2225
|
+
|
|
2226
|
+
let actualFromRoute = fromRoute;
|
|
2227
|
+
let actualFromFeature = fromFeature;
|
|
2228
|
+
let actualToRoute = toRoute;
|
|
2229
|
+
let actualToFeature = toFeature;
|
|
2230
|
+
|
|
2231
|
+
// Shift arguments if using state
|
|
2232
|
+
if (!toRoute && fromRoute && fromFeature) {
|
|
2233
|
+
// textor move-section /old-route /new-route
|
|
2234
|
+
const section = findSection(state, fromRoute);
|
|
2235
|
+
if (section) {
|
|
2236
|
+
actualFromRoute = section.route;
|
|
2237
|
+
actualFromFeature = section.featurePath;
|
|
2238
|
+
actualToRoute = fromFeature; // the second argument was actually the new route
|
|
2239
|
+
actualToFeature = toRoute; // which is null
|
|
2240
|
+
|
|
2241
|
+
// If toFeature is not provided, try to derive it from the new route
|
|
2242
|
+
if (!actualToFeature && actualToRoute) {
|
|
2243
|
+
const oldRouteParts = actualFromRoute.split('/').filter(Boolean);
|
|
2244
|
+
const newRouteParts = actualToRoute.split('/').filter(Boolean);
|
|
2245
|
+
const oldFeatureParts = actualFromFeature.split('/').filter(Boolean);
|
|
2246
|
+
|
|
2247
|
+
// If the feature path starts with the old route parts, replace them
|
|
2248
|
+
// We compare case-insensitively or via PascalCase to be more helpful
|
|
2249
|
+
let match = true;
|
|
2250
|
+
for (let i = 0; i < oldRouteParts.length; i++) {
|
|
2251
|
+
const routePart = oldRouteParts[i].toLowerCase();
|
|
2252
|
+
const featurePart = oldFeatureParts[i] ? oldFeatureParts[i].toLowerCase() : null;
|
|
2253
|
+
|
|
2254
|
+
if (featurePart !== routePart) {
|
|
2255
|
+
match = false;
|
|
2256
|
+
break;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
if (match && oldRouteParts.length > 0) {
|
|
2261
|
+
actualToFeature = [...newRouteParts, ...oldFeatureParts.slice(oldRouteParts.length)].join('/');
|
|
2262
|
+
} else {
|
|
2263
|
+
// Otherwise just keep it the same
|
|
2264
|
+
actualToFeature = actualFromFeature;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const isRouteOnly = options.keepFeature || (!actualToFeature && actualToRoute && !actualFromFeature);
|
|
2271
|
+
|
|
2272
|
+
if (isRouteOnly && !actualToRoute) {
|
|
2273
|
+
throw new Error('Destination route required for route-only move');
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const normalizedFromRoute = normalizeRoute(actualFromRoute);
|
|
2277
|
+
const normalizedToRoute = normalizeRoute(actualToRoute);
|
|
2278
|
+
const normalizedFromFeature = actualFromFeature ? featureToDirectoryPath(actualFromFeature) : null;
|
|
2279
|
+
const normalizedToFeature = actualToFeature ? featureToDirectoryPath(actualToFeature) : null;
|
|
2280
|
+
|
|
2281
|
+
const pagesRoot = resolvePath(config, 'pages');
|
|
2282
|
+
const featuresRoot = resolvePath(config, 'features');
|
|
2283
|
+
|
|
2284
|
+
const fromSection = findSection(state, actualFromRoute);
|
|
2285
|
+
const routeExtension = (fromSection && fromSection.extension) || config.naming.routeExtension;
|
|
2286
|
+
|
|
2287
|
+
const fromRouteFile = routeToFilePath(normalizedFromRoute, {
|
|
2288
|
+
extension: routeExtension,
|
|
2289
|
+
mode: config.routing.mode,
|
|
2290
|
+
indexFile: config.routing.indexFile
|
|
2291
|
+
});
|
|
2292
|
+
const toRouteFile = routeToFilePath(normalizedToRoute, {
|
|
2293
|
+
extension: routeExtension,
|
|
2294
|
+
mode: config.routing.mode,
|
|
2295
|
+
indexFile: config.routing.indexFile
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
const fromRoutePath = secureJoin(pagesRoot, fromRouteFile);
|
|
2299
|
+
const toRoutePath = secureJoin(pagesRoot, toRouteFile);
|
|
2300
|
+
|
|
2301
|
+
const movedFiles = [];
|
|
2302
|
+
|
|
2303
|
+
if (options.dryRun) {
|
|
2304
|
+
console.log('Dry run - would move:');
|
|
2305
|
+
console.log(` Route: ${fromRoutePath} -> ${toRoutePath}`);
|
|
2306
|
+
|
|
2307
|
+
if (!isRouteOnly && normalizedFromFeature && normalizedToFeature) {
|
|
2308
|
+
const fromFeaturePath = secureJoin(featuresRoot, normalizedFromFeature);
|
|
2309
|
+
const toFeaturePath = secureJoin(featuresRoot, normalizedToFeature);
|
|
2310
|
+
console.log(` Feature: ${fromFeaturePath} -> ${toFeaturePath}`);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
const normalizedFromRouteRelative = path.relative(process.cwd(), fromRoutePath).replace(/\\/g, '/');
|
|
2317
|
+
const routeFileState = state.files[normalizedFromRouteRelative];
|
|
2318
|
+
|
|
2319
|
+
const newRouteHash = await safeMove(fromRoutePath, toRoutePath, {
|
|
2320
|
+
force: options.force,
|
|
2321
|
+
expectedHash: routeFileState?.hash,
|
|
2322
|
+
acceptChanges: options.acceptChanges,
|
|
2323
|
+
owner: normalizedFromRoute,
|
|
2324
|
+
actualOwner: routeFileState?.owner
|
|
2325
|
+
});
|
|
2326
|
+
movedFiles.push({ from: fromRoutePath, to: toRoutePath });
|
|
2327
|
+
|
|
2328
|
+
// Update state for moved route file
|
|
2329
|
+
const normalizedToRouteRelative = path.relative(process.cwd(), toRoutePath).replace(/\\/g, '/');
|
|
2330
|
+
if (routeFileState) {
|
|
2331
|
+
state.files[normalizedToRouteRelative] = { ...routeFileState, hash: newRouteHash };
|
|
2332
|
+
delete state.files[normalizedFromRouteRelative];
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// Update imports in the moved route file
|
|
2336
|
+
const targetFeature = normalizedToFeature || normalizedFromFeature;
|
|
2337
|
+
if (targetFeature) {
|
|
2338
|
+
const fromFeatureDirPath = secureJoin(featuresRoot, normalizedFromFeature);
|
|
2339
|
+
const toFeatureDirPath = secureJoin(featuresRoot, targetFeature);
|
|
2340
|
+
const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
|
|
2341
|
+
const toFeatureComponentName = getFeatureComponentName(targetFeature);
|
|
2342
|
+
|
|
2343
|
+
// Update component name in JSX
|
|
2344
|
+
if (fromFeatureComponentName !== toFeatureComponentName) {
|
|
2345
|
+
let content = await readFile(toRoutePath, 'utf-8');
|
|
2346
|
+
content = content.replace(
|
|
2347
|
+
new RegExp(`<${fromFeatureComponentName}`, 'g'),
|
|
2348
|
+
`<${toFeatureComponentName}`
|
|
2349
|
+
);
|
|
2350
|
+
content = content.replace(
|
|
2351
|
+
new RegExp(`</${fromFeatureComponentName}`, 'g'),
|
|
2352
|
+
`</${toFeatureComponentName}`
|
|
2353
|
+
);
|
|
2354
|
+
await writeFile(toRoutePath, content, 'utf-8');
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
if (config.importAliases.features) {
|
|
2358
|
+
if (normalizedFromFeature !== targetFeature) {
|
|
2359
|
+
const oldAliasPath = `${config.importAliases.features}/${normalizedFromFeature}`;
|
|
2360
|
+
const newAliasPath = `${config.importAliases.features}/${targetFeature}`;
|
|
2361
|
+
|
|
2362
|
+
// Replace both the path and the component name if they are different
|
|
2363
|
+
await updateSignature(toRoutePath,
|
|
2364
|
+
`import ${fromFeatureComponentName} from '${oldAliasPath}/${fromFeatureComponentName}'`,
|
|
2365
|
+
`import ${toFeatureComponentName} from '${newAliasPath}/${toFeatureComponentName}'`
|
|
2366
|
+
);
|
|
2367
|
+
|
|
2368
|
+
// Fallback for prefix only replacement
|
|
2369
|
+
await updateSignature(toRoutePath, oldAliasPath, newAliasPath);
|
|
2370
|
+
} else if (fromFeatureComponentName !== toFeatureComponentName) {
|
|
2371
|
+
// Name changed but path didn't
|
|
2372
|
+
const aliasPath = `${config.importAliases.features}/${targetFeature}`;
|
|
2373
|
+
await updateSignature(toRoutePath,
|
|
2374
|
+
`import ${fromFeatureComponentName} from '${aliasPath}/${fromFeatureComponentName}'`,
|
|
2375
|
+
`import ${toFeatureComponentName} from '${aliasPath}/${toFeatureComponentName}'`
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
} else {
|
|
2379
|
+
const oldRelativeDir = getRelativeImportPath(fromRoutePath, fromFeatureDirPath);
|
|
2380
|
+
const newRelativeDir = getRelativeImportPath(toRoutePath, toFeatureDirPath);
|
|
2381
|
+
|
|
2382
|
+
const oldImportPath = `import ${fromFeatureComponentName} from '${oldRelativeDir}/${fromFeatureComponentName}'`;
|
|
2383
|
+
const newImportPath = `import ${toFeatureComponentName} from '${newRelativeDir}/${toFeatureComponentName}'`;
|
|
2384
|
+
|
|
2385
|
+
if (oldImportPath !== newImportPath) {
|
|
2386
|
+
await updateSignature(toRoutePath, oldImportPath, newImportPath);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
if (!isRouteOnly && normalizedFromFeature && normalizedToFeature && normalizedFromFeature !== normalizedToFeature) {
|
|
2392
|
+
const fromFeaturePath = secureJoin(featuresRoot, normalizedFromFeature);
|
|
2393
|
+
const toFeaturePath = secureJoin(featuresRoot, normalizedToFeature);
|
|
2394
|
+
|
|
2395
|
+
const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
|
|
2396
|
+
const toFeatureComponentName = getFeatureComponentName(normalizedToFeature);
|
|
2397
|
+
|
|
2398
|
+
if (existsSync(fromFeaturePath)) {
|
|
2399
|
+
await moveDirectory(fromFeaturePath, toFeaturePath, state, config, {
|
|
2400
|
+
...options,
|
|
2401
|
+
fromName: fromFeatureComponentName,
|
|
2402
|
+
toName: toFeatureComponentName,
|
|
2403
|
+
owner: normalizedFromRoute
|
|
2404
|
+
});
|
|
2405
|
+
movedFiles.push({ from: fromFeaturePath, to: toFeaturePath });
|
|
2406
|
+
|
|
2407
|
+
await cleanupEmptyDirs(path.dirname(fromFeaturePath), featuresRoot);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (options.scan && (normalizedFromFeature || normalizedToFeature)) {
|
|
2412
|
+
await scanAndReplaceImports(config, state, {
|
|
2413
|
+
fromFeaturePath: normalizedFromFeature,
|
|
2414
|
+
fromComponentName: getFeatureComponentName(normalizedFromFeature)
|
|
2415
|
+
}, {
|
|
2416
|
+
toFeaturePath: normalizedToFeature || normalizedFromFeature,
|
|
2417
|
+
toComponentName: getFeatureComponentName(normalizedToFeature || normalizedFromFeature)
|
|
2418
|
+
}, options);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
await cleanupEmptyDirs(path.dirname(fromRoutePath), pagesRoot);
|
|
2422
|
+
|
|
2423
|
+
console.log('✓ Moved:');
|
|
2424
|
+
movedFiles.forEach(item => {
|
|
2425
|
+
console.log(` ${item.from}`);
|
|
2426
|
+
console.log(` -> ${item.to}`);
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2429
|
+
if (movedFiles.length > 0) {
|
|
2430
|
+
const existingSection = fromSection;
|
|
2431
|
+
|
|
2432
|
+
// Update section data in state
|
|
2433
|
+
state.sections = state.sections.filter(s => s.route !== normalizedFromRoute);
|
|
2434
|
+
state.sections.push({
|
|
2435
|
+
name: existingSection ? existingSection.name : getFeatureComponentName(normalizedToFeature || normalizedFromFeature),
|
|
2436
|
+
route: normalizedToRoute,
|
|
2437
|
+
featurePath: normalizedToFeature || normalizedFromFeature,
|
|
2438
|
+
layout: existingSection ? existingSection.layout : 'Main',
|
|
2439
|
+
extension: routeExtension
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
await saveState(state);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
console.error('Error:', error.message);
|
|
2447
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
2448
|
+
process.exit(1);
|
|
2449
|
+
}
|
|
2450
|
+
throw error;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
async function scanAndReplaceImports(config, state, fromInfo, toInfo, options) {
|
|
2455
|
+
const { fromFeaturePath, fromComponentName } = fromInfo;
|
|
2456
|
+
const { toFeaturePath, toComponentName } = toInfo;
|
|
2457
|
+
|
|
2458
|
+
const allFiles = new Set();
|
|
2459
|
+
const { scanDirectory, calculateHash } = await Promise.resolve().then(function () { return filesystem; });
|
|
2460
|
+
await scanDirectory(process.cwd(), allFiles);
|
|
2461
|
+
|
|
2462
|
+
const featuresRoot = resolvePath(config, 'features');
|
|
2463
|
+
|
|
2464
|
+
for (const relPath of allFiles) {
|
|
2465
|
+
const fullPath = path.join(process.cwd(), relPath);
|
|
2466
|
+
|
|
2467
|
+
// Skip the moved directory itself as it was already handled
|
|
2468
|
+
if (fullPath.startsWith(path.resolve(toFeaturePath))) continue;
|
|
2469
|
+
|
|
2470
|
+
let content = await readFile(fullPath, 'utf-8');
|
|
2471
|
+
let changed = false;
|
|
2472
|
+
|
|
2473
|
+
// Handle Aliases
|
|
2474
|
+
if (config.importAliases.features) {
|
|
2475
|
+
const oldAlias = `${config.importAliases.features}/${fromFeaturePath}`;
|
|
2476
|
+
const newAlias = `${config.importAliases.features}/${toFeaturePath}`;
|
|
2477
|
+
|
|
2478
|
+
// Update component name and path if both changed
|
|
2479
|
+
const oldFullImport = `from '${oldAlias}/${fromComponentName}'`;
|
|
2480
|
+
const newFullImport = `from '${newAlias}/${toComponentName}'`;
|
|
2481
|
+
|
|
2482
|
+
if (content.includes(oldFullImport)) {
|
|
2483
|
+
content = content.replace(new RegExp(oldFullImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newFullImport);
|
|
2484
|
+
changed = true;
|
|
2485
|
+
} else if (content.includes(oldAlias)) {
|
|
2486
|
+
content = content.replace(new RegExp(oldAlias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAlias);
|
|
2487
|
+
changed = true;
|
|
2488
|
+
}
|
|
2489
|
+
} else {
|
|
2490
|
+
// Handle Relative Imports (more complex)
|
|
2491
|
+
// This is best-effort: we look for imports that resolve to the old feature path
|
|
2492
|
+
const fromFeatureDir = secureJoin(featuresRoot, fromFeaturePath);
|
|
2493
|
+
const toFeatureDir = secureJoin(featuresRoot, toFeaturePath);
|
|
2494
|
+
|
|
2495
|
+
const oldRelPath = getRelativeImportPath(fullPath, fromFeatureDir);
|
|
2496
|
+
const newRelPath = getRelativeImportPath(fullPath, toFeatureDir);
|
|
2497
|
+
|
|
2498
|
+
const oldImport = `'${oldRelPath}/${fromComponentName}'`;
|
|
2499
|
+
const newImport = `'${newRelPath}/${toComponentName}'`;
|
|
2500
|
+
|
|
2501
|
+
if (content.includes(oldImport)) {
|
|
2502
|
+
content = content.replace(new RegExp(oldImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newImport);
|
|
2503
|
+
changed = true;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// Update component name in JSX and imports if it changed
|
|
2508
|
+
if (fromComponentName !== toComponentName && changed) {
|
|
2509
|
+
content = content.replace(new RegExp(`\\b${fromComponentName}\\b`, 'g'), toComponentName);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
if (changed) {
|
|
2513
|
+
if (options.dryRun) {
|
|
2514
|
+
console.log(` [Scan] Would update imports in ${relPath}`);
|
|
2515
|
+
} else {
|
|
2516
|
+
await writeFile(fullPath, content, 'utf-8');
|
|
2517
|
+
console.log(` [Scan] Updated imports in ${relPath}`);
|
|
2518
|
+
|
|
2519
|
+
// Update state hash if this file is managed
|
|
2520
|
+
if (state.files[relPath]) {
|
|
2521
|
+
state.files[relPath].hash = calculateHash(content, config.hashing?.normalization);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
async function moveDirectory(fromPath, toPath, state, config, options = {}) {
|
|
2529
|
+
const { fromName, toName, owner = null } = options;
|
|
2530
|
+
|
|
2531
|
+
if (!existsSync(fromPath)) {
|
|
2532
|
+
throw new Error(`Source directory not found: ${fromPath}`);
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
if (existsSync(toPath) && !options.force) {
|
|
2536
|
+
throw new Error(
|
|
2537
|
+
`Destination already exists: ${toPath}\n` +
|
|
2538
|
+
`Use --force to overwrite.`
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
await ensureDir(toPath);
|
|
2543
|
+
|
|
2544
|
+
const entries = await readdir(fromPath);
|
|
2545
|
+
|
|
2546
|
+
for (const entry of entries) {
|
|
2547
|
+
let targetEntry = entry;
|
|
2548
|
+
|
|
2549
|
+
// Rename files if they match the component name
|
|
2550
|
+
if (fromName && toName && fromName !== toName) {
|
|
2551
|
+
if (entry.includes(fromName)) {
|
|
2552
|
+
targetEntry = entry.replace(fromName, toName);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
const fromEntryPath = path.join(fromPath, entry);
|
|
2557
|
+
const toEntryPath = path.join(toPath, targetEntry);
|
|
2558
|
+
|
|
2559
|
+
const stats = await stat(fromEntryPath);
|
|
2560
|
+
|
|
2561
|
+
if (stats.isDirectory()) {
|
|
2562
|
+
await moveDirectory(fromEntryPath, toEntryPath, state, config, options);
|
|
2563
|
+
} else {
|
|
2564
|
+
const normalizedFromRelative = path.relative(process.cwd(), fromEntryPath).replace(/\\/g, '/');
|
|
2565
|
+
const fileState = state.files[normalizedFromRelative];
|
|
2566
|
+
|
|
2567
|
+
const newHash = await safeMove(fromEntryPath, toEntryPath, {
|
|
2568
|
+
force: options.force,
|
|
2569
|
+
expectedHash: fileState?.hash,
|
|
2570
|
+
acceptChanges: options.acceptChanges,
|
|
2571
|
+
normalization: config.hashing?.normalization,
|
|
2572
|
+
owner,
|
|
2573
|
+
actualOwner: fileState?.owner
|
|
2574
|
+
});
|
|
2575
|
+
|
|
2576
|
+
// Update internal content (signatures, component names) if renaming
|
|
2577
|
+
if (fromName && toName && fromName !== toName) {
|
|
2578
|
+
let content = await readFile(toEntryPath, 'utf-8');
|
|
2579
|
+
let hasChanged = false;
|
|
2580
|
+
|
|
2581
|
+
// Simple replacement of component names
|
|
2582
|
+
if (content.includes(fromName)) {
|
|
2583
|
+
content = content.replace(new RegExp(fromName, 'g'), toName);
|
|
2584
|
+
hasChanged = true;
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// Also handle lowercase class names if any
|
|
2588
|
+
const fromLower = fromName.toLowerCase();
|
|
2589
|
+
const toLower = toName.toLowerCase();
|
|
2590
|
+
if (content.includes(fromLower)) {
|
|
2591
|
+
content = content.replace(new RegExp(fromLower, 'g'), toLower);
|
|
2592
|
+
hasChanged = true;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
if (hasChanged) {
|
|
2596
|
+
await writeFile(toEntryPath, content, 'utf-8');
|
|
2597
|
+
// Re-calculate hash after content update
|
|
2598
|
+
const { calculateHash } = await Promise.resolve().then(function () { return filesystem; });
|
|
2599
|
+
const updatedHash = calculateHash(content, config.hashing?.normalization);
|
|
2600
|
+
|
|
2601
|
+
const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
|
|
2602
|
+
if (fileState) {
|
|
2603
|
+
state.files[normalizedToRelative] = { ...fileState, hash: updatedHash };
|
|
2604
|
+
delete state.files[normalizedFromRelative];
|
|
2605
|
+
}
|
|
2606
|
+
} else {
|
|
2607
|
+
// Update state for each file moved normally
|
|
2608
|
+
const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
|
|
2609
|
+
if (fileState) {
|
|
2610
|
+
state.files[normalizedToRelative] = { ...fileState, hash: newHash };
|
|
2611
|
+
delete state.files[normalizedFromRelative];
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
} else {
|
|
2615
|
+
// Update state for each file moved normally
|
|
2616
|
+
const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
|
|
2617
|
+
if (fileState) {
|
|
2618
|
+
state.files[normalizedToRelative] = { ...fileState, hash: newHash };
|
|
2619
|
+
delete state.files[normalizedFromRelative];
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
const remainingFiles = await readdir(fromPath);
|
|
2626
|
+
if (remainingFiles.length === 0) {
|
|
2627
|
+
await rmdir(fromPath);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
async function createComponentCommand(componentName, options) {
|
|
2632
|
+
try {
|
|
2633
|
+
const config = await loadConfig();
|
|
2634
|
+
const effectiveOptions = getEffectiveOptions(options, config, 'components');
|
|
2635
|
+
|
|
2636
|
+
const normalizedName = normalizeComponentName(componentName);
|
|
2637
|
+
|
|
2638
|
+
const componentsRoot = resolvePath(config, 'components');
|
|
2639
|
+
|
|
2640
|
+
const componentDir = secureJoin(componentsRoot, normalizedName);
|
|
2641
|
+
const subComponentsDir = secureJoin(componentDir, 'sub-components');
|
|
2642
|
+
const testsDir = secureJoin(componentDir, '__tests__');
|
|
2643
|
+
const configDirInside = secureJoin(componentDir, 'config');
|
|
2644
|
+
const constantsDirInside = secureJoin(componentDir, 'constants');
|
|
2645
|
+
const contextDirInside = secureJoin(componentDir, 'context');
|
|
2646
|
+
const hooksDirInside = secureJoin(componentDir, 'hooks');
|
|
2647
|
+
const typesDirInside = secureJoin(componentDir, 'types');
|
|
2648
|
+
const apiDirInside = secureJoin(componentDir, 'api');
|
|
2649
|
+
const servicesDirInside = secureJoin(componentDir, 'services');
|
|
2650
|
+
const schemasDirInside = secureJoin(componentDir, 'schemas');
|
|
2651
|
+
|
|
2652
|
+
const {
|
|
2653
|
+
framework,
|
|
2654
|
+
createContext: shouldCreateContext,
|
|
2655
|
+
createHook: shouldCreateHook,
|
|
2656
|
+
createTests: shouldCreateTests,
|
|
2657
|
+
createConfig: shouldCreateConfig,
|
|
2658
|
+
createConstants: shouldCreateConstants,
|
|
2659
|
+
createTypes: shouldCreateTypes,
|
|
2660
|
+
createSubComponentsDir: shouldCreateSubComponentsDir,
|
|
2661
|
+
createApi: shouldCreateApi,
|
|
2662
|
+
createServices: shouldCreateServices,
|
|
2663
|
+
createSchemas: shouldCreateSchemas,
|
|
2664
|
+
createReadme: shouldCreateReadme,
|
|
2665
|
+
createStories: shouldCreateStories
|
|
2666
|
+
} = effectiveOptions;
|
|
2667
|
+
|
|
2668
|
+
const componentFilePath = path.join(componentDir, `${normalizedName}${config.naming.componentExtension}`);
|
|
2669
|
+
const indexFilePath = path.join(componentDir, 'index.ts');
|
|
2670
|
+
const contextFilePath = path.join(contextDirInside, `${normalizedName}Context.tsx`);
|
|
2671
|
+
const hookFilePath = path.join(hooksDirInside, getHookFileName(normalizedName, config.naming.hookExtension));
|
|
2672
|
+
const testFilePath = path.join(testsDir, `${normalizedName}${config.naming.testExtension}`);
|
|
2673
|
+
const configFilePath = path.join(configDirInside, 'index.ts');
|
|
2674
|
+
const constantsFilePath = path.join(constantsDirInside, 'index.ts');
|
|
2675
|
+
const typesFilePath = path.join(typesDirInside, 'index.ts');
|
|
2676
|
+
const apiFilePath = path.join(apiDirInside, 'index.ts');
|
|
2677
|
+
const servicesFilePath = path.join(servicesDirInside, 'index.ts');
|
|
2678
|
+
const schemasFilePath = path.join(schemasDirInside, 'index.ts');
|
|
2679
|
+
const readmeFilePath = path.join(componentDir, 'README.md');
|
|
2680
|
+
const storiesFilePath = path.join(componentDir, `${normalizedName}.stories.tsx`);
|
|
2681
|
+
|
|
2682
|
+
if (options.dryRun) {
|
|
2683
|
+
console.log('Dry run - would create:');
|
|
2684
|
+
console.log(` Component: ${componentFilePath}`);
|
|
2685
|
+
console.log(` Index: ${indexFilePath}`);
|
|
2686
|
+
|
|
2687
|
+
if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
|
|
2688
|
+
if (shouldCreateHook) console.log(` Hook: ${hookFilePath}`);
|
|
2689
|
+
if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
|
|
2690
|
+
if (shouldCreateConfig) console.log(` Config: ${configFilePath}`);
|
|
2691
|
+
if (shouldCreateConstants) console.log(` Constants: ${constantsFilePath}`);
|
|
2692
|
+
if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
|
|
2693
|
+
if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
|
|
2694
|
+
if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
|
|
2695
|
+
if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
|
|
2696
|
+
if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
|
|
2697
|
+
if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
|
|
2698
|
+
if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
|
|
2699
|
+
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
await ensureNotExists(componentFilePath, options.force);
|
|
2704
|
+
await ensureNotExists(indexFilePath, options.force);
|
|
2705
|
+
|
|
2706
|
+
if (shouldCreateContext) await ensureNotExists(contextFilePath, options.force);
|
|
2707
|
+
if (shouldCreateHook) await ensureNotExists(hookFilePath, options.force);
|
|
2708
|
+
if (shouldCreateTests) await ensureNotExists(testFilePath, options.force);
|
|
2709
|
+
if (shouldCreateConfig) await ensureNotExists(configFilePath, options.force);
|
|
2710
|
+
if (shouldCreateConstants) await ensureNotExists(constantsFilePath, options.force);
|
|
2711
|
+
if (shouldCreateTypes) await ensureNotExists(typesFilePath, options.force);
|
|
2712
|
+
if (shouldCreateApi) await ensureNotExists(apiFilePath, options.force);
|
|
2713
|
+
if (shouldCreateServices) await ensureNotExists(servicesFilePath, options.force);
|
|
2714
|
+
if (shouldCreateSchemas) await ensureNotExists(schemasFilePath, options.force);
|
|
2715
|
+
if (shouldCreateReadme) await ensureNotExists(readmeFilePath, options.force);
|
|
2716
|
+
if (shouldCreateStories) await ensureNotExists(storiesFilePath, options.force);
|
|
2717
|
+
|
|
2718
|
+
await ensureDir(componentDir);
|
|
2719
|
+
|
|
2720
|
+
if (shouldCreateSubComponentsDir) await ensureDir(subComponentsDir);
|
|
2721
|
+
if (shouldCreateContext) await ensureDir(contextDirInside);
|
|
2722
|
+
if (shouldCreateHook) await ensureDir(hooksDirInside);
|
|
2723
|
+
if (shouldCreateTests) await ensureDir(testsDir);
|
|
2724
|
+
if (shouldCreateConfig) await ensureDir(configDirInside);
|
|
2725
|
+
if (shouldCreateConstants) await ensureDir(constantsDirInside);
|
|
2726
|
+
if (shouldCreateTypes) await ensureDir(typesDirInside);
|
|
2727
|
+
if (shouldCreateApi) await ensureDir(apiDirInside);
|
|
2728
|
+
if (shouldCreateServices) await ensureDir(servicesDirInside);
|
|
2729
|
+
if (shouldCreateSchemas) await ensureDir(schemasDirInside);
|
|
2730
|
+
|
|
2731
|
+
const componentContent = generateComponentTemplate(normalizedName, framework);
|
|
2732
|
+
const signature = getSignature(config, config.naming.componentExtension === '.astro' ? 'astro' : 'tsx');
|
|
2733
|
+
|
|
2734
|
+
const componentHash = await writeFileWithSignature(
|
|
2735
|
+
componentFilePath,
|
|
2736
|
+
componentContent,
|
|
2737
|
+
signature,
|
|
2738
|
+
config.hashing?.normalization
|
|
2739
|
+
);
|
|
2740
|
+
await registerFile(componentFilePath, {
|
|
2741
|
+
kind: 'component',
|
|
2742
|
+
template: 'component',
|
|
2743
|
+
hash: componentHash,
|
|
2744
|
+
owner: normalizedName
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
const writtenFiles = [componentFilePath];
|
|
2748
|
+
|
|
2749
|
+
const indexContent = generateIndexTemplate(normalizedName, config.naming.componentExtension);
|
|
2750
|
+
const indexHash = await writeFileWithSignature(
|
|
2751
|
+
indexFilePath,
|
|
2752
|
+
indexContent,
|
|
2753
|
+
getSignature(config, 'typescript'),
|
|
2754
|
+
config.hashing?.normalization
|
|
2755
|
+
);
|
|
2756
|
+
await registerFile(indexFilePath, {
|
|
2757
|
+
kind: 'component-file',
|
|
2758
|
+
template: 'index',
|
|
2759
|
+
hash: indexHash,
|
|
2760
|
+
owner: normalizedName
|
|
2761
|
+
});
|
|
2762
|
+
writtenFiles.push(indexFilePath);
|
|
2763
|
+
|
|
2764
|
+
if (shouldCreateTypes) {
|
|
2765
|
+
const typesContent = generateTypesTemplate(normalizedName);
|
|
2766
|
+
const hash = await writeFileWithSignature(
|
|
2767
|
+
typesFilePath,
|
|
2768
|
+
typesContent,
|
|
2769
|
+
getSignature(config, 'typescript'),
|
|
2770
|
+
config.hashing?.normalization
|
|
2771
|
+
);
|
|
2772
|
+
await registerFile(typesFilePath, {
|
|
2773
|
+
kind: 'component-file',
|
|
2774
|
+
template: 'types',
|
|
2775
|
+
hash,
|
|
2776
|
+
owner: normalizedName
|
|
2777
|
+
});
|
|
2778
|
+
writtenFiles.push(typesFilePath);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
if (shouldCreateContext) {
|
|
2782
|
+
const contextContent = generateContextTemplate(normalizedName);
|
|
2783
|
+
const hash = await writeFileWithSignature(
|
|
2784
|
+
contextFilePath,
|
|
2785
|
+
contextContent,
|
|
2786
|
+
getSignature(config, 'typescript'),
|
|
2787
|
+
config.hashing?.normalization
|
|
2788
|
+
);
|
|
2789
|
+
await registerFile(contextFilePath, {
|
|
2790
|
+
kind: 'component-file',
|
|
2791
|
+
template: 'context',
|
|
2792
|
+
hash,
|
|
2793
|
+
owner: normalizedName
|
|
2794
|
+
});
|
|
2795
|
+
writtenFiles.push(contextFilePath);
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
if (shouldCreateHook) {
|
|
2799
|
+
const hookName = getHookFunctionName(normalizedName);
|
|
2800
|
+
const hookContent = generateHookTemplate(normalizedName, hookName);
|
|
2801
|
+
const hash = await writeFileWithSignature(
|
|
2802
|
+
hookFilePath,
|
|
2803
|
+
hookContent,
|
|
2804
|
+
getSignature(config, 'typescript'),
|
|
2805
|
+
config.hashing?.normalization
|
|
2806
|
+
);
|
|
2807
|
+
await registerFile(hookFilePath, {
|
|
2808
|
+
kind: 'component-file',
|
|
2809
|
+
template: 'hook',
|
|
2810
|
+
hash,
|
|
2811
|
+
owner: normalizedName
|
|
2812
|
+
});
|
|
2813
|
+
writtenFiles.push(hookFilePath);
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
if (shouldCreateTests) {
|
|
2817
|
+
const relativeComponentPath = `../${normalizedName}${config.naming.componentExtension}`;
|
|
2818
|
+
const testContent = generateTestTemplate(normalizedName, relativeComponentPath);
|
|
2819
|
+
const hash = await writeFileWithSignature(
|
|
2820
|
+
testFilePath,
|
|
2821
|
+
testContent,
|
|
2822
|
+
getSignature(config, 'typescript'),
|
|
2823
|
+
config.hashing?.normalization
|
|
2824
|
+
);
|
|
2825
|
+
await registerFile(testFilePath, {
|
|
2826
|
+
kind: 'component-file',
|
|
2827
|
+
template: 'test',
|
|
2828
|
+
hash,
|
|
2829
|
+
owner: normalizedName
|
|
2830
|
+
});
|
|
2831
|
+
writtenFiles.push(testFilePath);
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
if (shouldCreateConfig) {
|
|
2835
|
+
const configContent = generateConfigTemplate(normalizedName);
|
|
2836
|
+
const hash = await writeFileWithSignature(
|
|
2837
|
+
configFilePath,
|
|
2838
|
+
configContent,
|
|
2839
|
+
getSignature(config, 'typescript'),
|
|
2840
|
+
config.hashing?.normalization
|
|
2841
|
+
);
|
|
2842
|
+
await registerFile(configFilePath, {
|
|
2843
|
+
kind: 'component-file',
|
|
2844
|
+
template: 'config',
|
|
2845
|
+
hash,
|
|
2846
|
+
owner: normalizedName
|
|
2847
|
+
});
|
|
2848
|
+
writtenFiles.push(configFilePath);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
if (shouldCreateConstants) {
|
|
2852
|
+
const constantsContent = generateConstantsTemplate(normalizedName);
|
|
2853
|
+
const hash = await writeFileWithSignature(
|
|
2854
|
+
constantsFilePath,
|
|
2855
|
+
constantsContent,
|
|
2856
|
+
getSignature(config, 'typescript'),
|
|
2857
|
+
config.hashing?.normalization
|
|
2858
|
+
);
|
|
2859
|
+
await registerFile(constantsFilePath, {
|
|
2860
|
+
kind: 'component-file',
|
|
2861
|
+
template: 'constants',
|
|
2862
|
+
hash,
|
|
2863
|
+
owner: normalizedName
|
|
2864
|
+
});
|
|
2865
|
+
writtenFiles.push(constantsFilePath);
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
if (shouldCreateApi) {
|
|
2869
|
+
const apiContent = generateApiTemplate(normalizedName);
|
|
2870
|
+
const hash = await writeFileWithSignature(
|
|
2871
|
+
apiFilePath,
|
|
2872
|
+
apiContent,
|
|
2873
|
+
getSignature(config, 'typescript'),
|
|
2874
|
+
config.hashing?.normalization
|
|
2875
|
+
);
|
|
2876
|
+
await registerFile(apiFilePath, {
|
|
2877
|
+
kind: 'component-file',
|
|
2878
|
+
template: 'api',
|
|
2879
|
+
hash,
|
|
2880
|
+
owner: normalizedName
|
|
2881
|
+
});
|
|
2882
|
+
writtenFiles.push(apiFilePath);
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
if (shouldCreateServices) {
|
|
2886
|
+
const servicesContent = generateServiceTemplate(normalizedName);
|
|
2887
|
+
const hash = await writeFileWithSignature(
|
|
2888
|
+
servicesFilePath,
|
|
2889
|
+
servicesContent,
|
|
2890
|
+
getSignature(config, 'typescript'),
|
|
2891
|
+
config.hashing?.normalization
|
|
2892
|
+
);
|
|
2893
|
+
await registerFile(servicesFilePath, {
|
|
2894
|
+
kind: 'component-file',
|
|
2895
|
+
template: 'service',
|
|
2896
|
+
hash,
|
|
2897
|
+
owner: normalizedName
|
|
2898
|
+
});
|
|
2899
|
+
writtenFiles.push(servicesFilePath);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
if (shouldCreateSchemas) {
|
|
2903
|
+
const schemasContent = generateSchemaTemplate(normalizedName);
|
|
2904
|
+
const hash = await writeFileWithSignature(
|
|
2905
|
+
schemasFilePath,
|
|
2906
|
+
schemasContent,
|
|
2907
|
+
getSignature(config, 'typescript'),
|
|
2908
|
+
config.hashing?.normalization
|
|
2909
|
+
);
|
|
2910
|
+
await registerFile(schemasFilePath, {
|
|
2911
|
+
kind: 'component-file',
|
|
2912
|
+
template: 'schema',
|
|
2913
|
+
hash,
|
|
2914
|
+
owner: normalizedName
|
|
2915
|
+
});
|
|
2916
|
+
writtenFiles.push(schemasFilePath);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
if (shouldCreateReadme) {
|
|
2920
|
+
const readmeContent = generateReadmeTemplate(normalizedName);
|
|
2921
|
+
const hash = await writeFileWithSignature(
|
|
2922
|
+
readmeFilePath,
|
|
2923
|
+
readmeContent,
|
|
2924
|
+
getSignature(config, 'astro'),
|
|
2925
|
+
config.hashing?.normalization
|
|
2926
|
+
);
|
|
2927
|
+
await registerFile(readmeFilePath, {
|
|
2928
|
+
kind: 'component-file',
|
|
2929
|
+
template: 'readme',
|
|
2930
|
+
hash,
|
|
2931
|
+
owner: normalizedName
|
|
2932
|
+
});
|
|
2933
|
+
writtenFiles.push(readmeFilePath);
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (shouldCreateStories) {
|
|
2937
|
+
const relativePath = `./${normalizedName}${config.naming.componentExtension}`;
|
|
2938
|
+
const storiesContent = generateStoriesTemplate(normalizedName, relativePath);
|
|
2939
|
+
const hash = await writeFileWithSignature(
|
|
2940
|
+
storiesFilePath,
|
|
2941
|
+
storiesContent,
|
|
2942
|
+
getSignature(config, 'typescript'),
|
|
2943
|
+
config.hashing?.normalization
|
|
2944
|
+
);
|
|
2945
|
+
await registerFile(storiesFilePath, {
|
|
2946
|
+
kind: 'component-file',
|
|
2947
|
+
template: 'stories',
|
|
2948
|
+
hash,
|
|
2949
|
+
owner: normalizedName
|
|
2950
|
+
});
|
|
2951
|
+
writtenFiles.push(storiesFilePath);
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// Formatting
|
|
2955
|
+
if (config.formatting.tool !== 'none') {
|
|
2956
|
+
await formatFiles(writtenFiles, config.formatting.tool);
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
console.log('✓ Component created successfully:');
|
|
2960
|
+
console.log(` Component: ${componentFilePath}`);
|
|
2961
|
+
console.log(` Index: ${indexFilePath}`);
|
|
2962
|
+
|
|
2963
|
+
if (shouldCreateContext) console.log(` Context: ${contextFilePath}`);
|
|
2964
|
+
if (shouldCreateHook) console.log(` Hook: ${hookFilePath}`);
|
|
2965
|
+
if (shouldCreateTests) console.log(` Tests: ${testFilePath}`);
|
|
2966
|
+
if (shouldCreateConfig) console.log(` Config: ${configFilePath}`);
|
|
2967
|
+
if (shouldCreateConstants) console.log(` Constants: ${constantsFilePath}`);
|
|
2968
|
+
if (shouldCreateTypes) console.log(` Types: ${typesFilePath}`);
|
|
2969
|
+
if (shouldCreateApi) console.log(` Api: ${apiFilePath}`);
|
|
2970
|
+
if (shouldCreateServices) console.log(` Services: ${servicesFilePath}`);
|
|
2971
|
+
if (shouldCreateSchemas) console.log(` Schemas: ${schemasFilePath}`);
|
|
2972
|
+
if (shouldCreateReadme) console.log(` Readme: ${readmeFilePath}`);
|
|
2973
|
+
if (shouldCreateStories) console.log(` Stories: ${storiesFilePath}`);
|
|
2974
|
+
if (shouldCreateSubComponentsDir) console.log(` Sub-components: ${subComponentsDir}/`);
|
|
2975
|
+
|
|
2976
|
+
await addComponentToState({
|
|
2977
|
+
name: normalizedName,
|
|
2978
|
+
path: componentDir
|
|
2979
|
+
});
|
|
2980
|
+
|
|
2981
|
+
if (config.git?.stageChanges) {
|
|
2982
|
+
await stageFiles(writtenFiles);
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
} catch (error) {
|
|
2986
|
+
console.error('Error:', error.message);
|
|
2987
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
2988
|
+
process.exit(1);
|
|
2989
|
+
}
|
|
2990
|
+
throw error;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
async function removeComponentCommand(identifier, options) {
|
|
2995
|
+
try {
|
|
2996
|
+
const config = await loadConfig();
|
|
2997
|
+
|
|
2998
|
+
if (config.git?.requireCleanRepo && !await isRepoClean()) {
|
|
2999
|
+
throw new Error('Git repository is not clean. Please commit or stash your changes before proceeding.');
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
const state = await loadState();
|
|
3003
|
+
|
|
3004
|
+
const component = findComponent(state, identifier);
|
|
3005
|
+
let componentDir;
|
|
3006
|
+
|
|
3007
|
+
if (component) {
|
|
3008
|
+
componentDir = component.path;
|
|
3009
|
+
} else {
|
|
3010
|
+
// Fallback: try to guess path if not in state
|
|
3011
|
+
const componentsRoot = path.resolve(process.cwd(), config.paths.components);
|
|
3012
|
+
componentDir = secureJoin(componentsRoot, identifier);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
if (options.dryRun) {
|
|
3016
|
+
console.log('Dry run - would delete:');
|
|
3017
|
+
console.log(` Component directory: ${componentDir}/`);
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
const result = await safeDeleteDir(componentDir, {
|
|
3022
|
+
force: options.force,
|
|
3023
|
+
stateFiles: state.files,
|
|
3024
|
+
acceptChanges: options.acceptChanges,
|
|
3025
|
+
normalization: config.hashing?.normalization,
|
|
3026
|
+
owner: identifier
|
|
3027
|
+
});
|
|
3028
|
+
|
|
3029
|
+
if (result.deleted) {
|
|
3030
|
+
console.log(`✓ Deleted component: ${componentDir}/`);
|
|
3031
|
+
await cleanupEmptyDirs(path.dirname(componentDir), path.join(process.cwd(), config.paths.components));
|
|
3032
|
+
|
|
3033
|
+
// Unregister files
|
|
3034
|
+
const dirPrefix = path.relative(process.cwd(), componentDir).replace(/\\/g, '/') + '/';
|
|
3035
|
+
for (const f in state.files) {
|
|
3036
|
+
if (f.startsWith(dirPrefix)) {
|
|
3037
|
+
delete state.files[f];
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
state.components = state.components.filter(c => c.name !== identifier && c.path !== componentDir);
|
|
3041
|
+
await saveState(state);
|
|
3042
|
+
} else if (result.message) {
|
|
3043
|
+
console.log(`⚠ Skipped: ${componentDir}`);
|
|
3044
|
+
console.log(` Reason: ${result.message}`);
|
|
3045
|
+
} else {
|
|
3046
|
+
console.log(`Component not found at ${componentDir}`);
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
} catch (error) {
|
|
3050
|
+
console.error('Error:', error.message);
|
|
3051
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
3052
|
+
process.exit(1);
|
|
3053
|
+
}
|
|
3054
|
+
throw error;
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
async function listSectionsCommand() {
|
|
3059
|
+
try {
|
|
3060
|
+
const config = await loadConfig();
|
|
3061
|
+
const state = await loadState();
|
|
3062
|
+
const pagesRoot = resolvePath(config, 'pages');
|
|
3063
|
+
|
|
3064
|
+
console.log('Managed Sections:');
|
|
3065
|
+
|
|
3066
|
+
if (!existsSync(pagesRoot)) {
|
|
3067
|
+
console.log(' No pages directory found.');
|
|
3068
|
+
} else {
|
|
3069
|
+
const extensions = [config.naming.routeExtension, '.ts', '.js'];
|
|
3070
|
+
const sections = await findGeneratedFiles(pagesRoot, extensions);
|
|
3071
|
+
|
|
3072
|
+
if (sections.length === 0) {
|
|
3073
|
+
console.log(' No Textor-managed sections found.');
|
|
3074
|
+
} else {
|
|
3075
|
+
for (const section of sections) {
|
|
3076
|
+
const relativePath = path.relative(pagesRoot, section).replace(/\\/g, '/');
|
|
3077
|
+
const route = '/' + relativePath
|
|
3078
|
+
.replace(/\.[^/.]+$/, ''); // Remove extension
|
|
3079
|
+
|
|
3080
|
+
const stateSection = state.sections.find(s => s.route === route);
|
|
3081
|
+
const name = stateSection ? stateSection.name : route;
|
|
3082
|
+
|
|
3083
|
+
console.log(` - ${name} [${route}] (${relativePath})`);
|
|
3084
|
+
|
|
3085
|
+
if (stateSection) {
|
|
3086
|
+
console.log(` Feature: ${stateSection.featurePath}`);
|
|
3087
|
+
console.log(` Layout: ${stateSection.layout}`);
|
|
3088
|
+
|
|
3089
|
+
// Check for senior architecture folders/files
|
|
3090
|
+
const featuresRoot = resolvePath(config, 'features');
|
|
3091
|
+
const featureDir = path.join(featuresRoot, stateSection.featurePath);
|
|
3092
|
+
const capabilities = [];
|
|
3093
|
+
|
|
3094
|
+
const checkDir = (subDir, label) => {
|
|
3095
|
+
if (existsSync(path.join(featureDir, subDir))) capabilities.push(label);
|
|
3096
|
+
};
|
|
3097
|
+
|
|
3098
|
+
checkDir('api', 'API');
|
|
3099
|
+
checkDir('services', 'Services');
|
|
3100
|
+
checkDir('schemas', 'Schemas');
|
|
3101
|
+
checkDir('hooks', 'Hooks');
|
|
3102
|
+
checkDir('context', 'Context');
|
|
3103
|
+
checkDir('types', 'Types');
|
|
3104
|
+
checkDir('scripts', 'Scripts');
|
|
3105
|
+
checkDir('sub-components', 'Sub-components');
|
|
3106
|
+
checkDir('__tests__', 'Tests');
|
|
3107
|
+
|
|
3108
|
+
if (existsSync(path.join(featureDir, 'README.md'))) capabilities.push('Docs');
|
|
3109
|
+
|
|
3110
|
+
const storiesFile = (await readdir(featureDir).catch(() => []))
|
|
3111
|
+
.find(f => f.endsWith('.stories.tsx') || f.endsWith('.stories.jsx'));
|
|
3112
|
+
if (storiesFile) capabilities.push('Stories');
|
|
3113
|
+
|
|
3114
|
+
if (capabilities.length > 0) {
|
|
3115
|
+
console.log(` Architecture: ${capabilities.join(', ')}`);
|
|
3116
|
+
}
|
|
3117
|
+
} else {
|
|
3118
|
+
// Try to extract feature path from the file content
|
|
3119
|
+
const content = await readFile(section, 'utf-8');
|
|
3120
|
+
const featureImportMatch = content.match(/import\s+\w+\s+from\s+'([^']+)'/g);
|
|
3121
|
+
if (featureImportMatch) {
|
|
3122
|
+
for (const match of featureImportMatch) {
|
|
3123
|
+
const pathMatch = match.match(/'([^']+)'/);
|
|
3124
|
+
if (pathMatch) {
|
|
3125
|
+
console.log(` Import: ${pathMatch[1]}`);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
if (state.components && state.components.length > 0) {
|
|
3135
|
+
console.log('\nManaged Components:');
|
|
3136
|
+
for (const component of state.components) {
|
|
3137
|
+
console.log(` - ${component.name} (${path.relative(process.cwd(), component.path)})`);
|
|
3138
|
+
|
|
3139
|
+
const componentDir = component.path;
|
|
3140
|
+
const capabilities = [];
|
|
3141
|
+
|
|
3142
|
+
const checkDir = (subDir, label) => {
|
|
3143
|
+
if (existsSync(path.join(componentDir, subDir))) capabilities.push(label);
|
|
3144
|
+
};
|
|
3145
|
+
|
|
3146
|
+
checkDir('api', 'API');
|
|
3147
|
+
checkDir('services', 'Services');
|
|
3148
|
+
checkDir('schemas', 'Schemas');
|
|
3149
|
+
checkDir('hooks', 'Hooks');
|
|
3150
|
+
checkDir('context', 'Context');
|
|
3151
|
+
checkDir('types', 'Types');
|
|
3152
|
+
checkDir('sub-components', 'Sub-components');
|
|
3153
|
+
checkDir('__tests__', 'Tests');
|
|
3154
|
+
checkDir('config', 'Config');
|
|
3155
|
+
checkDir('constants', 'Constants');
|
|
3156
|
+
|
|
3157
|
+
if (existsSync(path.join(componentDir, 'README.md'))) capabilities.push('Docs');
|
|
3158
|
+
|
|
3159
|
+
const storiesFile = (await readdir(componentDir).catch(() => []))
|
|
3160
|
+
.find(f => f.endsWith('.stories.tsx') || f.endsWith('.stories.jsx'));
|
|
3161
|
+
if (storiesFile) capabilities.push('Stories');
|
|
3162
|
+
|
|
3163
|
+
if (capabilities.length > 0) {
|
|
3164
|
+
console.log(` Architecture: ${capabilities.join(', ')}`);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
} catch (error) {
|
|
3169
|
+
console.error('Error:', error.message);
|
|
3170
|
+
process.exit(1);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
async function findGeneratedFiles(dir, extensions) {
|
|
3175
|
+
const results = [];
|
|
3176
|
+
const entries = await readdir(dir);
|
|
3177
|
+
const exts = Array.isArray(extensions) ? extensions : [extensions];
|
|
3178
|
+
|
|
3179
|
+
for (const entry of entries) {
|
|
3180
|
+
const fullPath = path.join(dir, entry);
|
|
3181
|
+
const stats = await stat(fullPath);
|
|
3182
|
+
|
|
3183
|
+
if (stats.isDirectory()) {
|
|
3184
|
+
results.push(...await findGeneratedFiles(fullPath, exts));
|
|
3185
|
+
} else if (exts.some(ext => entry.endsWith(ext))) {
|
|
3186
|
+
if (await isTextorGenerated(fullPath)) {
|
|
3187
|
+
results.push(fullPath);
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
return results;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
async function validateStateCommand(options) {
|
|
3196
|
+
try {
|
|
3197
|
+
const config = await loadConfig();
|
|
3198
|
+
const state = await loadState();
|
|
3199
|
+
const results = {
|
|
3200
|
+
missing: [],
|
|
3201
|
+
modified: [],
|
|
3202
|
+
valid: 0
|
|
3203
|
+
};
|
|
3204
|
+
|
|
3205
|
+
const files = Object.keys(state.files);
|
|
3206
|
+
|
|
3207
|
+
for (const relativePath of files) {
|
|
3208
|
+
const fullPath = path.join(process.cwd(), relativePath);
|
|
3209
|
+
const fileData = state.files[relativePath];
|
|
3210
|
+
|
|
3211
|
+
if (!existsSync(fullPath)) {
|
|
3212
|
+
results.missing.push(relativePath);
|
|
3213
|
+
continue;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
3217
|
+
const currentHash = calculateHash(content, config.hashing?.normalization);
|
|
3218
|
+
|
|
3219
|
+
if (currentHash !== fileData.hash) {
|
|
3220
|
+
results.modified.push({
|
|
3221
|
+
path: relativePath,
|
|
3222
|
+
newHash: currentHash
|
|
3223
|
+
});
|
|
3224
|
+
} else {
|
|
3225
|
+
results.valid++;
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
console.log('State Validation Results:');
|
|
3230
|
+
console.log(` Valid files: ${results.valid}`);
|
|
3231
|
+
|
|
3232
|
+
if (results.missing.length > 0) {
|
|
3233
|
+
console.log(`\n Missing files: ${results.missing.length}`);
|
|
3234
|
+
results.missing.forEach(f => console.log(` - ${f}`));
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
if (results.modified.length > 0) {
|
|
3238
|
+
console.log(`\n Modified files: ${results.modified.length}`);
|
|
3239
|
+
results.modified.forEach(f => console.log(` - ${f.path}`));
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
if (options.fix) {
|
|
3243
|
+
let fixedCount = 0;
|
|
3244
|
+
|
|
3245
|
+
// Fix modified files if they still have the Textor signature
|
|
3246
|
+
for (const mod of results.modified) {
|
|
3247
|
+
const fullPath = path.join(process.cwd(), mod.path);
|
|
3248
|
+
if (await isTextorGenerated(fullPath)) {
|
|
3249
|
+
state.files[mod.path].hash = mod.newHash;
|
|
3250
|
+
fixedCount++;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// Remove missing files from state
|
|
3255
|
+
for (const miss of results.missing) {
|
|
3256
|
+
delete state.files[miss];
|
|
3257
|
+
fixedCount++;
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
if (fixedCount > 0) {
|
|
3261
|
+
await saveState(state);
|
|
3262
|
+
console.log(`\n✓ Fixed ${fixedCount} entries in state.`);
|
|
3263
|
+
} else {
|
|
3264
|
+
console.log('\nNothing to fix or missing signatures on modified files.');
|
|
3265
|
+
}
|
|
3266
|
+
} else if (results.missing.length > 0 || results.modified.length > 0) {
|
|
3267
|
+
console.log('\nRun with --fix to synchronize state with reality (requires Textor signature to be present).');
|
|
3268
|
+
} else {
|
|
3269
|
+
console.log('\n✓ State is perfectly in sync.');
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
} catch (error) {
|
|
3273
|
+
console.error('Error:', error.message);
|
|
3274
|
+
process.exit(1);
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
async function statusCommand() {
|
|
3279
|
+
try {
|
|
3280
|
+
const config = await loadConfig();
|
|
3281
|
+
const state = await loadState();
|
|
3282
|
+
|
|
3283
|
+
const results = {
|
|
3284
|
+
missing: [],
|
|
3285
|
+
modified: [],
|
|
3286
|
+
untracked: [], // Has signature, not in state
|
|
3287
|
+
orphaned: [], // No signature, not in state
|
|
3288
|
+
synced: 0
|
|
3289
|
+
};
|
|
3290
|
+
|
|
3291
|
+
const roots = [
|
|
3292
|
+
resolvePath(config, 'pages'),
|
|
3293
|
+
resolvePath(config, 'features'),
|
|
3294
|
+
resolvePath(config, 'components')
|
|
3295
|
+
].map(p => path.resolve(p));
|
|
3296
|
+
|
|
3297
|
+
const diskFiles = new Set();
|
|
3298
|
+
const configSignatures = Object.values(config.signatures || {});
|
|
3299
|
+
|
|
3300
|
+
for (const root of roots) {
|
|
3301
|
+
if (existsSync(root)) {
|
|
3302
|
+
await scanDirectory(root, diskFiles);
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// 1. Check state files against disk
|
|
3307
|
+
for (const relativePath in state.files) {
|
|
3308
|
+
const fullPath = path.join(process.cwd(), relativePath);
|
|
3309
|
+
|
|
3310
|
+
if (!existsSync(fullPath)) {
|
|
3311
|
+
results.missing.push(relativePath);
|
|
3312
|
+
continue;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
diskFiles.delete(relativePath);
|
|
3316
|
+
|
|
3317
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
3318
|
+
const currentHash = calculateHash(content, config.hashing?.normalization);
|
|
3319
|
+
const fileData = state.files[relativePath];
|
|
3320
|
+
|
|
3321
|
+
if (currentHash !== fileData.hash) {
|
|
3322
|
+
results.modified.push(relativePath);
|
|
3323
|
+
} else {
|
|
3324
|
+
results.synced++;
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
// 2. Check remaining disk files
|
|
3329
|
+
for (const relativePath of diskFiles) {
|
|
3330
|
+
const fullPath = path.join(process.cwd(), relativePath);
|
|
3331
|
+
const isGenerated = await isTextorGenerated(fullPath, configSignatures);
|
|
3332
|
+
|
|
3333
|
+
if (isGenerated) {
|
|
3334
|
+
results.untracked.push(relativePath);
|
|
3335
|
+
} else {
|
|
3336
|
+
results.orphaned.push(relativePath);
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
// Reporting
|
|
3341
|
+
console.log('Textor Status Report:');
|
|
3342
|
+
console.log(` Synced files: ${results.synced}`);
|
|
3343
|
+
|
|
3344
|
+
if (results.modified.length > 0) {
|
|
3345
|
+
console.log(`\n MODIFIED (In state, but content changed): ${results.modified.length}`);
|
|
3346
|
+
results.modified.forEach(f => console.log(` ~ ${f}`));
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
if (results.missing.length > 0) {
|
|
3350
|
+
console.log(`\n MISSING (In state, but not on disk): ${results.missing.length}`);
|
|
3351
|
+
results.missing.forEach(f => console.log(` - ${f}`));
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
if (results.untracked.length > 0) {
|
|
3355
|
+
console.log(`\n UNTRACKED (On disk with signature, not in state): ${results.untracked.length}`);
|
|
3356
|
+
results.untracked.forEach(f => console.log(` + ${f}`));
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
if (results.orphaned.length > 0) {
|
|
3360
|
+
console.log(`\n ORPHANED (On disk without signature, in managed folder): ${results.orphaned.length}`);
|
|
3361
|
+
results.orphaned.forEach(f => console.log(` ? ${f}`));
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
if (results.modified.length === 0 && results.missing.length === 0 && results.untracked.length === 0) {
|
|
3365
|
+
console.log('\n✓ Project is perfectly synchronized with state.');
|
|
3366
|
+
} else {
|
|
3367
|
+
console.log('\nUse "textor sync" to reconcile state with disk.');
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
} catch (error) {
|
|
3371
|
+
console.error('Error:', error.message);
|
|
3372
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
3373
|
+
process.exit(1);
|
|
3374
|
+
}
|
|
3375
|
+
throw error;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
async function syncCommand(options) {
|
|
3380
|
+
try {
|
|
3381
|
+
const config = await loadConfig();
|
|
3382
|
+
const state = await loadState();
|
|
3383
|
+
const results = {
|
|
3384
|
+
added: [],
|
|
3385
|
+
updated: [],
|
|
3386
|
+
missing: [],
|
|
3387
|
+
untouched: 0
|
|
3388
|
+
};
|
|
3389
|
+
|
|
3390
|
+
const roots = [
|
|
3391
|
+
resolvePath(config, 'pages'),
|
|
3392
|
+
resolvePath(config, 'features'),
|
|
3393
|
+
resolvePath(config, 'components')
|
|
3394
|
+
].map(p => path.resolve(p));
|
|
3395
|
+
|
|
3396
|
+
const managedFiles = new Set();
|
|
3397
|
+
const configSignatures = Object.values(config.signatures || {});
|
|
3398
|
+
|
|
3399
|
+
for (const root of roots) {
|
|
3400
|
+
if (existsSync(root)) {
|
|
3401
|
+
await scanDirectory(root, managedFiles);
|
|
3402
|
+
} else {
|
|
3403
|
+
const relativeRoot = path.relative(process.cwd(), root).replace(/\\/g, '/');
|
|
3404
|
+
console.log(` Warning: Managed directory not found: ${relativeRoot}`);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
const stateFiles = Object.keys(state.files);
|
|
3409
|
+
let changed = false;
|
|
3410
|
+
|
|
3411
|
+
// 1. Check files in state
|
|
3412
|
+
for (const relativePath of stateFiles) {
|
|
3413
|
+
const fullPath = path.join(process.cwd(), relativePath);
|
|
3414
|
+
|
|
3415
|
+
if (!existsSync(fullPath)) {
|
|
3416
|
+
results.missing.push(relativePath);
|
|
3417
|
+
continue;
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
managedFiles.delete(relativePath); // Remove from managedFiles so we don't process it again in step 2
|
|
3421
|
+
|
|
3422
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
3423
|
+
const currentHash = calculateHash(content, config.hashing?.normalization);
|
|
3424
|
+
const fileData = state.files[relativePath];
|
|
3425
|
+
|
|
3426
|
+
if (currentHash !== fileData.hash) {
|
|
3427
|
+
const isGenerated = await isTextorGenerated(fullPath, configSignatures);
|
|
3428
|
+
if (isGenerated || options.force) {
|
|
3429
|
+
results.updated.push({ path: relativePath, newHash: currentHash });
|
|
3430
|
+
}
|
|
3431
|
+
} else {
|
|
3432
|
+
results.untouched++;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// 2. Check files on disk not in state
|
|
3437
|
+
let ignoredCount = 0;
|
|
3438
|
+
for (const relativePath of managedFiles) {
|
|
3439
|
+
const fullPath = path.join(process.cwd(), relativePath);
|
|
3440
|
+
const isGenerated = await isTextorGenerated(fullPath, configSignatures);
|
|
3441
|
+
|
|
3442
|
+
if (isGenerated || options.includeAll) {
|
|
3443
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
3444
|
+
const hash = calculateHash(content, config.hashing?.normalization);
|
|
3445
|
+
results.added.push({ path: relativePath, hash });
|
|
3446
|
+
} else {
|
|
3447
|
+
ignoredCount++;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
if (results.added.length > 0 || results.updated.length > 0 || results.missing.length > 0) {
|
|
3452
|
+
changed = true;
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// Reporting
|
|
3456
|
+
console.log('Sync Analysis:');
|
|
3457
|
+
console.log(` Untouched files: ${results.untouched}`);
|
|
3458
|
+
|
|
3459
|
+
if (ignoredCount > 0 && !options.includeAll) {
|
|
3460
|
+
console.log(` Ignored non-generated files: ${ignoredCount} (use --include-all to track them)`);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
if (results.added.length > 0) {
|
|
3464
|
+
console.log(`\n New files to track: ${results.added.length}`);
|
|
3465
|
+
results.added.forEach(f => console.log(` + ${f.path}`));
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
if (results.updated.length > 0) {
|
|
3469
|
+
console.log(`\n Modified files to update: ${results.updated.length}`);
|
|
3470
|
+
results.updated.forEach(f => console.log(` ~ ${f.path}`));
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
if (results.missing.length > 0) {
|
|
3474
|
+
console.log(`\n Missing files to remove from state: ${results.missing.length}`);
|
|
3475
|
+
results.missing.forEach(f => console.log(` - ${f}`));
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
if (options.dryRun) {
|
|
3479
|
+
console.log('\nDry run: no changes applied.');
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
if (results.added.length > 0) {
|
|
3484
|
+
for (const file of results.added) {
|
|
3485
|
+
state.files[file.path] = {
|
|
3486
|
+
kind: inferKind(file.path, config),
|
|
3487
|
+
hash: file.hash,
|
|
3488
|
+
timestamp: new Date().toISOString(),
|
|
3489
|
+
synced: true
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
if (results.updated.length > 0) {
|
|
3495
|
+
for (const file of results.updated) {
|
|
3496
|
+
state.files[file.path].hash = file.newHash;
|
|
3497
|
+
state.files[file.path].timestamp = new Date().toISOString();
|
|
3498
|
+
state.files[file.path].synced = true;
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
if (results.missing.length > 0) {
|
|
3503
|
+
for (const relPath of results.missing) {
|
|
3504
|
+
delete state.files[relPath];
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
if (changed) {
|
|
3509
|
+
// 3. Reconstruct components and sections
|
|
3510
|
+
state.components = reconstructComponents(state.files, config);
|
|
3511
|
+
state.sections = reconstructSections(state, config);
|
|
3512
|
+
|
|
3513
|
+
await saveState(state);
|
|
3514
|
+
console.log(`\n✓ State synchronized successfully (${results.added.length} added, ${results.updated.length} updated, ${results.missing.length} removed).`);
|
|
3515
|
+
} else {
|
|
3516
|
+
// Even if no files changed, check if metadata needs reconstruction
|
|
3517
|
+
const newComponents = reconstructComponents(state.files, config);
|
|
3518
|
+
const newSections = reconstructSections(state, config);
|
|
3519
|
+
|
|
3520
|
+
const componentsEqual = JSON.stringify(newComponents) === JSON.stringify(state.components || []);
|
|
3521
|
+
const sectionsEqual = JSON.stringify(newSections) === JSON.stringify(state.sections || []);
|
|
3522
|
+
|
|
3523
|
+
if (!componentsEqual || !sectionsEqual) {
|
|
3524
|
+
state.components = newComponents;
|
|
3525
|
+
state.sections = newSections;
|
|
3526
|
+
await saveState(state);
|
|
3527
|
+
console.log('\n✓ Metadata (components/sections) reconstructed.');
|
|
3528
|
+
} else {
|
|
3529
|
+
console.log('\n✓ Everything is already in sync.');
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
} catch (error) {
|
|
3534
|
+
console.error('Error:', error.message);
|
|
3535
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
3536
|
+
process.exit(1);
|
|
3537
|
+
}
|
|
3538
|
+
throw error;
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
async function adoptCommand(identifier, options) {
|
|
3543
|
+
try {
|
|
3544
|
+
const config = await loadConfig();
|
|
3545
|
+
const state = await loadState();
|
|
3546
|
+
|
|
3547
|
+
const roots = {
|
|
3548
|
+
pages: resolvePath(config, 'pages'),
|
|
3549
|
+
features: resolvePath(config, 'features'),
|
|
3550
|
+
components: resolvePath(config, 'components')
|
|
3551
|
+
};
|
|
3552
|
+
|
|
3553
|
+
let filesToAdopt = [];
|
|
3554
|
+
|
|
3555
|
+
if (!identifier && options.all) {
|
|
3556
|
+
// Adopt all untracked files in all roots
|
|
3557
|
+
const managedFiles = new Set();
|
|
3558
|
+
for (const root of Object.values(roots)) {
|
|
3559
|
+
if (existsSync(root)) {
|
|
3560
|
+
await scanDirectory(root, managedFiles);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
filesToAdopt = Array.from(managedFiles).filter(f => !state.files[f]);
|
|
3564
|
+
} else if (identifier) {
|
|
3565
|
+
const untrackedFiles = new Set();
|
|
3566
|
+
|
|
3567
|
+
// 1. Try as direct path
|
|
3568
|
+
const fullPath = path.resolve(process.cwd(), identifier);
|
|
3569
|
+
if (existsSync(fullPath)) {
|
|
3570
|
+
await scanDirectoryOrFile(fullPath, untrackedFiles, state);
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
// 2. Try as component name
|
|
3574
|
+
const compPath = path.join(roots.components, identifier);
|
|
3575
|
+
if (existsSync(compPath)) {
|
|
3576
|
+
await scanDirectoryOrFile(compPath, untrackedFiles, state);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// 3. Try as feature name
|
|
3580
|
+
const featPath = path.join(roots.features, identifier);
|
|
3581
|
+
if (existsSync(featPath)) {
|
|
3582
|
+
await scanDirectoryOrFile(featPath, untrackedFiles, state);
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
// 4. Try as route or page name
|
|
3586
|
+
const cleanRoute = identifier.startsWith('/') ? identifier.slice(1) : identifier;
|
|
3587
|
+
const pagePath = path.join(roots.pages, cleanRoute + (config.naming?.routeExtension || '.astro'));
|
|
3588
|
+
if (existsSync(pagePath)) {
|
|
3589
|
+
await scanDirectoryOrFile(pagePath, untrackedFiles, state);
|
|
3590
|
+
}
|
|
3591
|
+
const nestedPagePath = path.join(roots.pages, cleanRoute, config.routing?.indexFile || 'index.astro');
|
|
3592
|
+
if (existsSync(nestedPagePath)) {
|
|
3593
|
+
await scanDirectoryOrFile(nestedPagePath, untrackedFiles, state);
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
filesToAdopt = Array.from(untrackedFiles);
|
|
3597
|
+
|
|
3598
|
+
if (filesToAdopt.length === 0 && !existsSync(fullPath)) {
|
|
3599
|
+
throw new Error(`Could not find any untracked files for identifier: ${identifier}`);
|
|
3600
|
+
}
|
|
3601
|
+
} else {
|
|
3602
|
+
throw new Error('Please provide a path/identifier or use --all');
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
// Filter to ensure all files are within managed roots
|
|
3606
|
+
const rootPaths = Object.values(roots).map(p => path.resolve(p));
|
|
3607
|
+
filesToAdopt = filesToAdopt.filter(relPath => {
|
|
3608
|
+
const fullPath = path.resolve(process.cwd(), relPath);
|
|
3609
|
+
return rootPaths.some(root => fullPath.startsWith(root));
|
|
3610
|
+
});
|
|
3611
|
+
|
|
3612
|
+
if (filesToAdopt.length === 0) {
|
|
3613
|
+
console.log('No untracked files found to adopt.');
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
console.log(`Found ${filesToAdopt.length} files to adopt...`);
|
|
3618
|
+
let adoptedCount = 0;
|
|
3619
|
+
|
|
3620
|
+
for (const relPath of filesToAdopt) {
|
|
3621
|
+
const success = await adoptFile(relPath, config, state, options);
|
|
3622
|
+
if (success) adoptedCount++;
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
if (adoptedCount > 0 && !options.dryRun) {
|
|
3626
|
+
state.components = reconstructComponents(state.files, config);
|
|
3627
|
+
state.sections = reconstructSections(state, config);
|
|
3628
|
+
await saveState(state);
|
|
3629
|
+
console.log(`\n✓ Successfully adopted ${adoptedCount} files.`);
|
|
3630
|
+
} else if (options.dryRun) {
|
|
3631
|
+
console.log(`\nDry run: would adopt ${adoptedCount} files.`);
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
} catch (error) {
|
|
3635
|
+
console.error('Error:', error.message);
|
|
3636
|
+
if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
|
|
3637
|
+
process.exit(1);
|
|
3638
|
+
}
|
|
3639
|
+
throw error;
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
async function adoptFile(relPath, config, state, options) {
|
|
3644
|
+
const fullPath = path.join(process.cwd(), relPath);
|
|
3645
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
3646
|
+
|
|
3647
|
+
const ext = path.extname(relPath);
|
|
3648
|
+
let signature = '';
|
|
3649
|
+
if (ext === '.astro') signature = config.signatures.astro;
|
|
3650
|
+
else if (ext === '.ts' || ext === '.tsx') signature = config.signatures.typescript;
|
|
3651
|
+
else if (ext === '.js' || ext === '.jsx') signature = config.signatures.javascript;
|
|
3652
|
+
|
|
3653
|
+
let finalContent = content;
|
|
3654
|
+
|
|
3655
|
+
if (signature && !content.includes(signature)) {
|
|
3656
|
+
if (options.dryRun) {
|
|
3657
|
+
console.log(` ~ Would add signature to ${relPath}`);
|
|
3658
|
+
} else {
|
|
3659
|
+
finalContent = signature + '\n' + content;
|
|
3660
|
+
await writeFile(fullPath, finalContent, 'utf-8');
|
|
3661
|
+
console.log(` + Added signature and adopting: ${relPath}`);
|
|
3662
|
+
}
|
|
3663
|
+
} else {
|
|
3664
|
+
if (options.dryRun) {
|
|
3665
|
+
console.log(` + Would adopt (already has signature or no signature for ext): ${relPath}`);
|
|
3666
|
+
} else {
|
|
3667
|
+
console.log(` + Adopting: ${relPath}`);
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
if (!options.dryRun) {
|
|
3672
|
+
const hash = calculateHash(finalContent, config.hashing?.normalization);
|
|
3673
|
+
state.files[relPath] = {
|
|
3674
|
+
kind: inferKind(relPath, config),
|
|
3675
|
+
hash: hash,
|
|
3676
|
+
timestamp: new Date().toISOString(),
|
|
3677
|
+
synced: true
|
|
3678
|
+
};
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
return true;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
async function scanDirectoryOrFile(fullPath, fileSet, state) {
|
|
3685
|
+
if ((await stat(fullPath)).isDirectory()) {
|
|
3686
|
+
const dirFiles = new Set();
|
|
3687
|
+
await scanDirectory(fullPath, dirFiles);
|
|
3688
|
+
for (const f of dirFiles) {
|
|
3689
|
+
if (!state.files[f]) fileSet.add(f);
|
|
3690
|
+
}
|
|
3691
|
+
} else {
|
|
3692
|
+
const relPath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
|
|
3693
|
+
if (!state.files[relPath]) {
|
|
3694
|
+
fileSet.add(relPath);
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
const program = new Command();
|
|
3700
|
+
|
|
3701
|
+
program
|
|
3702
|
+
.name('textor')
|
|
3703
|
+
.description('Safe, deterministic scaffolding and refactoring tool for Astro projects')
|
|
3704
|
+
.version('1.0.0');
|
|
3705
|
+
|
|
3706
|
+
program
|
|
3707
|
+
.command('init')
|
|
3708
|
+
.description('Initialize Textor configuration')
|
|
3709
|
+
.option('--force', 'Overwrite existing configuration')
|
|
3710
|
+
.action(initCommand);
|
|
3711
|
+
|
|
3712
|
+
program
|
|
3713
|
+
.command('add-section <route> <featurePath>')
|
|
3714
|
+
.description('Create a route + feature binding')
|
|
3715
|
+
.option('--preset <name>', 'Scaffolding preset (minimal, standard, senior)')
|
|
3716
|
+
.option('--layout <name>', 'Layout component name (use "none" for no layout)', 'Main')
|
|
3717
|
+
.option('--name <name>', 'Section name for state tracking')
|
|
3718
|
+
.option('--endpoint', 'Create an API endpoint (.ts) instead of an Astro page')
|
|
3719
|
+
.option('--api', 'Create api directory')
|
|
3720
|
+
.option('--services', 'Create services directory')
|
|
3721
|
+
.option('--schemas', 'Create schemas directory')
|
|
3722
|
+
.option('--hooks', 'Create hooks directory')
|
|
3723
|
+
.option('--context', 'Create context directory')
|
|
3724
|
+
.option('--tests', 'Create tests directory')
|
|
3725
|
+
.option('--types', 'Create types directory')
|
|
3726
|
+
.option('--readme', 'Create README.md')
|
|
3727
|
+
.option('--stories', 'Create Storybook stories')
|
|
3728
|
+
.option('--index', 'Create index.ts')
|
|
3729
|
+
.option('--no-sub-components-dir', 'Skip creating sub-components directory')
|
|
3730
|
+
.option('--no-scripts-dir', 'Skip creating scripts directory')
|
|
3731
|
+
.option('--dry-run', 'Show what would be created without creating')
|
|
3732
|
+
.option('--force', 'Overwrite existing files')
|
|
3733
|
+
.action(addSectionCommand);
|
|
3734
|
+
|
|
3735
|
+
program
|
|
3736
|
+
.command('remove-section <route> [featurePath]')
|
|
3737
|
+
.description('Remove a section (featurePath optional if using state)')
|
|
3738
|
+
.option('--keep-feature', 'Keep the feature module')
|
|
3739
|
+
.option('--keep-route', 'Keep the route file')
|
|
3740
|
+
.option('--accept-changes', 'Allow removal of modified files')
|
|
3741
|
+
.option('--dry-run', 'Show what would be deleted without deleting')
|
|
3742
|
+
.option('--force', 'Delete files even if not generated by Textor')
|
|
3743
|
+
.action(removeSectionCommand);
|
|
3744
|
+
|
|
3745
|
+
program
|
|
3746
|
+
.command('move-section')
|
|
3747
|
+
.description('Move a section')
|
|
3748
|
+
.argument('<fromRoute>', 'Source route or section name')
|
|
3749
|
+
.argument('<fromFeatureOrToRoute>', 'Source feature path OR Destination route (if using state)')
|
|
3750
|
+
.argument('[toRoute]', 'Destination route')
|
|
3751
|
+
.argument('[toFeature]', 'Destination feature path')
|
|
3752
|
+
.option('--keep-feature', 'Only move route, not feature')
|
|
3753
|
+
.option('--accept-changes', 'Allow moving of modified files')
|
|
3754
|
+
.option('--scan', 'Enable repo-wide import updates')
|
|
3755
|
+
.option('--force', 'Overwrite existing files')
|
|
3756
|
+
.option('--dry-run', 'Show what would be moved without moving')
|
|
3757
|
+
.action(moveSectionCommand);
|
|
3758
|
+
|
|
3759
|
+
program
|
|
3760
|
+
.command('create-component <componentName>')
|
|
3761
|
+
.description('Create a reusable UI component')
|
|
3762
|
+
.option('--preset <name>', 'Scaffolding preset (minimal, standard, senior)')
|
|
3763
|
+
.option('--api', 'Create api directory')
|
|
3764
|
+
.option('--services', 'Create services directory')
|
|
3765
|
+
.option('--schemas', 'Create schemas directory')
|
|
3766
|
+
.option('--readme', 'Create README.md')
|
|
3767
|
+
.option('--stories', 'Create Storybook stories')
|
|
3768
|
+
.option('--no-context', 'Skip creating context file')
|
|
3769
|
+
.option('--no-hook', 'Skip creating hook file')
|
|
3770
|
+
.option('--no-tests', 'Skip creating test file')
|
|
3771
|
+
.option('--no-types', 'Skip creating types file')
|
|
3772
|
+
.option('--no-config', 'Skip creating config file')
|
|
3773
|
+
.option('--no-constants', 'Skip creating constants file')
|
|
3774
|
+
.option('--no-sub-components-dir', 'Skip creating sub-components directory')
|
|
3775
|
+
.option('--dry-run', 'Show what would be created without creating')
|
|
3776
|
+
.option('--force', 'Overwrite existing files')
|
|
3777
|
+
.action(createComponentCommand);
|
|
3778
|
+
|
|
3779
|
+
program
|
|
3780
|
+
.command('remove-component <name>')
|
|
3781
|
+
.description('Remove a reusable UI component')
|
|
3782
|
+
.option('--accept-changes', 'Allow removal of modified files')
|
|
3783
|
+
.option('--dry-run', 'Show what would be deleted without deleting')
|
|
3784
|
+
.option('--force', 'Delete files even if not generated by Textor')
|
|
3785
|
+
.action(removeComponentCommand);
|
|
3786
|
+
|
|
3787
|
+
program
|
|
3788
|
+
.command('list-sections')
|
|
3789
|
+
.description('List all Textor-managed sections')
|
|
3790
|
+
.action(listSectionsCommand);
|
|
3791
|
+
|
|
3792
|
+
program
|
|
3793
|
+
.command('status')
|
|
3794
|
+
.description('Show drift between state and disk')
|
|
3795
|
+
.action(statusCommand);
|
|
3796
|
+
|
|
3797
|
+
program
|
|
3798
|
+
.command('validate-state')
|
|
3799
|
+
.description('Validate that the state file matches the project files')
|
|
3800
|
+
.option('--fix', 'Try to fix state by re-hashing modified files (requires signatures to match)')
|
|
3801
|
+
.action(validateStateCommand);
|
|
3802
|
+
|
|
3803
|
+
program
|
|
3804
|
+
.command('sync')
|
|
3805
|
+
.description('Synchronize the state with the actual files in managed directories')
|
|
3806
|
+
.option('--include-all', 'Include all files in managed directories, even without Textor signature')
|
|
3807
|
+
.option('--force', 'Update hashes for modified files even without Textor signature')
|
|
3808
|
+
.option('--dry-run', 'Show what would be changed without applying')
|
|
3809
|
+
.action(syncCommand);
|
|
3810
|
+
|
|
3811
|
+
program
|
|
3812
|
+
.command('adopt [path]')
|
|
3813
|
+
.description('Adopt untracked files into Textor state, adding signatures')
|
|
3814
|
+
.option('--all', 'Adopt all untracked files in managed directories')
|
|
3815
|
+
.option('--dry-run', 'Show what would be adopted without applying')
|
|
3816
|
+
.action(adoptCommand);
|
|
3817
|
+
|
|
3818
|
+
program.parse();
|
|
3819
|
+
//# sourceMappingURL=textor.js.map
|