@oamm/textor 1.0.7 → 1.0.9

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 CHANGED
@@ -279,6 +279,20 @@ pnpm textor normalize-state
279
279
  **Options:**
280
280
  - `--dry-run`: Print the normalized state without writing it.
281
281
 
282
+ ### prune-missing
283
+ Remove missing references from Textor state. This command identifies files that are tracked in the state but are no longer present on disk and removes them from `.textor/state.json`.
284
+
285
+ **Safe-by-default**: This command never deletes any files from your disk; it only updates the state tracker.
286
+
287
+ ```bash
288
+ pnpm textor prune-missing
289
+ ```
290
+
291
+ **Options:**
292
+ - `--dry-run`: Preview what would be removed from the state without making changes.
293
+ - `--yes`: Skip confirmation prompt.
294
+ - `--no-interactive`: Disable interactive prompts (useful for CI).
295
+
282
296
  ## šŸ—ļø Technical Architecture
283
297
 
284
298
  Textor is designed with enterprise-grade robustness, moving beyond simple scaffolding to provide a reliable refactoring engine.
@@ -341,6 +355,8 @@ The .textor/config.json file allows full control over the tool's behavior.
341
355
  "indexFile": "index.astro"
342
356
  },
343
357
  "importAliases": {
358
+ "layouts": "@/layouts",
359
+ "features": "@/features"
344
360
  },
345
361
  "naming": {
346
362
  "routeExtension": ".astro",
@@ -459,6 +475,31 @@ The .textor/config.json file allows full control over the tool's behavior.
459
475
  ```
460
476
  *Supported formatting tools: prettier, biome, none.*
461
477
 
478
+ ### 7. Layout Parameters
479
+
480
+ You can pass parameters to your layout component by defining `layoutProps` in `.textor/config.json`. These props support variable substitution.
481
+
482
+ ```json
483
+ {
484
+ "features": {
485
+ "layout": "AppLayout",
486
+ "layoutProps": {
487
+ "title": "{{componentName}}",
488
+ "description": "Description for {{componentName}}"
489
+ }
490
+ }
491
+ }
492
+ ```
493
+
494
+ You can also override these props via the CLI using the `--prop` flag:
495
+ ```bash
496
+ pnpm textor add-section /users users/roles --prop title="Custom Title" --prop breadcrumbs='{[{ label: "Users" }]}'
497
+ ```
498
+
499
+ Properties that start and end with curly braces `{}` are passed as JavaScript expressions, others as strings.
500
+
501
+ ---
502
+
462
503
  ## šŸ“ Template Overrides
463
504
 
464
505
  You can customize the code generated by Textor by providing your own templates. Textor looks for override files in the `.textor/templates/` directory at your project root.
@@ -473,7 +514,7 @@ You can customize the code generated by Textor by providing your own templates.
473
514
 
474
515
  | Template Name | File to create in `.textor/templates/` | Available Variables |
475
516
  | :--- | :--- | :--- |
476
- | **Route** | `route.astro` | `{{layoutName}}`, `{{layoutImportPath}}`, `{{featureImportPath}}`, `{{featureComponentName}}` |
517
+ | **Route** | `route.astro` | `{{layoutName}}`, `{{layoutImportPath}}`, `{{featureImportPath}}`, `{{featureComponentName}}`, plus any `layoutProps` |
477
518
  | **Feature** | `feature.astro` or `feature.tsx` | `{{componentName}}`, `{{scriptImportPath}}` |
478
519
  | **Component** | `component.astro` or `component.tsx` | `{{componentName}}` |
479
520
  | **Hook** | `hook.ts` | `{{componentName}}`, `{{hookName}}` |
@@ -6,6 +6,7 @@ import path from 'path';
6
6
  import { createHash } from 'crypto';
7
7
  import { exec } from 'child_process';
8
8
  import { promisify } from 'util';
9
+ import readline from 'readline';
9
10
 
10
11
  const CONFIG_DIR$1 = '.textor';
11
12
  const CONFIG_FILE = 'config.json';
@@ -42,6 +43,7 @@ const CURRENT_CONFIG_VERSION = 2;
42
43
  * @property {boolean} features.createScriptsDir
43
44
  * @property {string} features.scriptsIndexFile
44
45
  * @property {string} features.layout
46
+ * @property {Object} features.layoutProps
45
47
  * @property {Object} components
46
48
  * @property {boolean} components.createSubComponentsDir
47
49
  * @property {boolean} components.createContext
@@ -108,7 +110,8 @@ const DEFAULT_CONFIG = {
108
110
  createReadme: false,
109
111
  createStories: false,
110
112
  createIndex: false,
111
- layout: 'Main'
113
+ layout: 'Main',
114
+ layoutProps: {}
112
115
  },
113
116
  components: {
114
117
  framework: 'react',
@@ -1020,27 +1023,6 @@ async function formatFiles(filePaths, tool) {
1020
1023
  }
1021
1024
  }
1022
1025
 
1023
- var filesystem = /*#__PURE__*/Object.freeze({
1024
- __proto__: null,
1025
- calculateHash: calculateHash,
1026
- cleanupEmptyDirs: cleanupEmptyDirs,
1027
- ensureDir: ensureDir,
1028
- ensureNotExists: ensureNotExists,
1029
- formatFiles: formatFiles,
1030
- getSignature: getSignature,
1031
- inferKind: inferKind,
1032
- isEmptyDir: isEmptyDir,
1033
- isTextorGenerated: isTextorGenerated,
1034
- safeDelete: safeDelete,
1035
- safeDeleteDir: safeDeleteDir,
1036
- safeMove: safeMove,
1037
- scanDirectory: scanDirectory,
1038
- secureJoin: secureJoin,
1039
- updateSignature: updateSignature,
1040
- verifyFileIntegrity: verifyFileIntegrity,
1041
- writeFileWithSignature: writeFileWithSignature
1042
- });
1043
-
1044
1026
  function renderNamePattern(pattern, data = {}, label = 'pattern') {
1045
1027
  if (typeof pattern !== 'string') return null;
1046
1028
  const trimmed = pattern.trim();
@@ -1108,13 +1090,15 @@ function getTemplateOverride(templateName, extension, data = {}) {
1108
1090
  * - layoutImportPath: Path to import the layout
1109
1091
  * - featureImportPath: Path to import the feature component
1110
1092
  * - featureComponentName: Name of the feature component
1093
+ * - layoutProps: Optional properties for the layout
1111
1094
  */
1112
- function generateRouteTemplate(layoutName, layoutImportPath, featureImportPath, featureComponentName, extension = '.astro') {
1095
+ function generateRouteTemplate(layoutName, layoutImportPath, featureImportPath, featureComponentName, extension = '.astro', layoutProps = {}) {
1113
1096
  const override = getTemplateOverride('route', extension, {
1114
1097
  layoutName,
1115
1098
  layoutImportPath,
1116
1099
  featureImportPath,
1117
- featureComponentName
1100
+ featureComponentName,
1101
+ ...layoutProps
1118
1102
  });
1119
1103
  if (override) return override;
1120
1104
 
@@ -1127,12 +1111,26 @@ import ${featureComponentName} from '${featureImportPath}';
1127
1111
  `;
1128
1112
  }
1129
1113
 
1114
+ const propsStr = Object.entries(layoutProps)
1115
+ .map(([key, value]) => {
1116
+ if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) {
1117
+ return `${key}=${value}`;
1118
+ }
1119
+ if (typeof value === 'string') {
1120
+ return `${key}="${value}"`;
1121
+ }
1122
+ return `${key}={${JSON.stringify(value)}}`;
1123
+ })
1124
+ .join(' ');
1125
+
1126
+ const layoutOpening = propsStr ? `<${layoutName} ${propsStr}>` : `<${layoutName}>`;
1127
+
1130
1128
  return `---
1131
1129
  import ${layoutName} from '${layoutImportPath}';
1132
1130
  import ${featureComponentName} from '${featureImportPath}';
1133
1131
  ---
1134
1132
 
1135
- <${layoutName}>
1133
+ ${layoutOpening}
1136
1134
  <${featureComponentName} />
1137
1135
  </${layoutName}>
1138
1136
  `;
@@ -1343,6 +1341,11 @@ function generateIndexTemplate(componentName, componentExtension) {
1343
1341
  const override = getTemplateOverride('index', '.ts', { componentName, componentExtension });
1344
1342
  if (override) return override;
1345
1343
 
1344
+ if (componentExtension === '.astro') {
1345
+ return `export * from './types';
1346
+ `;
1347
+ }
1348
+
1346
1349
  return `export { default as ${componentName} } from './${componentName}${componentExtension}';
1347
1350
  export * from './types';
1348
1351
  `;
@@ -1798,6 +1801,7 @@ async function addSectionCommand(route, featurePath, options) {
1798
1801
  const {
1799
1802
  framework,
1800
1803
  layout,
1804
+ layoutProps: configLayoutProps,
1801
1805
  createSubComponentsDir: shouldCreateSubComponentsDir,
1802
1806
  createScriptsDir: shouldCreateScriptsDir,
1803
1807
  createApi: shouldCreateApi,
@@ -1969,7 +1973,7 @@ async function addSectionCommand(route, featurePath, options) {
1969
1973
  }
1970
1974
 
1971
1975
  // Update imports in the moved file
1972
- await updateImportsInFile$1(reorg.to, reorg.from, reorg.to);
1976
+ await updateImportsInFile$2(reorg.to, reorg.from, reorg.to);
1973
1977
 
1974
1978
  // Update hash in state after import updates
1975
1979
  if (state.files[newRelative]) {
@@ -1998,6 +2002,32 @@ async function addSectionCommand(route, featurePath, options) {
1998
2002
  if (shouldCreateScriptsDir) await ensureNotExists(scriptsIndexPath, options.force);
1999
2003
 
2000
2004
  let layoutImportPath = null;
2005
+ const cliProps = options.prop || {};
2006
+ const rawLayoutProps = { ...configLayoutProps, ...cliProps };
2007
+ const layoutProps = {};
2008
+
2009
+ // Resolve variables in layoutProps
2010
+ const substitutionData = enrichData({
2011
+ componentName: featureComponentName,
2012
+ layoutName: layout,
2013
+ featureComponentName: featureComponentName
2014
+ });
2015
+
2016
+ for (const [key, value] of Object.entries(rawLayoutProps)) {
2017
+ if (typeof value === 'string') {
2018
+ let resolvedValue = value;
2019
+ for (const [varKey, varValue] of Object.entries(substitutionData)) {
2020
+ const regex = new RegExp(`{{${varKey}}}`, 'g');
2021
+ resolvedValue = resolvedValue.replace(regex, varValue);
2022
+ const underscoreRegex = new RegExp(`__${varKey}__`, 'g');
2023
+ resolvedValue = resolvedValue.replace(underscoreRegex, varValue);
2024
+ }
2025
+ layoutProps[key] = resolvedValue;
2026
+ } else {
2027
+ layoutProps[key] = value;
2028
+ }
2029
+ }
2030
+
2001
2031
  if (routeFilePath && layout !== 'none') {
2002
2032
  if (config.importAliases.layouts) {
2003
2033
  layoutImportPath = `${config.importAliases.layouts}/${layout}.astro`;
@@ -2010,7 +2040,7 @@ async function addSectionCommand(route, featurePath, options) {
2010
2040
  let featureImportPath = null;
2011
2041
  if (routeFilePath) {
2012
2042
  if (config.importAliases.features) {
2013
- const entryPart = effectiveOptions.entry === 'index' ? '' : `/${featureComponentName}`;
2043
+ const entryPart = effectiveOptions.entry === 'index' ? '/index' : `/${featureComponentName}`;
2014
2044
  // In Astro, we can often omit the extension for .tsx files, but not for .astro files if using aliases sometimes.
2015
2045
  // However, to be safe, we use the configured extension.
2016
2046
  featureImportPath = `${config.importAliases.features}/${normalizedFeaturePath}${entryPart}${config.naming.featureExtension}`;
@@ -2043,7 +2073,8 @@ async function addSectionCommand(route, featurePath, options) {
2043
2073
  layoutImportPath,
2044
2074
  featureImportPath,
2045
2075
  featureComponentName,
2046
- routeExtension
2076
+ routeExtension,
2077
+ layoutProps
2047
2078
  );
2048
2079
  routeSignature = getSignature(config, 'astro');
2049
2080
  }
@@ -2328,7 +2359,7 @@ async function addSectionCommand(route, featurePath, options) {
2328
2359
  }
2329
2360
  }
2330
2361
 
2331
- async function updateImportsInFile$1(filePath, oldFilePath, newFilePath) {
2362
+ async function updateImportsInFile$2(filePath, oldFilePath, newFilePath) {
2332
2363
  if (!existsSync(filePath)) return;
2333
2364
 
2334
2365
  let content = await readFile(filePath, 'utf-8');
@@ -2538,7 +2569,7 @@ async function removeSectionCommand(route, featurePath, options) {
2538
2569
  state.files[newRelative] = { ...state.files[oldRelative] };
2539
2570
  delete state.files[oldRelative];
2540
2571
 
2541
- await updateImportsInFile(flatFilePath, loneFilePath, flatFilePath);
2572
+ await updateImportsInFile$1(flatFilePath, loneFilePath, flatFilePath);
2542
2573
 
2543
2574
  // Update hash in state after import updates
2544
2575
  const content = await readFile(flatFilePath, 'utf-8');
@@ -2568,7 +2599,7 @@ async function removeSectionCommand(route, featurePath, options) {
2568
2599
  }
2569
2600
  }
2570
2601
 
2571
- async function updateImportsInFile(filePath, oldFilePath, newFilePath) {
2602
+ async function updateImportsInFile$1(filePath, oldFilePath, newFilePath) {
2572
2603
  if (!existsSync(filePath)) return;
2573
2604
 
2574
2605
  let content = await readFile(filePath, 'utf-8');
@@ -2741,9 +2772,14 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
2741
2772
  const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
2742
2773
  const toFeatureComponentName = getFeatureComponentName(targetFeature);
2743
2774
 
2744
- // Update component name in JSX
2775
+ // First, update all relative imports in the file because it moved
2776
+ await updateImportsInFile(toRoutePath, fromRoutePath, toRoutePath);
2777
+
2778
+ let content = await readFile(toRoutePath, 'utf-8');
2779
+ let changed = false;
2780
+
2781
+ // Update component name in JSX tags
2745
2782
  if (fromFeatureComponentName !== toFeatureComponentName) {
2746
- let content = await readFile(toRoutePath, 'utf-8');
2747
2783
  content = content.replace(
2748
2784
  new RegExp(`<${fromFeatureComponentName}`, 'g'),
2749
2785
  `<${toFeatureComponentName}`
@@ -2752,44 +2788,54 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
2752
2788
  new RegExp(`</${fromFeatureComponentName}`, 'g'),
2753
2789
  `</${toFeatureComponentName}`
2754
2790
  );
2755
- await writeFile(toRoutePath, content, 'utf-8');
2791
+ changed = true;
2756
2792
  }
2757
2793
 
2758
2794
  if (config.importAliases.features) {
2759
- if (normalizedFromFeature !== targetFeature) {
2760
- const oldAliasPath = `${config.importAliases.features}/${normalizedFromFeature}`;
2761
- const newAliasPath = `${config.importAliases.features}/${targetFeature}`;
2762
- const ext = config.naming.featureExtension === '.astro' ? '.astro' : '';
2763
-
2764
- // Replace both the path and the component name if they are different
2765
- await updateSignature(toRoutePath,
2766
- `import ${fromFeatureComponentName} from '${oldAliasPath}/${fromFeatureComponentName}${ext}'`,
2767
- `import ${toFeatureComponentName} from '${newAliasPath}/${toFeatureComponentName}${ext}'`
2768
- );
2769
-
2770
- // Fallback for prefix only replacement
2771
- await updateSignature(toRoutePath, oldAliasPath, newAliasPath);
2772
- } else if (fromFeatureComponentName !== toFeatureComponentName) {
2773
- // Name changed but path didn't
2774
- const aliasPath = `${config.importAliases.features}/${targetFeature}`;
2775
- const ext = config.naming.featureExtension === '.astro' ? '.astro' : '';
2776
- await updateSignature(toRoutePath,
2777
- `import ${fromFeatureComponentName} from '${aliasPath}/${fromFeatureComponentName}${ext}'`,
2778
- `import ${toFeatureComponentName} from '${aliasPath}/${toFeatureComponentName}${ext}'`
2779
- );
2795
+ const oldAliasPath = `${config.importAliases.features}/${normalizedFromFeature}`;
2796
+ const newAliasPath = `${config.importAliases.features}/${targetFeature}`;
2797
+
2798
+ // Flexible regex to match import identifier and path with alias
2799
+ const importRegex = new RegExp(`(import\\s+)(${fromFeatureComponentName})(\\s+from\\s+['"])${oldAliasPath}(/[^'"]+)?(['"])`, 'g');
2800
+
2801
+ if (importRegex.test(content)) {
2802
+ content = content.replace(importRegex, (match, p1, p2, p3, subPath, p5) => {
2803
+ let newSubPath = subPath || '';
2804
+ if (subPath && subPath.includes(fromFeatureComponentName)) {
2805
+ newSubPath = subPath.replace(fromFeatureComponentName, toFeatureComponentName);
2806
+ }
2807
+ return `${p1}${toFeatureComponentName}${p3}${newAliasPath}${newSubPath}${p5}`;
2808
+ });
2809
+ changed = true;
2810
+ } else if (content.includes(oldAliasPath)) {
2811
+ // Fallback for path only replacement
2812
+ content = content.replace(new RegExp(oldAliasPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAliasPath);
2813
+ changed = true;
2780
2814
  }
2781
2815
  } else {
2782
- const oldRelativeDir = getRelativeImportPath(fromRoutePath, fromFeatureDirPath);
2816
+ const oldRelativeDir = getRelativeImportPath(toRoutePath, fromFeatureDirPath);
2783
2817
  const newRelativeDir = getRelativeImportPath(toRoutePath, toFeatureDirPath);
2784
- const ext = config.naming.featureExtension === '.astro' ? '.astro' : '';
2785
2818
 
2786
- const oldImportPath = `import ${fromFeatureComponentName} from '${oldRelativeDir}/${fromFeatureComponentName}${ext}'`;
2787
- const newImportPath = `import ${toFeatureComponentName} from '${newRelativeDir}/${toFeatureComponentName}${ext}'`;
2819
+ // Flexible regex for relative imports
2820
+ const relImportRegex = new RegExp(`(import\\s+)(${fromFeatureComponentName})(\\s+from\\s+['"])${oldRelativeDir}(/[^'"]+)?(['"])`, 'g');
2788
2821
 
2789
- if (oldImportPath !== newImportPath) {
2790
- await updateSignature(toRoutePath, oldImportPath, newImportPath);
2822
+ if (relImportRegex.test(content)) {
2823
+ content = content.replace(relImportRegex, (match, p1, p2, p3, subPath, p5) => {
2824
+ let newSubPath = subPath || '';
2825
+ if (subPath && subPath.includes(fromFeatureComponentName)) {
2826
+ newSubPath = subPath.replace(fromFeatureComponentName, toFeatureComponentName);
2827
+ }
2828
+ return `${p1}${toFeatureComponentName}${p3}${newRelativeDir}${newSubPath}${p5}`;
2829
+ });
2830
+ changed = true;
2791
2831
  }
2792
2832
  }
2833
+
2834
+ if (changed) {
2835
+ await writeFile(toRoutePath, content, 'utf-8');
2836
+ // Update hash in state after changes
2837
+ state.files[normalizedToRouteRelative].hash = calculateHash(content, config.hashing?.normalization);
2838
+ }
2793
2839
  }
2794
2840
 
2795
2841
  if (!isRouteOnly && normalizedFromFeature && normalizedToFeature && normalizedFromFeature !== normalizedToFeature) {
@@ -2860,7 +2906,6 @@ async function scanAndReplaceImports(config, state, fromInfo, toInfo, options) {
2860
2906
  const { toFeaturePath, toComponentName } = toInfo;
2861
2907
 
2862
2908
  const allFiles = new Set();
2863
- const { scanDirectory, calculateHash } = await Promise.resolve().then(function () { return filesystem; });
2864
2909
  await scanDirectory(process.cwd(), allFiles);
2865
2910
 
2866
2911
  const featuresRoot = resolvePath(config, 'features');
@@ -3001,7 +3046,6 @@ async function moveDirectory(fromPath, toPath, state, config, options = {}) {
3001
3046
  if (hasChanged) {
3002
3047
  await writeFile(toEntryPath, content, 'utf-8');
3003
3048
  // Re-calculate hash after content update
3004
- const { calculateHash } = await Promise.resolve().then(function () { return filesystem; });
3005
3049
  const updatedHash = calculateHash(content, config.hashing?.normalization);
3006
3050
 
3007
3051
  const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
@@ -3034,6 +3078,39 @@ async function moveDirectory(fromPath, toPath, state, config, options = {}) {
3034
3078
  }
3035
3079
  }
3036
3080
 
3081
+ async function updateImportsInFile(filePath, oldFilePath, newFilePath) {
3082
+ if (!existsSync(filePath)) return;
3083
+
3084
+ let content = await readFile(filePath, 'utf-8');
3085
+ const oldDir = path.dirname(oldFilePath);
3086
+ const newDir = path.dirname(newFilePath);
3087
+
3088
+ if (oldDir === newDir) return;
3089
+
3090
+ // Find all relative imports
3091
+ const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
3092
+ let match;
3093
+ const replacements = [];
3094
+
3095
+ while ((match = relativeImportRegex.exec(content)) !== null) {
3096
+ const relativePath = match[1];
3097
+ const absoluteTarget = path.resolve(oldDir, relativePath);
3098
+ const newRelativePath = getRelativeImportPath(newFilePath, absoluteTarget);
3099
+
3100
+ replacements.push({
3101
+ full: match[0],
3102
+ oldRel: relativePath,
3103
+ newRel: newRelativePath
3104
+ });
3105
+ }
3106
+
3107
+ for (const repl of replacements) {
3108
+ content = content.replace(repl.full, `from '${repl.newRel}'`);
3109
+ }
3110
+
3111
+ await writeFile(filePath, content, 'utf-8');
3112
+ }
3113
+
3037
3114
  async function createComponentCommand(componentName, options) {
3038
3115
  try {
3039
3116
  const config = await loadConfig();
@@ -3768,67 +3845,87 @@ async function validateStateCommand(options) {
3768
3845
  }
3769
3846
  }
3770
3847
 
3771
- async function statusCommand() {
3772
- try {
3773
- const config = await loadConfig();
3774
- const state = await loadState();
3775
-
3776
- const results = {
3777
- missing: [],
3778
- modified: [],
3779
- untracked: [], // Has signature, not in state
3780
- orphaned: [], // No signature, not in state
3781
- synced: 0
3782
- };
3848
+ /**
3849
+ * Computes the drift between the state and the actual files on disk.
3850
+ *
3851
+ * @param {import('./config.js').TextorConfig} config
3852
+ * @param {Object} state
3853
+ * @returns {Promise<{
3854
+ * missing: string[],
3855
+ * modified: string[],
3856
+ * untracked: string[],
3857
+ * orphaned: string[],
3858
+ * synced: number
3859
+ * }>}
3860
+ */
3861
+ async function getProjectStatus(config, state) {
3862
+ const results = {
3863
+ missing: [],
3864
+ modified: [],
3865
+ untracked: [], // Has signature, not in state
3866
+ orphaned: [], // No signature, not in state
3867
+ synced: 0
3868
+ };
3783
3869
 
3784
- const roots = [
3785
- resolvePath(config, 'pages'),
3786
- resolvePath(config, 'features'),
3787
- resolvePath(config, 'components')
3788
- ].map(p => path.resolve(p));
3870
+ const roots = [
3871
+ resolvePath(config, 'pages'),
3872
+ resolvePath(config, 'features'),
3873
+ resolvePath(config, 'components')
3874
+ ].map(p => path.resolve(p));
3789
3875
 
3790
- const diskFiles = new Set();
3791
- const configSignatures = Object.values(config.signatures || {});
3876
+ const diskFiles = new Set();
3877
+ const configSignatures = Object.values(config.signatures || {});
3792
3878
 
3793
- for (const root of roots) {
3794
- if (existsSync(root)) {
3795
- await scanDirectory(root, diskFiles);
3796
- }
3879
+ for (const root of roots) {
3880
+ if (existsSync(root)) {
3881
+ await scanDirectory(root, diskFiles);
3797
3882
  }
3883
+ }
3798
3884
 
3799
- // 1. Check state files against disk
3800
- for (const relativePath in state.files) {
3801
- const fullPath = path.join(process.cwd(), relativePath);
3802
-
3803
- if (!existsSync(fullPath)) {
3804
- results.missing.push(relativePath);
3805
- continue;
3806
- }
3885
+ // 1. Check state files against disk
3886
+ for (const relativePath in state.files) {
3887
+ const fullPath = path.join(process.cwd(), relativePath);
3888
+
3889
+ if (!existsSync(fullPath)) {
3890
+ results.missing.push(relativePath);
3891
+ continue;
3892
+ }
3807
3893
 
3808
- diskFiles.delete(relativePath);
3894
+ // It exists on disk, so it's not untracked/orphaned
3895
+ diskFiles.delete(relativePath);
3809
3896
 
3810
- const content = await readFile(fullPath, 'utf-8');
3811
- const currentHash = calculateHash(content, config.hashing?.normalization);
3812
- const fileData = state.files[relativePath];
3897
+ const content = await readFile(fullPath, 'utf-8');
3898
+ const currentHash = calculateHash(content, config.hashing?.normalization);
3899
+ const fileData = state.files[relativePath];
3813
3900
 
3814
- if (currentHash !== fileData.hash) {
3815
- results.modified.push(relativePath);
3816
- } else {
3817
- results.synced++;
3818
- }
3901
+ if (currentHash !== fileData.hash) {
3902
+ results.modified.push(relativePath);
3903
+ } else {
3904
+ results.synced++;
3819
3905
  }
3906
+ }
3820
3907
 
3821
- // 2. Check remaining disk files
3822
- for (const relativePath of diskFiles) {
3823
- const fullPath = path.join(process.cwd(), relativePath);
3824
- const isGenerated = await isTextorGenerated(fullPath, configSignatures);
3825
-
3826
- if (isGenerated) {
3827
- results.untracked.push(relativePath);
3828
- } else {
3829
- results.orphaned.push(relativePath);
3830
- }
3908
+ // 2. Check remaining disk files
3909
+ for (const relativePath of diskFiles) {
3910
+ const fullPath = path.join(process.cwd(), relativePath);
3911
+ const isGenerated = await isTextorGenerated(fullPath, configSignatures);
3912
+
3913
+ if (isGenerated) {
3914
+ results.untracked.push(relativePath);
3915
+ } else {
3916
+ results.orphaned.push(relativePath);
3831
3917
  }
3918
+ }
3919
+
3920
+ return results;
3921
+ }
3922
+
3923
+ async function statusCommand() {
3924
+ try {
3925
+ const config = await loadConfig();
3926
+ const state = await loadState();
3927
+
3928
+ const results = await getProjectStatus(config, state);
3832
3929
 
3833
3930
  // Reporting
3834
3931
  console.log('Textor Status Report:');
@@ -4253,6 +4350,71 @@ async function normalizeStateCommand(options) {
4253
4350
  }
4254
4351
  }
4255
4352
 
4353
+ /**
4354
+ * Removes missing references from Textor state.
4355
+ * @param {Object} options
4356
+ * @param {boolean} options.dryRun
4357
+ * @param {boolean} options.yes
4358
+ */
4359
+ async function pruneMissingCommand(options = {}) {
4360
+ try {
4361
+ const config = await loadConfig();
4362
+ const state = await loadState();
4363
+
4364
+ const results = await getProjectStatus(config, state);
4365
+
4366
+ if (results.missing.length === 0) {
4367
+ console.log('No missing references found.');
4368
+ return;
4369
+ }
4370
+
4371
+ console.log(`Found ${results.missing.length} missing references:`);
4372
+ results.missing.forEach(f => console.log(` - ${f}`));
4373
+
4374
+ if (options.dryRun) {
4375
+ console.log('\nDry run: no changes applied to state.');
4376
+ return;
4377
+ }
4378
+
4379
+ if (!options.yes && options.interactive !== false && process.stdin.isTTY && process.env.NODE_ENV !== 'test') {
4380
+ const rl = readline.createInterface({
4381
+ input: process.stdin,
4382
+ output: process.stdout
4383
+ });
4384
+
4385
+ const confirmed = await new Promise(resolve => {
4386
+ rl.question('\nDo you want to proceed with pruning? (y/N) ', (answer) => {
4387
+ rl.close();
4388
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
4389
+ });
4390
+ });
4391
+
4392
+ if (!confirmed) {
4393
+ console.log('Aborted.');
4394
+ return;
4395
+ }
4396
+ }
4397
+
4398
+ for (const relPath of results.missing) {
4399
+ delete state.files[relPath];
4400
+ }
4401
+
4402
+ // Reconstruct metadata
4403
+ state.components = reconstructComponents(state.files, config);
4404
+ state.sections = reconstructSections(state, config);
4405
+
4406
+ await saveState(state);
4407
+ console.log(`\nāœ“ Successfully removed ${results.missing.length} missing references from state.`);
4408
+
4409
+ } catch (error) {
4410
+ console.error('Error:', error.message);
4411
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
4412
+ process.exit(1);
4413
+ }
4414
+ throw error;
4415
+ }
4416
+ }
4417
+
4256
4418
  const program = new Command();
4257
4419
 
4258
4420
  program
@@ -4286,6 +4448,11 @@ program
4286
4448
  .option('--index', 'Create index.ts')
4287
4449
  .option('--no-sub-components-dir', 'Skip creating sub-components directory')
4288
4450
  .option('--no-scripts-dir', 'Skip creating scripts directory')
4451
+ .option('--prop <key=value>', 'Layout property', (val, memo) => {
4452
+ const [key, ...rest] = val.split('=');
4453
+ memo[key] = rest.join('=');
4454
+ return memo;
4455
+ }, {})
4289
4456
  .option('--dry-run', 'Show what would be created without creating')
4290
4457
  .option('--force', 'Overwrite existing files')
4291
4458
  .action(addSectionCommand);
@@ -4385,5 +4552,13 @@ program
4385
4552
  .option('--dry-run', 'Show the normalized state without writing to disk')
4386
4553
  .action(normalizeStateCommand);
4387
4554
 
4555
+ program
4556
+ .command('prune-missing')
4557
+ .description('Remove missing files from state (files that are in state but not on disk)')
4558
+ .option('--dry-run', 'Show what would be removed without applying')
4559
+ .option('--yes', 'Skip confirmation')
4560
+ .option('--no-interactive', 'Disable interactive prompts')
4561
+ .action(pruneMissingCommand);
4562
+
4388
4563
  program.parse();
4389
4564
  //# sourceMappingURL=textor.js.map