|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +const { execSync, spawn } = require('node:child_process'); |
| 4 | +const fs = require('node:fs'); |
| 5 | +const path = require('node:path'); |
| 6 | + |
| 7 | +/** |
| 8 | + * Execute a shell command and return the output |
| 9 | + */ |
| 10 | +function execCommand(command, options = {}) { |
| 11 | + try { |
| 12 | + return execSync(command, { |
| 13 | + encoding: 'utf8', |
| 14 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 15 | + ...options, |
| 16 | + }).trim(); |
| 17 | + } catch (error) { |
| 18 | + if (options.allowEmpty && error.status === 1) { |
| 19 | + return ''; |
| 20 | + } |
| 21 | + throw error; |
| 22 | + } |
| 23 | +} |
| 24 | + |
| 25 | +/** |
| 26 | + * Get the current timestamp in ISO format |
| 27 | + */ |
| 28 | +function getCurrentTimestamp() { |
| 29 | + return new Date().toISOString(); |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Find all available translation locales |
| 34 | + */ |
| 35 | +function findAvailableLocales() { |
| 36 | + const contentDir = path.join(process.cwd(), 'apps/docs/content'); |
| 37 | + |
| 38 | + if (!fs.existsSync(contentDir)) { |
| 39 | + console.log('Content directory not found:', contentDir); |
| 40 | + return []; |
| 41 | + } |
| 42 | + |
| 43 | + const locales = fs |
| 44 | + .readdirSync(contentDir, { withFileTypes: true }) |
| 45 | + .filter((dirent) => dirent.isDirectory() && dirent.name !== 'en') |
| 46 | + .map((dirent) => dirent.name); |
| 47 | + |
| 48 | + console.log('Available translation locales:', locales.join(' ')); |
| 49 | + return locales; |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Get renamed files from git status |
| 54 | + */ |
| 55 | +function getRenamedFiles() { |
| 56 | + try { |
| 57 | + const gitStatus = execCommand('git status --porcelain', { |
| 58 | + allowEmpty: true, |
| 59 | + }); |
| 60 | + const renames = gitStatus |
| 61 | + .split('\n') |
| 62 | + .filter((line) => line.match(/^R\s+apps\/docs\/content\/en/)) |
| 63 | + .map((line) => line.replace(/^R\s+/, '')); |
| 64 | + |
| 65 | + console.log('Renamed files detected:', renames.length); |
| 66 | + return renames; |
| 67 | + } catch (error) { |
| 68 | + console.log('No renamed files detected or git error:', error.message); |
| 69 | + return []; |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Get deleted files from git status |
| 75 | + */ |
| 76 | +function getDeletedFiles() { |
| 77 | + try { |
| 78 | + const gitStatus = execCommand('git status --porcelain', { |
| 79 | + allowEmpty: true, |
| 80 | + }); |
| 81 | + const deletes = gitStatus |
| 82 | + .split('\n') |
| 83 | + .filter((line) => line.match(/^D\s+apps\/docs\/content\/en/)) |
| 84 | + .map((line) => line.replace(/^D\s+/, '')); |
| 85 | + |
| 86 | + console.log('Deleted files detected:', deletes.length); |
| 87 | + return deletes; |
| 88 | + } catch (error) { |
| 89 | + console.log('No deleted files detected or git error:', error.message); |
| 90 | + return []; |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * Check if a rename involves content changes |
| 96 | + */ |
| 97 | +function hasContentChanges(sourcePath, destPath) { |
| 98 | + try { |
| 99 | + const diffOutput = execCommand( |
| 100 | + `git diff --cached -M -- "${sourcePath}" "${destPath}"`, |
| 101 | + { allowEmpty: true }, |
| 102 | + ); |
| 103 | + |
| 104 | + // Count lines that start with +/- (changes) but ignore the rename header lines |
| 105 | + const changeLines = diffOutput |
| 106 | + .split('\n') |
| 107 | + .filter((line) => !line.startsWith('renamed:') && !line.startsWith('─')) |
| 108 | + .filter((line) => line.match(/^[\+\-]/)); |
| 109 | + |
| 110 | + const changeCount = changeLines.length; |
| 111 | + console.log( |
| 112 | + `Git diff for '${sourcePath}' → '${destPath}': ${changeCount} lines changed`, |
| 113 | + ); |
| 114 | + |
| 115 | + return changeCount > 0; |
| 116 | + } catch (error) { |
| 117 | + console.log('Error checking content changes:', error.message); |
| 118 | + return false; |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +/** |
| 123 | + * Update timestamps in a translation file |
| 124 | + */ |
| 125 | +function updateTimestamps(filePath, timestamp) { |
| 126 | + try { |
| 127 | + let content = fs.readFileSync(filePath, 'utf8'); |
| 128 | + |
| 129 | + // Update source-updated-at and translation-updated-at |
| 130 | + content = content.replace( |
| 131 | + /source-updated-at: .*/, |
| 132 | + `source-updated-at: ${timestamp}`, |
| 133 | + ); |
| 134 | + content = content.replace( |
| 135 | + /translation-updated-at: .*/, |
| 136 | + `translation-updated-at: ${timestamp}`, |
| 137 | + ); |
| 138 | + |
| 139 | + fs.writeFileSync(filePath, content, 'utf8'); |
| 140 | + console.log(`Updated timestamps in ${filePath}`); |
| 141 | + } catch (error) { |
| 142 | + console.log(`Error updating timestamps in ${filePath}:`, error.message); |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Process file renames for all locales |
| 148 | + */ |
| 149 | +function processRenames(renames, locales) { |
| 150 | + if (renames.length === 0) { |
| 151 | + console.log('No file renames detected in English docs.'); |
| 152 | + return; |
| 153 | + } |
| 154 | + |
| 155 | + console.log( |
| 156 | + 'File renames detected in English docs. Processing for other languages...', |
| 157 | + ); |
| 158 | + const currentTimestamp = getCurrentTimestamp(); |
| 159 | + console.log('Current timestamp:', currentTimestamp); |
| 160 | + |
| 161 | + for (const locale of locales) { |
| 162 | + console.log(`Processing renames for locale: ${locale}`); |
| 163 | + |
| 164 | + for (const rename of renames) { |
| 165 | + // Parse the rename line: "oldname -> newname" |
| 166 | + const parts = rename.split(' -> '); |
| 167 | + if (parts.length !== 2) { |
| 168 | + console.log(`Invalid rename format: ${rename}`); |
| 169 | + continue; |
| 170 | + } |
| 171 | + |
| 172 | + const [source, dest] = parts; |
| 173 | + |
| 174 | + // Check for content changes |
| 175 | + const contentChanged = hasContentChanges(source, dest); |
| 176 | + const contentUnchanged = !contentChanged; |
| 177 | + |
| 178 | + if (contentUnchanged) { |
| 179 | + console.log('Pure rename detected (no content changes)'); |
| 180 | + } else { |
| 181 | + console.log('Content changes detected along with rename'); |
| 182 | + } |
| 183 | + |
| 184 | + // Replace 'en' with current locale in paths |
| 185 | + const sourceLocale = source.replace('content/en', `content/${locale}`); |
| 186 | + const destLocale = dest.replace('content/en', `content/${locale}`); |
| 187 | + |
| 188 | + // Check if source file exists in this locale |
| 189 | + if (fs.existsSync(sourceLocale)) { |
| 190 | + console.log(`Renaming ${sourceLocale} to ${destLocale}`); |
| 191 | + |
| 192 | + // Create directory if it doesn't exist |
| 193 | + const destDir = path.dirname(destLocale); |
| 194 | + if (!fs.existsSync(destDir)) { |
| 195 | + fs.mkdirSync(destDir, { recursive: true }); |
| 196 | + } |
| 197 | + |
| 198 | + // Move the file |
| 199 | + fs.renameSync(sourceLocale, destLocale); |
| 200 | + |
| 201 | + // If content is unchanged, update timestamps |
| 202 | + if (contentUnchanged) { |
| 203 | + console.log( |
| 204 | + `Content unchanged, updating timestamps in ${destLocale}`, |
| 205 | + ); |
| 206 | + updateTimestamps(destLocale, currentTimestamp); |
| 207 | + } else { |
| 208 | + console.log( |
| 209 | + 'Content changed, keeping original timestamps for translation to detect changes', |
| 210 | + ); |
| 211 | + } |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +/** |
| 218 | + * Process file deletions for all locales |
| 219 | + */ |
| 220 | +function processDeletions(deletions, locales) { |
| 221 | + if (deletions.length === 0) { |
| 222 | + console.log('No file deletions detected in English docs.'); |
| 223 | + return; |
| 224 | + } |
| 225 | + |
| 226 | + console.log( |
| 227 | + 'File deletions detected in English docs. Processing for other languages...', |
| 228 | + ); |
| 229 | + |
| 230 | + for (const locale of locales) { |
| 231 | + console.log(`Processing deletions for locale: ${locale}`); |
| 232 | + |
| 233 | + for (const deletedFile of deletions) { |
| 234 | + // Replace 'en' with current locale in path |
| 235 | + const fileLocale = deletedFile.replace('content/en', `content/${locale}`); |
| 236 | + |
| 237 | + // Check if file exists in this locale |
| 238 | + if (fs.existsSync(fileLocale)) { |
| 239 | + console.log(`Deleting ${fileLocale}`); |
| 240 | + fs.unlinkSync(fileLocale); |
| 241 | + |
| 242 | + // Check if parent directory is empty and remove it if it is |
| 243 | + const dir = path.dirname(fileLocale); |
| 244 | + try { |
| 245 | + if (fs.existsSync(dir)) { |
| 246 | + const dirContents = fs.readdirSync(dir); |
| 247 | + if (dirContents.length === 0) { |
| 248 | + console.log(`Removing empty directory: ${dir}`); |
| 249 | + fs.rmdirSync(dir); |
| 250 | + } |
| 251 | + } |
| 252 | + } catch (error) { |
| 253 | + // Directory might not be empty or other error, that's ok |
| 254 | + console.log(`Could not remove directory ${dir}:`, error.message); |
| 255 | + } |
| 256 | + } |
| 257 | + } |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +/** |
| 262 | + * Main function |
| 263 | + */ |
| 264 | +function main() { |
| 265 | + console.log('Processing file renames and deletions...'); |
| 266 | + |
| 267 | + try { |
| 268 | + // Find available locales |
| 269 | + const locales = findAvailableLocales(); |
| 270 | + |
| 271 | + if (locales.length === 0) { |
| 272 | + console.log('No translation locales found, skipping processing.'); |
| 273 | + return; |
| 274 | + } |
| 275 | + |
| 276 | + // Get renamed and deleted files |
| 277 | + const renames = getRenamedFiles(); |
| 278 | + const deletions = getDeletedFiles(); |
| 279 | + |
| 280 | + // Process renames |
| 281 | + processRenames(renames, locales); |
| 282 | + |
| 283 | + // Process deletions |
| 284 | + processDeletions(deletions, locales); |
| 285 | + |
| 286 | + console.log('File processing completed successfully.'); |
| 287 | + } catch (error) { |
| 288 | + console.error('Error processing file changes:', error.message); |
| 289 | + process.exit(1); |
| 290 | + } |
| 291 | +} |
| 292 | + |
| 293 | +// Run the script if called directly |
| 294 | +if (require.main === module) { |
| 295 | + main(); |
| 296 | +} |
| 297 | + |
| 298 | +module.exports = { |
| 299 | + main, |
| 300 | + findAvailableLocales, |
| 301 | + getRenamedFiles, |
| 302 | + getDeletedFiles, |
| 303 | + processRenames, |
| 304 | + processDeletions, |
| 305 | +}; |
0 commit comments