@oamm/textor 1.0.12 → 1.0.13

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.
@@ -543,6 +543,7 @@ function normalizeRoute(route) {
543
543
 
544
544
  function routeToFilePath(route, options = {}) {
545
545
  const { extension = '.astro', mode = 'flat', indexFile = 'index.astro' } = options;
546
+ if (!route) return null;
546
547
  const normalized = normalizeRoute(route);
547
548
 
548
549
  if (normalized === '/') {
@@ -832,6 +833,11 @@ async function safeMove(fromPath, toPath, options = {}) {
832
833
  throw new Error(`Source file not found: ${fromPath}`);
833
834
  }
834
835
 
836
+ if (path.resolve(fromPath) === path.resolve(toPath)) {
837
+ const content = await readFile(toPath, 'utf-8');
838
+ return calculateHash(content, normalization);
839
+ }
840
+
835
841
  if (existsSync(toPath) && !force) {
836
842
  throw new Error(
837
843
  `Destination already exists: ${toPath}\n` +
@@ -2751,6 +2757,222 @@ async function updateImportsInFile$1(filePath, oldFilePath, newFilePath) {
2751
2757
  await writeFile(filePath, content, 'utf-8');
2752
2758
  }
2753
2759
 
2760
+ /**
2761
+ * Updates relative imports in a file after it has been moved.
2762
+ */
2763
+ async function updateImportsInFile(filePath, oldFilePath, newFilePath) {
2764
+ if (!existsSync(filePath)) return;
2765
+
2766
+ let content = await readFile(filePath, 'utf-8');
2767
+ const oldDir = path.dirname(oldFilePath);
2768
+ const newDir = path.dirname(newFilePath);
2769
+
2770
+ if (oldDir === newDir) return;
2771
+
2772
+ // Find all relative imports
2773
+ const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
2774
+ let match;
2775
+ const replacements = [];
2776
+
2777
+ while ((match = relativeImportRegex.exec(content)) !== null) {
2778
+ const relativePath = match[1];
2779
+ const absoluteTarget = path.resolve(oldDir, relativePath);
2780
+ const newRelativePath = getRelativeImportPath(newFilePath, absoluteTarget);
2781
+
2782
+ replacements.push({
2783
+ full: match[0],
2784
+ oldRel: relativePath,
2785
+ newRel: newRelativePath
2786
+ });
2787
+ }
2788
+
2789
+ for (const repl of replacements) {
2790
+ content = content.replace(repl.full, `from '${repl.newRel}'`);
2791
+ }
2792
+
2793
+ await writeFile(filePath, content, 'utf-8');
2794
+ }
2795
+
2796
+ /**
2797
+ * Moves a directory and its contents, renaming files and updating internal content/imports.
2798
+ */
2799
+ async function moveDirectory(fromPath, toPath, state, config, options = {}) {
2800
+ const { fromName, toName, owner = null, signatures = [] } = options;
2801
+
2802
+ if (!existsSync(fromPath)) {
2803
+ throw new Error(`Source directory not found: ${fromPath}`);
2804
+ }
2805
+
2806
+ if (existsSync(toPath) && !options.force) {
2807
+ throw new Error(
2808
+ `Destination already exists: ${toPath}\n` +
2809
+ `Use --force to overwrite.`
2810
+ );
2811
+ }
2812
+
2813
+ await ensureDir(toPath);
2814
+
2815
+ const entries = await readdir(fromPath);
2816
+
2817
+ for (const entry of entries) {
2818
+ let targetEntry = entry;
2819
+
2820
+ // Rename files if they match the component name
2821
+ if (fromName && toName && fromName !== toName) {
2822
+ if (entry.includes(fromName)) {
2823
+ targetEntry = entry.replace(fromName, toName);
2824
+ }
2825
+ }
2826
+
2827
+ const fromEntryPath = path.join(fromPath, entry);
2828
+ const toEntryPath = path.join(toPath, targetEntry);
2829
+
2830
+ const stats = await stat(fromEntryPath);
2831
+
2832
+ if (stats.isDirectory()) {
2833
+ await moveDirectory(fromEntryPath, toEntryPath, state, config, options);
2834
+ } else {
2835
+ const normalizedFromRelative = path.relative(process.cwd(), fromEntryPath).replace(/\\/g, '/');
2836
+ const fileState = state.files[normalizedFromRelative];
2837
+
2838
+ const newHash = await safeMove(fromEntryPath, toEntryPath, {
2839
+ force: options.force,
2840
+ expectedHash: fileState?.hash,
2841
+ acceptChanges: options.acceptChanges,
2842
+ normalization: config.hashing?.normalization,
2843
+ owner,
2844
+ actualOwner: fileState?.owner,
2845
+ signatures
2846
+ });
2847
+
2848
+ // Update internal content (signatures, component names) if renaming
2849
+ if (fromName && toName && fromName !== toName) {
2850
+ let content = await readFile(toEntryPath, 'utf-8');
2851
+ let hasChanged = false;
2852
+
2853
+ // Simple replacement of component names
2854
+ if (content.includes(fromName)) {
2855
+ content = content.replace(new RegExp(fromName, 'g'), toName);
2856
+ hasChanged = true;
2857
+ }
2858
+
2859
+ // Also handle lowercase class names if any
2860
+ const fromLower = fromName.toLowerCase();
2861
+ const toLower = toName.toLowerCase();
2862
+ if (content.includes(fromLower)) {
2863
+ content = content.replace(new RegExp(fromLower, 'g'), toLower);
2864
+ hasChanged = true;
2865
+ }
2866
+
2867
+ if (hasChanged) {
2868
+ await writeFile(toEntryPath, content, 'utf-8');
2869
+ // Re-calculate hash after content update
2870
+ const updatedHash = calculateHash(content, config.hashing?.normalization);
2871
+
2872
+ const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
2873
+ if (fileState) {
2874
+ state.files[normalizedToRelative] = { ...fileState, hash: updatedHash };
2875
+ delete state.files[normalizedFromRelative];
2876
+ }
2877
+ } else {
2878
+ // Update state for each file moved normally
2879
+ const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
2880
+ if (fileState) {
2881
+ state.files[normalizedToRelative] = { ...fileState, hash: newHash };
2882
+ delete state.files[normalizedFromRelative];
2883
+ }
2884
+ }
2885
+ } else {
2886
+ // Update state for each file moved normally
2887
+ const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
2888
+ if (fileState) {
2889
+ state.files[normalizedToRelative] = { ...fileState, hash: newHash };
2890
+ delete state.files[normalizedFromRelative];
2891
+ }
2892
+ }
2893
+ }
2894
+ }
2895
+
2896
+ const remainingFiles = await readdir(fromPath);
2897
+ if (remainingFiles.length === 0) {
2898
+ await rmdir(fromPath);
2899
+ }
2900
+ }
2901
+
2902
+ /**
2903
+ * Scans the project and replaces imports of a moved/renamed item.
2904
+ */
2905
+ async function scanAndReplaceImports(config, state, fromInfo, toInfo, options) {
2906
+ const { fromPath: fromItemPath, fromName, type } = fromInfo;
2907
+ const { toPath: toItemPath, toName } = toInfo;
2908
+
2909
+ const allFiles = new Set();
2910
+ await scanDirectory(process.cwd(), allFiles);
2911
+
2912
+ const rootPath = resolvePath(config, type === 'component' ? 'components' : 'features');
2913
+
2914
+ for (const relPath of allFiles) {
2915
+ const fullPath = path.resolve(process.cwd(), relPath);
2916
+
2917
+ // Skip the moved directory itself
2918
+ const toFullPath = path.resolve(toItemPath);
2919
+ if (fullPath.startsWith(toFullPath)) continue;
2920
+
2921
+ let content = await readFile(fullPath, 'utf-8');
2922
+ let changed = false;
2923
+
2924
+ const aliasBase = config.importAliases[type === 'component' ? 'components' : 'features'];
2925
+ const ext = type === 'component' ? '' : (config.naming.featureExtension === '.astro' ? '.astro' : '');
2926
+
2927
+ if (aliasBase) {
2928
+ const oldAlias = `${aliasBase}/${fromItemPath}`;
2929
+ const newAlias = `${aliasBase}/${toItemPath}`;
2930
+
2931
+ const oldFullImport = `from '${oldAlias}/${fromName}${ext}'`;
2932
+ const newFullImport = `from '${newAlias}/${toName}${ext}'`;
2933
+
2934
+ if (content.includes(oldFullImport)) {
2935
+ content = content.replace(new RegExp(oldFullImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newFullImport);
2936
+ changed = true;
2937
+ } else if (content.includes(oldAlias)) {
2938
+ content = content.replace(new RegExp(oldAlias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAlias);
2939
+ changed = true;
2940
+ }
2941
+ } else {
2942
+ const oldDir = path.resolve(rootPath, fromItemPath);
2943
+ const newDir = path.resolve(rootPath, toItemPath);
2944
+
2945
+ const oldRelPath = getRelativeImportPath(fullPath, oldDir);
2946
+ const newRelPath = getRelativeImportPath(fullPath, newDir);
2947
+
2948
+ const oldImport = `'${oldRelPath}/${fromName}${ext}'`;
2949
+ const newImport = `'${newRelPath}/${toName}${ext}'`;
2950
+
2951
+ if (content.includes(oldImport)) {
2952
+ content = content.replace(new RegExp(oldImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newImport);
2953
+ changed = true;
2954
+ }
2955
+ }
2956
+
2957
+ if (fromName !== toName && changed) {
2958
+ content = content.replace(new RegExp(`\\b${fromName}\\b`, 'g'), toName);
2959
+ }
2960
+
2961
+ if (changed) {
2962
+ if (options.dryRun) {
2963
+ console.log(` [Scan] Would update imports in ${relPath}`);
2964
+ } else {
2965
+ await writeFile(fullPath, content, 'utf-8');
2966
+ console.log(` [Scan] Updated imports in ${relPath}`);
2967
+
2968
+ if (state.files[relPath]) {
2969
+ state.files[relPath].hash = calculateHash(content, config.hashing?.normalization);
2970
+ }
2971
+ }
2972
+ }
2973
+ }
2974
+ }
2975
+
2754
2976
  /**
2755
2977
  * Move a section (route + feature).
2756
2978
  *
@@ -2779,45 +3001,59 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
2779
3001
  let actualToRoute = toRoute;
2780
3002
  let actualToFeature = toFeature;
2781
3003
 
2782
- // Shift arguments if using state
3004
+ // Shift arguments if using state or if called with fewer arguments
2783
3005
  if (!toRoute && fromRoute && fromFeature) {
2784
3006
  // textor move-section /old-route /new-route
2785
- const section = findSection(state, fromRoute);
3007
+ actualFromRoute = fromRoute;
3008
+ actualToRoute = fromFeature;
3009
+ actualFromFeature = undefined;
3010
+ actualToFeature = undefined;
3011
+ }
3012
+
3013
+ // Lookup missing info from state
3014
+ if (actualFromRoute && !actualFromFeature) {
3015
+ const section = findSection(state, actualFromRoute);
2786
3016
  if (section) {
2787
- actualFromRoute = section.route;
2788
3017
  actualFromFeature = section.featurePath;
2789
- actualToRoute = fromFeature; // the second argument was actually the new route
2790
- actualToFeature = toRoute; // which is null
3018
+ }
3019
+ } else if (!actualFromRoute && actualFromFeature) {
3020
+ const section = findSection(state, actualFromFeature);
3021
+ if (section) {
3022
+ actualFromRoute = section.route;
3023
+ }
3024
+ }
3025
+
3026
+ // If toFeature is not provided, try to derive it from the new route if route moved
3027
+ if (!actualToFeature && actualToRoute && actualFromRoute && actualFromRoute !== actualToRoute && actualFromFeature) {
3028
+ const oldRouteParts = actualFromRoute.split('/').filter(Boolean);
3029
+ const newRouteParts = actualToRoute.split('/').filter(Boolean);
3030
+ const oldFeatureParts = actualFromFeature.split('/').filter(Boolean);
3031
+
3032
+ let match = true;
3033
+ for (let i = 0; i < oldRouteParts.length; i++) {
3034
+ const routePart = oldRouteParts[i].toLowerCase();
3035
+ const featurePart = oldFeatureParts[i] ? oldFeatureParts[i].toLowerCase() : null;
2791
3036
 
2792
- // If toFeature is not provided, try to derive it from the new route
2793
- if (!actualToFeature && actualToRoute) {
2794
- const oldRouteParts = actualFromRoute.split('/').filter(Boolean);
2795
- const newRouteParts = actualToRoute.split('/').filter(Boolean);
2796
- const oldFeatureParts = actualFromFeature.split('/').filter(Boolean);
2797
-
2798
- // If the feature path starts with the old route parts, replace them
2799
- // We compare case-insensitively or via PascalCase to be more helpful
2800
- let match = true;
2801
- for (let i = 0; i < oldRouteParts.length; i++) {
2802
- const routePart = oldRouteParts[i].toLowerCase();
2803
- const featurePart = oldFeatureParts[i] ? oldFeatureParts[i].toLowerCase() : null;
2804
-
2805
- if (featurePart !== routePart) {
2806
- match = false;
2807
- break;
2808
- }
2809
- }
2810
-
2811
- if (match && oldRouteParts.length > 0) {
2812
- actualToFeature = [...newRouteParts, ...oldFeatureParts.slice(oldRouteParts.length)].join('/');
2813
- } else {
2814
- // Otherwise just keep it the same
2815
- actualToFeature = actualFromFeature;
2816
- }
3037
+ if (featurePart !== routePart) {
3038
+ match = false;
3039
+ break;
2817
3040
  }
2818
3041
  }
3042
+
3043
+ if (match && oldRouteParts.length > 0) {
3044
+ actualToFeature = [...newRouteParts, ...oldFeatureParts.slice(oldRouteParts.length)].join('/');
3045
+ } else {
3046
+ actualToFeature = actualFromFeature;
3047
+ }
3048
+ } else if (!actualToFeature) {
3049
+ actualToFeature = actualFromFeature;
3050
+ }
3051
+
3052
+ if (!actualToRoute) {
3053
+ actualToRoute = actualFromRoute;
2819
3054
  }
2820
3055
 
3056
+
2821
3057
  const isRouteOnly = options.keepFeature || (!actualToFeature && actualToRoute && !actualFromFeature);
2822
3058
 
2823
3059
  if (isRouteOnly && !actualToRoute) {
@@ -2847,14 +3083,16 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
2847
3083
  indexFile: config.routing.indexFile
2848
3084
  });
2849
3085
 
2850
- const fromRoutePath = secureJoin(pagesRoot, fromRouteFile);
2851
- const toRoutePath = secureJoin(pagesRoot, toRouteFile);
3086
+ const fromRoutePath = fromRouteFile ? secureJoin(pagesRoot, fromRouteFile) : null;
3087
+ const toRoutePath = toRouteFile ? secureJoin(pagesRoot, toRouteFile) : null;
2852
3088
 
2853
3089
  const movedFiles = [];
2854
3090
 
2855
3091
  if (options.dryRun) {
2856
3092
  console.log('Dry run - would move:');
2857
- console.log(` Route: ${fromRoutePath} -> ${toRoutePath}`);
3093
+ if (fromRoutePath && toRoutePath && fromRoutePath !== toRoutePath) {
3094
+ console.log(` Route: ${fromRoutePath} -> ${toRoutePath}`);
3095
+ }
2858
3096
 
2859
3097
  if (!isRouteOnly && normalizedFromFeature && normalizedToFeature) {
2860
3098
  const fromFeaturePath = secureJoin(featuresRoot, normalizedFromFeature);
@@ -2865,97 +3103,107 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
2865
3103
  return;
2866
3104
  }
2867
3105
 
2868
- const normalizedFromRouteRelative = path.relative(process.cwd(), fromRoutePath).replace(/\\/g, '/');
2869
- const routeFileState = state.files[normalizedFromRouteRelative];
3106
+ let normalizedToRouteRelative = null;
3107
+ if (fromRoutePath && toRoutePath) {
3108
+ const normalizedFromRouteRelative = path.relative(process.cwd(), fromRoutePath).replace(/\\/g, '/');
3109
+ const routeFileState = state.files[normalizedFromRouteRelative];
2870
3110
 
2871
- const newRouteHash = await safeMove(fromRoutePath, toRoutePath, {
2872
- force: options.force,
2873
- expectedHash: routeFileState?.hash,
2874
- acceptChanges: options.acceptChanges,
2875
- owner: normalizedFromRoute,
2876
- actualOwner: routeFileState?.owner,
2877
- signatures: configSignatures
2878
- });
2879
- movedFiles.push({ from: fromRoutePath, to: toRoutePath });
2880
-
2881
- // Update state for moved route file
2882
- const normalizedToRouteRelative = path.relative(process.cwd(), toRoutePath).replace(/\\/g, '/');
2883
- if (routeFileState) {
2884
- state.files[normalizedToRouteRelative] = { ...routeFileState, hash: newRouteHash };
2885
- delete state.files[normalizedFromRouteRelative];
2886
- }
2887
-
2888
- // Update imports in the moved route file
2889
- const targetFeature = normalizedToFeature || normalizedFromFeature;
2890
- if (targetFeature) {
2891
- const fromFeatureDirPath = secureJoin(featuresRoot, normalizedFromFeature);
2892
- const toFeatureDirPath = secureJoin(featuresRoot, targetFeature);
2893
- const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
2894
- const toFeatureComponentName = getFeatureComponentName(targetFeature);
2895
-
2896
- // First, update all relative imports in the file because it moved
2897
- await updateImportsInFile(toRoutePath, fromRoutePath, toRoutePath);
2898
-
2899
- let content = await readFile(toRoutePath, 'utf-8');
2900
- let changed = false;
2901
-
2902
- // Update component name in JSX tags
2903
- if (fromFeatureComponentName !== toFeatureComponentName) {
2904
- content = content.replace(
2905
- new RegExp(`<${fromFeatureComponentName}`, 'g'),
2906
- `<${toFeatureComponentName}`
2907
- );
2908
- content = content.replace(
2909
- new RegExp(`</${fromFeatureComponentName}`, 'g'),
2910
- `</${toFeatureComponentName}`
2911
- );
2912
- changed = true;
3111
+ const newRouteHash = await safeMove(fromRoutePath, toRoutePath, {
3112
+ force: options.force,
3113
+ expectedHash: routeFileState?.hash,
3114
+ acceptChanges: options.acceptChanges,
3115
+ owner: normalizedFromRoute,
3116
+ actualOwner: routeFileState?.owner,
3117
+ signatures: configSignatures
3118
+ });
3119
+
3120
+ if (fromRoutePath !== toRoutePath) {
3121
+ movedFiles.push({ from: fromRoutePath, to: toRoutePath });
2913
3122
  }
2914
-
2915
- if (config.importAliases.features) {
2916
- const oldAliasPath = `${config.importAliases.features}/${normalizedFromFeature}`;
2917
- const newAliasPath = `${config.importAliases.features}/${targetFeature}`;
2918
-
2919
- // Flexible regex to match import identifier and path with alias
2920
- const importRegex = new RegExp(`(import\\s+)(${fromFeatureComponentName})(\\s+from\\s+['"])${oldAliasPath}(/[^'"]+)?(['"])`, 'g');
2921
-
2922
- if (importRegex.test(content)) {
2923
- content = content.replace(importRegex, (match, p1, p2, p3, subPath, p5) => {
2924
- let newSubPath = subPath || '';
2925
- if (subPath && subPath.includes(fromFeatureComponentName)) {
2926
- newSubPath = subPath.replace(fromFeatureComponentName, toFeatureComponentName);
2927
- }
2928
- return `${p1}${toFeatureComponentName}${p3}${newAliasPath}${newSubPath}${p5}`;
2929
- });
2930
- changed = true;
2931
- } else if (content.includes(oldAliasPath)) {
2932
- // Fallback for path only replacement
2933
- content = content.replace(new RegExp(oldAliasPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAliasPath);
2934
- changed = true;
3123
+
3124
+ // Update state for moved route file
3125
+ normalizedToRouteRelative = path.relative(process.cwd(), toRoutePath).replace(/\\/g, '/');
3126
+ if (routeFileState) {
3127
+ state.files[normalizedToRouteRelative] = { ...routeFileState, hash: newRouteHash };
3128
+ if (fromRoutePath !== toRoutePath) {
3129
+ delete state.files[normalizedFromRouteRelative];
2935
3130
  }
2936
- } else {
2937
- const oldRelativeDir = getRelativeImportPath(toRoutePath, fromFeatureDirPath);
2938
- const newRelativeDir = getRelativeImportPath(toRoutePath, toFeatureDirPath);
2939
-
2940
- // Flexible regex for relative imports
2941
- const relImportRegex = new RegExp(`(import\\s+)(${fromFeatureComponentName})(\\s+from\\s+['"])${oldRelativeDir}(/[^'"]+)?(['"])`, 'g');
2942
-
2943
- if (relImportRegex.test(content)) {
2944
- content = content.replace(relImportRegex, (match, p1, p2, p3, subPath, p5) => {
2945
- let newSubPath = subPath || '';
2946
- if (subPath && subPath.includes(fromFeatureComponentName)) {
2947
- newSubPath = subPath.replace(fromFeatureComponentName, toFeatureComponentName);
2948
- }
2949
- return `${p1}${toFeatureComponentName}${p3}${newRelativeDir}${newSubPath}${p5}`;
2950
- });
3131
+ }
3132
+
3133
+ // Update imports in the route file (even if it didn't move, as feature might have)
3134
+ const targetFeature = normalizedToFeature || normalizedFromFeature;
3135
+ if (targetFeature && existsSync(toRoutePath)) {
3136
+ const fromFeatureDirPath = secureJoin(featuresRoot, normalizedFromFeature);
3137
+ const toFeatureDirPath = secureJoin(featuresRoot, targetFeature);
3138
+ const fromFeatureComponentName = getFeatureComponentName(normalizedFromFeature);
3139
+ const toFeatureComponentName = getFeatureComponentName(targetFeature);
3140
+
3141
+ // First, update all relative imports in the file because it moved (or stayed)
3142
+ await updateImportsInFile(toRoutePath, fromRoutePath, toRoutePath);
3143
+
3144
+ let content = await readFile(toRoutePath, 'utf-8');
3145
+ let changed = false;
3146
+
3147
+ // Update component name in JSX tags
3148
+ if (fromFeatureComponentName !== toFeatureComponentName) {
3149
+ content = content.replace(
3150
+ new RegExp(`<${fromFeatureComponentName}`, 'g'),
3151
+ `<${toFeatureComponentName}`
3152
+ );
3153
+ content = content.replace(
3154
+ new RegExp(`</${fromFeatureComponentName}`, 'g'),
3155
+ `</${toFeatureComponentName}`
3156
+ );
2951
3157
  changed = true;
2952
3158
  }
2953
- }
2954
3159
 
2955
- if (changed) {
2956
- await writeFile(toRoutePath, content, 'utf-8');
2957
- // Update hash in state after changes
2958
- state.files[normalizedToRouteRelative].hash = calculateHash(content, config.hashing?.normalization);
3160
+ if (config.importAliases.features) {
3161
+ const oldAliasPath = `${config.importAliases.features}/${normalizedFromFeature}`;
3162
+ const newAliasPath = `${config.importAliases.features}/${targetFeature}`;
3163
+
3164
+ // Flexible regex to match import identifier and path with alias
3165
+ const importRegex = new RegExp(`(import\\s+)(${fromFeatureComponentName})(\\s+from\\s+['"])${oldAliasPath}(/[^'"]+)?(['"])`, 'g');
3166
+
3167
+ if (importRegex.test(content)) {
3168
+ content = content.replace(importRegex, (match, p1, p2, p3, subPath, p5) => {
3169
+ let newSubPath = subPath || '';
3170
+ if (subPath && subPath.includes(fromFeatureComponentName)) {
3171
+ newSubPath = subPath.replace(fromFeatureComponentName, toFeatureComponentName);
3172
+ }
3173
+ return `${p1}${toFeatureComponentName}${p3}${newAliasPath}${newSubPath}${p5}`;
3174
+ });
3175
+ changed = true;
3176
+ } else if (content.includes(oldAliasPath)) {
3177
+ // Fallback for path only replacement
3178
+ content = content.replace(new RegExp(oldAliasPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAliasPath);
3179
+ changed = true;
3180
+ }
3181
+ } else {
3182
+ const oldRelativeDir = getRelativeImportPath(toRoutePath, fromFeatureDirPath);
3183
+ const newRelativeDir = getRelativeImportPath(toRoutePath, toFeatureDirPath);
3184
+
3185
+ // Flexible regex for relative imports
3186
+ const relImportRegex = new RegExp(`(import\\s+)(${fromFeatureComponentName})(\\s+from\\s+['"])${oldRelativeDir}(/[^'"]+)?(['"])`, 'g');
3187
+
3188
+ if (relImportRegex.test(content)) {
3189
+ content = content.replace(relImportRegex, (match, p1, p2, p3, subPath, p5) => {
3190
+ let newSubPath = subPath || '';
3191
+ if (subPath && subPath.includes(fromFeatureComponentName)) {
3192
+ newSubPath = subPath.replace(fromFeatureComponentName, toFeatureComponentName);
3193
+ }
3194
+ return `${p1}${toFeatureComponentName}${p3}${newRelativeDir}${newSubPath}${p5}`;
3195
+ });
3196
+ changed = true;
3197
+ }
3198
+ }
3199
+
3200
+ if (changed) {
3201
+ await writeFile(toRoutePath, content, 'utf-8');
3202
+ // Update hash in state after changes
3203
+ if (state.files[normalizedToRouteRelative]) {
3204
+ state.files[normalizedToRouteRelative].hash = calculateHash(content, config.hashing?.normalization);
3205
+ }
3206
+ }
2959
3207
  }
2960
3208
  }
2961
3209
 
@@ -2982,15 +3230,18 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
2982
3230
 
2983
3231
  if (options.scan && (normalizedFromFeature || normalizedToFeature)) {
2984
3232
  await scanAndReplaceImports(config, state, {
2985
- fromFeaturePath: normalizedFromFeature,
2986
- fromComponentName: getFeatureComponentName(normalizedFromFeature)
3233
+ fromPath: normalizedFromFeature,
3234
+ fromName: getFeatureComponentName(normalizedFromFeature),
3235
+ type: 'feature'
2987
3236
  }, {
2988
- toFeaturePath: normalizedToFeature || normalizedFromFeature,
2989
- toComponentName: getFeatureComponentName(normalizedToFeature || normalizedFromFeature)
3237
+ toPath: normalizedToFeature || normalizedFromFeature,
3238
+ toName: getFeatureComponentName(normalizedToFeature || normalizedFromFeature)
2990
3239
  }, options);
2991
3240
  }
2992
3241
 
2993
- await cleanupEmptyDirs(path.dirname(fromRoutePath), pagesRoot);
3242
+ if (fromRoutePath && toRoutePath && fromRoutePath !== toRoutePath) {
3243
+ await cleanupEmptyDirs(path.dirname(fromRoutePath), pagesRoot);
3244
+ }
2994
3245
 
2995
3246
  console.log('✓ Moved:');
2996
3247
  movedFiles.forEach(item => {
@@ -3001,6 +3252,15 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
3001
3252
  if (movedFiles.length > 0) {
3002
3253
  const existingSection = fromSection;
3003
3254
 
3255
+ // Update ownership in state if route moved
3256
+ if (normalizedFromRoute && normalizedToRoute && normalizedFromRoute !== normalizedToRoute) {
3257
+ for (const f in state.files) {
3258
+ if (state.files[f].owner === normalizedFromRoute) {
3259
+ state.files[f].owner = normalizedToRoute;
3260
+ }
3261
+ }
3262
+ }
3263
+
3004
3264
  // Update section data in state
3005
3265
  state.sections = state.sections.filter(s => s.route !== normalizedFromRoute);
3006
3266
  state.sections.push({
@@ -3023,216 +3283,6 @@ async function moveSectionCommand(fromRoute, fromFeature, toRoute, toFeature, op
3023
3283
  }
3024
3284
  }
3025
3285
 
3026
- async function scanAndReplaceImports(config, state, fromInfo, toInfo, options) {
3027
- const { fromFeaturePath, fromComponentName } = fromInfo;
3028
- const { toFeaturePath, toComponentName } = toInfo;
3029
-
3030
- const allFiles = new Set();
3031
- await scanDirectory(process.cwd(), allFiles);
3032
-
3033
- const featuresRoot = resolvePath(config, 'features');
3034
-
3035
- for (const relPath of allFiles) {
3036
- const fullPath = path.join(process.cwd(), relPath);
3037
-
3038
- // Skip the moved directory itself as it was already handled
3039
- if (fullPath.startsWith(path.resolve(toFeaturePath))) continue;
3040
-
3041
- let content = await readFile(fullPath, 'utf-8');
3042
- let changed = false;
3043
-
3044
- const ext = config.naming.featureExtension === '.astro' ? '.astro' : '';
3045
-
3046
- // Handle Aliases
3047
- if (config.importAliases.features) {
3048
- const oldAlias = `${config.importAliases.features}/${fromFeaturePath}`;
3049
- const newAlias = `${config.importAliases.features}/${toFeaturePath}`;
3050
-
3051
- // Update component name and path if both changed
3052
- const oldFullImport = `from '${oldAlias}/${fromComponentName}${ext}'`;
3053
- const newFullImport = `from '${newAlias}/${toComponentName}${ext}'`;
3054
-
3055
- if (content.includes(oldFullImport)) {
3056
- content = content.replace(new RegExp(oldFullImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newFullImport);
3057
- changed = true;
3058
- } else if (content.includes(oldAlias)) {
3059
- content = content.replace(new RegExp(oldAlias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newAlias);
3060
- changed = true;
3061
- }
3062
- } else {
3063
- // Handle Relative Imports (more complex)
3064
- // This is best-effort: we look for imports that resolve to the old feature path
3065
- const fromFeatureDir = secureJoin(featuresRoot, fromFeaturePath);
3066
- const toFeatureDir = secureJoin(featuresRoot, toFeaturePath);
3067
-
3068
- const oldRelPath = getRelativeImportPath(fullPath, fromFeatureDir);
3069
- const newRelPath = getRelativeImportPath(fullPath, toFeatureDir);
3070
-
3071
- const oldImport = `'${oldRelPath}/${fromComponentName}${ext}'`;
3072
- const newImport = `'${newRelPath}/${toComponentName}${ext}'`;
3073
-
3074
- if (content.includes(oldImport)) {
3075
- content = content.replace(new RegExp(oldImport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newImport);
3076
- changed = true;
3077
- }
3078
- }
3079
-
3080
- // Update component name in JSX and imports if it changed
3081
- if (fromComponentName !== toComponentName && changed) {
3082
- content = content.replace(new RegExp(`\\b${fromComponentName}\\b`, 'g'), toComponentName);
3083
- }
3084
-
3085
- if (changed) {
3086
- if (options.dryRun) {
3087
- console.log(` [Scan] Would update imports in ${relPath}`);
3088
- } else {
3089
- await writeFile(fullPath, content, 'utf-8');
3090
- console.log(` [Scan] Updated imports in ${relPath}`);
3091
-
3092
- // Update state hash if this file is managed
3093
- if (state.files[relPath]) {
3094
- state.files[relPath].hash = calculateHash(content, config.hashing?.normalization);
3095
- }
3096
- }
3097
- }
3098
- }
3099
- }
3100
-
3101
- async function moveDirectory(fromPath, toPath, state, config, options = {}) {
3102
- const { fromName, toName, owner = null } = options;
3103
-
3104
- if (!existsSync(fromPath)) {
3105
- throw new Error(`Source directory not found: ${fromPath}`);
3106
- }
3107
-
3108
- if (existsSync(toPath) && !options.force) {
3109
- throw new Error(
3110
- `Destination already exists: ${toPath}\n` +
3111
- `Use --force to overwrite.`
3112
- );
3113
- }
3114
-
3115
- await ensureDir(toPath);
3116
-
3117
- const entries = await readdir(fromPath);
3118
-
3119
- for (const entry of entries) {
3120
- let targetEntry = entry;
3121
-
3122
- // Rename files if they match the component name
3123
- if (fromName && toName && fromName !== toName) {
3124
- if (entry.includes(fromName)) {
3125
- targetEntry = entry.replace(fromName, toName);
3126
- }
3127
- }
3128
-
3129
- const fromEntryPath = path.join(fromPath, entry);
3130
- const toEntryPath = path.join(toPath, targetEntry);
3131
-
3132
- const stats = await stat(fromEntryPath);
3133
-
3134
- if (stats.isDirectory()) {
3135
- await moveDirectory(fromEntryPath, toEntryPath, state, config, options);
3136
- } else {
3137
- const normalizedFromRelative = path.relative(process.cwd(), fromEntryPath).replace(/\\/g, '/');
3138
- const fileState = state.files[normalizedFromRelative];
3139
-
3140
- const newHash = await safeMove(fromEntryPath, toEntryPath, {
3141
- force: options.force,
3142
- expectedHash: fileState?.hash,
3143
- acceptChanges: options.acceptChanges,
3144
- normalization: config.hashing?.normalization,
3145
- owner,
3146
- actualOwner: fileState?.owner
3147
- });
3148
-
3149
- // Update internal content (signatures, component names) if renaming
3150
- if (fromName && toName && fromName !== toName) {
3151
- let content = await readFile(toEntryPath, 'utf-8');
3152
- let hasChanged = false;
3153
-
3154
- // Simple replacement of component names
3155
- if (content.includes(fromName)) {
3156
- content = content.replace(new RegExp(fromName, 'g'), toName);
3157
- hasChanged = true;
3158
- }
3159
-
3160
- // Also handle lowercase class names if any
3161
- const fromLower = fromName.toLowerCase();
3162
- const toLower = toName.toLowerCase();
3163
- if (content.includes(fromLower)) {
3164
- content = content.replace(new RegExp(fromLower, 'g'), toLower);
3165
- hasChanged = true;
3166
- }
3167
-
3168
- if (hasChanged) {
3169
- await writeFile(toEntryPath, content, 'utf-8');
3170
- // Re-calculate hash after content update
3171
- const updatedHash = calculateHash(content, config.hashing?.normalization);
3172
-
3173
- const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
3174
- if (fileState) {
3175
- state.files[normalizedToRelative] = { ...fileState, hash: updatedHash };
3176
- delete state.files[normalizedFromRelative];
3177
- }
3178
- } else {
3179
- // Update state for each file moved normally
3180
- const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
3181
- if (fileState) {
3182
- state.files[normalizedToRelative] = { ...fileState, hash: newHash };
3183
- delete state.files[normalizedFromRelative];
3184
- }
3185
- }
3186
- } else {
3187
- // Update state for each file moved normally
3188
- const normalizedToRelative = path.relative(process.cwd(), toEntryPath).replace(/\\/g, '/');
3189
- if (fileState) {
3190
- state.files[normalizedToRelative] = { ...fileState, hash: newHash };
3191
- delete state.files[normalizedFromRelative];
3192
- }
3193
- }
3194
- }
3195
- }
3196
-
3197
- const remainingFiles = await readdir(fromPath);
3198
- if (remainingFiles.length === 0) {
3199
- await rmdir(fromPath);
3200
- }
3201
- }
3202
-
3203
- async function updateImportsInFile(filePath, oldFilePath, newFilePath) {
3204
- if (!existsSync(filePath)) return;
3205
-
3206
- let content = await readFile(filePath, 'utf-8');
3207
- const oldDir = path.dirname(oldFilePath);
3208
- const newDir = path.dirname(newFilePath);
3209
-
3210
- if (oldDir === newDir) return;
3211
-
3212
- // Find all relative imports
3213
- const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
3214
- let match;
3215
- const replacements = [];
3216
-
3217
- while ((match = relativeImportRegex.exec(content)) !== null) {
3218
- const relativePath = match[1];
3219
- const absoluteTarget = path.resolve(oldDir, relativePath);
3220
- const newRelativePath = getRelativeImportPath(newFilePath, absoluteTarget);
3221
-
3222
- replacements.push({
3223
- full: match[0],
3224
- oldRel: relativePath,
3225
- newRel: newRelativePath
3226
- });
3227
- }
3228
-
3229
- for (const repl of replacements) {
3230
- content = content.replace(repl.full, `from '${repl.newRel}'`);
3231
- }
3232
-
3233
- await writeFile(filePath, content, 'utf-8');
3234
- }
3235
-
3236
3286
  async function createComponentCommand(componentName, options) {
3237
3287
  try {
3238
3288
  const config = await loadConfig();
@@ -4551,6 +4601,111 @@ async function pruneMissingCommand(options = {}) {
4551
4601
  }
4552
4602
  }
4553
4603
 
4604
+ /**
4605
+ * Dispatcher for rename commands.
4606
+ */
4607
+ async function renameCommand(type, oldName, newName, options) {
4608
+ try {
4609
+ if (!type || !oldName || !newName) {
4610
+ throw new Error('Usage: textor rename <route|feature|component> <oldName> <newName>');
4611
+ }
4612
+
4613
+ if (type === 'route' || type === 'path') {
4614
+ const normalizedOld = normalizeRoute(oldName);
4615
+ const normalizedNew = normalizeRoute(newName);
4616
+ // By default, move-section will try to move the feature if it matches the route.
4617
+ // For a simple "rename route", we might want to keep that behavior or not.
4618
+ // Usually "rename route" means just the URL/file.
4619
+ return await moveSectionCommand(normalizedOld, undefined, normalizedNew, undefined, options);
4620
+ }
4621
+
4622
+ if (type === 'feature') {
4623
+ const state = await loadState();
4624
+ const normalizedOld = featureToDirectoryPath(oldName);
4625
+ const normalizedNew = featureToDirectoryPath(newName);
4626
+
4627
+ const section = findSection(state, normalizedOld);
4628
+
4629
+ if (section) {
4630
+ // If it's a managed section, move it using section logic
4631
+ return await moveSectionCommand(section.route, section.featurePath, section.route, normalizedNew, options);
4632
+ } else {
4633
+ // Standalone feature move
4634
+ return await moveSectionCommand(undefined, normalizedOld, undefined, normalizedNew, options);
4635
+ }
4636
+ }
4637
+
4638
+ if (type === 'component') {
4639
+ return await renameComponent(oldName, newName, options);
4640
+ }
4641
+
4642
+ throw new Error(`Unknown rename type: ${type}. Supported types: route, feature, component.`);
4643
+ } catch (error) {
4644
+ console.error('Error:', error.message);
4645
+ if (typeof process.exit === 'function' && process.env.NODE_ENV !== 'test') {
4646
+ process.exit(1);
4647
+ }
4648
+ throw error;
4649
+ }
4650
+ }
4651
+
4652
+ /**
4653
+ * Specialized logic for renaming shared components.
4654
+ */
4655
+ async function renameComponent(oldName, newName, options) {
4656
+ const config = await loadConfig();
4657
+ const state = await loadState();
4658
+
4659
+ const normalizedOldName = normalizeComponentName(oldName);
4660
+ const normalizedNewName = normalizeComponentName(newName);
4661
+
4662
+ const component = findComponent(state, normalizedOldName);
4663
+
4664
+ const componentsRoot = resolvePath(config, 'components');
4665
+ const fromPath = component
4666
+ ? path.resolve(process.cwd(), component.path)
4667
+ : path.join(componentsRoot, normalizedOldName);
4668
+
4669
+ const toPath = path.join(componentsRoot, normalizedNewName);
4670
+
4671
+ if (options.dryRun) {
4672
+ console.log(`Dry run - would rename component: ${normalizedOldName} -> ${normalizedNewName}`);
4673
+ console.log(` Path: ${fromPath} -> ${toPath}`);
4674
+ return;
4675
+ }
4676
+
4677
+ const signatures = Object.values(config.signatures || {});
4678
+
4679
+ await moveDirectory(fromPath, toPath, state, config, {
4680
+ ...options,
4681
+ fromName: normalizedOldName,
4682
+ toName: normalizedNewName,
4683
+ signatures
4684
+ });
4685
+
4686
+ if (options.scan) {
4687
+ await scanAndReplaceImports(config, state, {
4688
+ fromPath: normalizedOldName,
4689
+ fromName: normalizedOldName,
4690
+ type: 'component'
4691
+ }, {
4692
+ toPath: normalizedNewName,
4693
+ toName: normalizedNewName
4694
+ }, options);
4695
+ }
4696
+
4697
+ await cleanupEmptyDirs(path.dirname(fromPath), componentsRoot);
4698
+
4699
+ // Update state metadata
4700
+ if (component) {
4701
+ component.name = normalizedNewName;
4702
+ component.path = path.relative(process.cwd(), toPath).replace(/\\/g, '/');
4703
+ }
4704
+
4705
+ await saveState(state);
4706
+ console.log(`✓ Renamed component ${normalizedOldName} to ${normalizedNewName}`);
4707
+ }
4708
+
4554
4709
  const program = new Command();
4555
4710
 
4556
4711
  program
@@ -4696,5 +4851,14 @@ program
4696
4851
  .option('--no-interactive', 'Disable interactive prompts')
4697
4852
  .action(pruneMissingCommand);
4698
4853
 
4854
+ program
4855
+ .command('rename <type> <oldName> <newName>')
4856
+ .description('Rename a route, feature, or component')
4857
+ .option('--scan', 'Enable repo-wide import updates')
4858
+ .option('--force', 'Rename even if files are modified or not generated by Textor')
4859
+ .option('--accept-changes', 'Allow renaming of modified files')
4860
+ .option('--dry-run', 'Show what would be renamed without applying')
4861
+ .action(renameCommand);
4862
+
4699
4863
  program.parse();
4700
4864
  //# sourceMappingURL=textor.js.map