|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +const fs = require("fs"); |
| 4 | +const path = require("path"); |
| 5 | + |
| 6 | +const repoRoot = process.cwd(); |
| 7 | +const languages = process.argv |
| 8 | + .slice(2) |
| 9 | + .flatMap((value) => value.split(/\s+/)) |
| 10 | + .map((value) => value.trim()) |
| 11 | + .filter(Boolean); |
| 12 | + |
| 13 | +if (languages.length === 0) { |
| 14 | + console.error("Usage: node .github/scripts/fix-translated-markdown.js <language-codes>"); |
| 15 | + process.exit(1); |
| 16 | +} |
| 17 | + |
| 18 | +const tableHeadersByLanguage = { |
| 19 | + es: "| Capítulo | Título | Lo que construirás |", |
| 20 | + ko: "| 장 | 제목 | 만들 내용 |", |
| 21 | + ja: "| 章 | タイトル | 作成するもの |", |
| 22 | + "zh-CN": "| 章节 | 标题 | 你将构建的内容 |", |
| 23 | +}; |
| 24 | + |
| 25 | +const allFiles = new Set(walkFiles(repoRoot).map(toPosixRelative)); |
| 26 | +const errors = []; |
| 27 | +let changedFiles = 0; |
| 28 | + |
| 29 | +for (const language of languages) { |
| 30 | + const translationRoot = path.join(repoRoot, "translations", language); |
| 31 | + |
| 32 | + if (!fs.existsSync(translationRoot)) { |
| 33 | + console.log(`No translations found for ${language}; skipping.`); |
| 34 | + continue; |
| 35 | + } |
| 36 | + |
| 37 | + const markdownFiles = walkFiles(translationRoot).filter((file) => file.endsWith(".md")); |
| 38 | + |
| 39 | + for (const filePath of markdownFiles) { |
| 40 | + const original = fs.readFileSync(filePath, "utf8"); |
| 41 | + const fixed = fixMarkdown(original, filePath, language); |
| 42 | + |
| 43 | + if (fixed !== original) { |
| 44 | + fs.writeFileSync(filePath, fixed); |
| 45 | + changedFiles += 1; |
| 46 | + console.log(`Fixed translated Markdown: ${toPosixRelative(filePath)}`); |
| 47 | + } |
| 48 | + |
| 49 | + validateMarkdown(fixed, filePath, language); |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +if (errors.length > 0) { |
| 54 | + console.error("\nTranslated Markdown validation failed:"); |
| 55 | + for (const error of errors) { |
| 56 | + console.error(`- ${error}`); |
| 57 | + } |
| 58 | + process.exit(1); |
| 59 | +} |
| 60 | + |
| 61 | +console.log(`Translated Markdown cleanup complete. Files changed: ${changedFiles}`); |
| 62 | + |
| 63 | +function fixMarkdown(content, filePath, language) { |
| 64 | + let fixed = fixKnownTableHeaders(content, language); |
| 65 | + const headingSlugs = getHeadingSlugs(fixed); |
| 66 | + const headingSlugList = getHeadingSlugList(fixed); |
| 67 | + const sourceFile = getSourceFileForTranslation(filePath, language); |
| 68 | + |
| 69 | + fixed = fixed.replace(/\[([^\]\n]+)\]\((#[^)]+)\)/g, (match, label, destination) => { |
| 70 | + const currentSlug = destination.slice(1); |
| 71 | + |
| 72 | + if (headingSlugs.has(currentSlug)) { |
| 73 | + return match; |
| 74 | + } |
| 75 | + |
| 76 | + const labelSlug = slugifyHeading(label); |
| 77 | + |
| 78 | + if (headingSlugs.has(labelSlug)) { |
| 79 | + return `[${label}](#${labelSlug})`; |
| 80 | + } |
| 81 | + |
| 82 | + const candidates = [...headingSlugs].filter( |
| 83 | + (slug) => slug.startsWith(`${labelSlug}-`) || labelSlug.startsWith(`${slug}-`), |
| 84 | + ); |
| 85 | + |
| 86 | + if (candidates.length === 1) { |
| 87 | + return `[${label}](#${candidates[0]})`; |
| 88 | + } |
| 89 | + |
| 90 | + const sourceHeadingIndex = getSourceHeadingIndex(sourceFile, currentSlug); |
| 91 | + |
| 92 | + if (sourceHeadingIndex !== -1 && sourceHeadingIndex < headingSlugList.length) { |
| 93 | + return `[${label}](#${headingSlugList[sourceHeadingIndex]})`; |
| 94 | + } |
| 95 | + |
| 96 | + return match; |
| 97 | + }); |
| 98 | + |
| 99 | + return fixed.replace(/(\]\()([^)]+)(\))/g, (_match, prefix, destination, suffix) => { |
| 100 | + return `${prefix}${fixDestination(destination, filePath, language)}${suffix}`; |
| 101 | + }); |
| 102 | +} |
| 103 | + |
| 104 | +function fixKnownTableHeaders(content, language) { |
| 105 | + const translatedHeader = tableHeadersByLanguage[language]; |
| 106 | + |
| 107 | + if (!translatedHeader) { |
| 108 | + return content; |
| 109 | + } |
| 110 | + |
| 111 | + return content.replace( |
| 112 | + /^\|\s*Chapter\s*\|\s*Title\s*\|\s*What You'll Build\s*\|$/gm, |
| 113 | + translatedHeader, |
| 114 | + ); |
| 115 | +} |
| 116 | + |
| 117 | +function fixDestination(destination, filePath, language) { |
| 118 | + if (isExternal(destination) || destination.startsWith("#") || destination.startsWith("<")) { |
| 119 | + return destination; |
| 120 | + } |
| 121 | + |
| 122 | + const { target, fragment } = splitDestination(destination); |
| 123 | + |
| 124 | + if (!target || target.startsWith("#")) { |
| 125 | + return destination; |
| 126 | + } |
| 127 | + |
| 128 | + const translatedDir = path.posix.dirname(toPosixRelative(filePath)); |
| 129 | + const currentTarget = normalizePosix(path.posix.join(translatedDir, target)); |
| 130 | + const translatedRoot = `translations/${language}`; |
| 131 | + const sourceRelativeFile = path.posix.relative(translatedRoot, toPosixRelative(filePath)); |
| 132 | + const sourceDir = path.posix.dirname(sourceRelativeFile); |
| 133 | + const sourceTarget = normalizePosix(path.posix.join(sourceDir, target)); |
| 134 | + |
| 135 | + if (pathExists(currentTarget)) { |
| 136 | + return `${target}${fixCrossFileFragment(fragment, sourceTarget, currentTarget)}`; |
| 137 | + } |
| 138 | + |
| 139 | + if (!pathExists(sourceTarget)) { |
| 140 | + return destination; |
| 141 | + } |
| 142 | + |
| 143 | + const translatedTarget = normalizePosix(path.posix.join(translatedRoot, sourceTarget)); |
| 144 | + const preferredTarget = pathExists(translatedTarget) ? translatedTarget : sourceTarget; |
| 145 | + const relativeTarget = path.posix.relative(translatedDir, preferredTarget) || "."; |
| 146 | + const normalizedTarget = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; |
| 147 | + |
| 148 | + return `${normalizedTarget}${fixCrossFileFragment(fragment, sourceTarget, preferredTarget)}`; |
| 149 | +} |
| 150 | + |
| 151 | +function validateMarkdown(content, filePath, language) { |
| 152 | + const fileRelative = toPosixRelative(filePath); |
| 153 | + const fileDir = path.posix.dirname(fileRelative); |
| 154 | + const headingSlugs = getHeadingSlugs(content); |
| 155 | + const sourceFile = getSourceFileForTranslation(filePath, language); |
| 156 | + |
| 157 | + for (const destination of getDestinations(content)) { |
| 158 | + if (isExternal(destination) || destination.startsWith("<")) { |
| 159 | + continue; |
| 160 | + } |
| 161 | + |
| 162 | + const { target, fragment } = splitDestination(destination); |
| 163 | + |
| 164 | + if (!target) { |
| 165 | + const slug = fragment.slice(1); |
| 166 | + |
| 167 | + if ( |
| 168 | + slug && |
| 169 | + !headingSlugs.has(slug) && |
| 170 | + getSourceHeadingIndex(sourceFile, slug) !== -1 |
| 171 | + ) { |
| 172 | + errors.push(`${fileRelative} links to missing heading #${slug}`); |
| 173 | + } |
| 174 | + |
| 175 | + continue; |
| 176 | + } |
| 177 | + |
| 178 | + const resolvedTarget = normalizePosix(path.posix.join(fileDir, target)); |
| 179 | + |
| 180 | + if (!pathExists(resolvedTarget)) { |
| 181 | + const sourceTarget = getSourceTargetForDestination(filePath, language, target); |
| 182 | + |
| 183 | + if (sourceTarget && !pathExists(sourceTarget)) { |
| 184 | + continue; |
| 185 | + } |
| 186 | + |
| 187 | + errors.push(`${fileRelative} links to missing file ${destination}`); |
| 188 | + continue; |
| 189 | + } |
| 190 | + |
| 191 | + // Cross-file heading anchors are best-effort fixed above by mapping source |
| 192 | + // heading order to translated heading order. Avoid failing on anchors that |
| 193 | + // come from generated HTML IDs or pre-existing source content. |
| 194 | + } |
| 195 | +} |
| 196 | + |
| 197 | +function getDestinations(content) { |
| 198 | + const destinations = []; |
| 199 | + const pattern = /\]\(([^)]+)\)/g; |
| 200 | + let match; |
| 201 | + |
| 202 | + while ((match = pattern.exec(content)) !== null) { |
| 203 | + destinations.push(match[1]); |
| 204 | + } |
| 205 | + |
| 206 | + return destinations; |
| 207 | +} |
| 208 | + |
| 209 | +function getHeadingSlugs(content) { |
| 210 | + return new Set(getHeadingSlugList(content)); |
| 211 | +} |
| 212 | + |
| 213 | +function getHeadingSlugList(content) { |
| 214 | + return getHeadingSlugListWith(content, slugifyHeading); |
| 215 | +} |
| 216 | + |
| 217 | +function getSourceHeadingIndex(sourceFile, slug) { |
| 218 | + if (!sourceFile || !pathExists(sourceFile)) { |
| 219 | + return -1; |
| 220 | + } |
| 221 | + |
| 222 | + const sourceContent = fs.readFileSync(path.join(repoRoot, sourceFile), "utf8"); |
| 223 | + const slugLists = [ |
| 224 | + getHeadingSlugListWith(sourceContent, slugifyHeading), |
| 225 | + getHeadingSlugListWith(sourceContent, slugifyGitHubHeading), |
| 226 | + ]; |
| 227 | + |
| 228 | + for (const slugs of slugLists) { |
| 229 | + const index = slugs.indexOf(slug); |
| 230 | + |
| 231 | + if (index !== -1) { |
| 232 | + return index; |
| 233 | + } |
| 234 | + } |
| 235 | + |
| 236 | + return -1; |
| 237 | +} |
| 238 | + |
| 239 | +function getHeadingSlugListWith(content, slugifier) { |
| 240 | + const slugs = new Map(); |
| 241 | + const result = []; |
| 242 | + |
| 243 | + for (const line of content.split(/\r?\n/)) { |
| 244 | + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line); |
| 245 | + |
| 246 | + if (!match) { |
| 247 | + continue; |
| 248 | + } |
| 249 | + |
| 250 | + const baseSlug = slugifier(match[2]); |
| 251 | + const count = slugs.get(baseSlug) || 0; |
| 252 | + const slug = count === 0 ? baseSlug : `${baseSlug}-${count}`; |
| 253 | + slugs.set(baseSlug, count + 1); |
| 254 | + result.push(slug); |
| 255 | + } |
| 256 | + |
| 257 | + return result; |
| 258 | +} |
| 259 | + |
| 260 | +function fixCrossFileFragment(fragment, sourceTarget, translatedTarget) { |
| 261 | + if (!fragment || !sourceTarget.endsWith(".md") || !translatedTarget.endsWith(".md")) { |
| 262 | + return fragment; |
| 263 | + } |
| 264 | + |
| 265 | + if (!pathExists(sourceTarget) || !pathExists(translatedTarget)) { |
| 266 | + return fragment; |
| 267 | + } |
| 268 | + |
| 269 | + const currentSlug = fragment.slice(1); |
| 270 | + const translatedContent = fs.readFileSync(path.join(repoRoot, translatedTarget), "utf8"); |
| 271 | + const translatedSlugs = getHeadingSlugList(translatedContent); |
| 272 | + |
| 273 | + if (translatedSlugs.includes(currentSlug)) { |
| 274 | + return fragment; |
| 275 | + } |
| 276 | + |
| 277 | + const sourceContent = fs.readFileSync(path.join(repoRoot, sourceTarget), "utf8"); |
| 278 | + const sourceSlugs = getHeadingSlugList(sourceContent); |
| 279 | + const sourceIndex = sourceSlugs.indexOf(currentSlug); |
| 280 | + |
| 281 | + if (sourceIndex === -1 || sourceIndex >= translatedSlugs.length) { |
| 282 | + return fragment; |
| 283 | + } |
| 284 | + |
| 285 | + return `#${translatedSlugs[sourceIndex]}`; |
| 286 | +} |
| 287 | + |
| 288 | +function getSourceFileForTranslation(filePath, language) { |
| 289 | + const translatedRoot = `translations/${language}`; |
| 290 | + const sourceFile = path.posix.relative(translatedRoot, toPosixRelative(filePath)); |
| 291 | + |
| 292 | + if (sourceFile.startsWith("..")) { |
| 293 | + return ""; |
| 294 | + } |
| 295 | + |
| 296 | + return sourceFile; |
| 297 | +} |
| 298 | + |
| 299 | +function getSourceTargetForDestination(filePath, language, target) { |
| 300 | + const sourceFile = getSourceFileForTranslation(filePath, language); |
| 301 | + |
| 302 | + if (!sourceFile) { |
| 303 | + return ""; |
| 304 | + } |
| 305 | + |
| 306 | + const sourceDir = path.posix.dirname(sourceFile); |
| 307 | + return normalizePosix(path.posix.join(sourceDir, target)); |
| 308 | +} |
| 309 | + |
| 310 | +function slugifyHeading(value) { |
| 311 | + return value |
| 312 | + .replace(/<[^>]+>/g, "") |
| 313 | + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") |
| 314 | + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") |
| 315 | + .replace(/[`*_~]/g, "") |
| 316 | + .trim() |
| 317 | + .toLocaleLowerCase() |
| 318 | + .replace(/[^\p{Letter}\p{Number}\p{Mark}\s-]/gu, "") |
| 319 | + .trim() |
| 320 | + .replace(/\s+/g, "-"); |
| 321 | +} |
| 322 | + |
| 323 | +function slugifyGitHubHeading(value) { |
| 324 | + return value |
| 325 | + .replace(/<[^>]+>/g, "") |
| 326 | + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") |
| 327 | + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") |
| 328 | + .replace(/[`*_~]/g, "") |
| 329 | + .trim() |
| 330 | + .toLocaleLowerCase() |
| 331 | + .replace(/[^\p{Letter}\p{Number}\p{Mark}\s-]/gu, "") |
| 332 | + .replace(/\s/g, "-"); |
| 333 | +} |
| 334 | + |
| 335 | +function splitDestination(destination) { |
| 336 | + const hashIndex = destination.indexOf("#"); |
| 337 | + |
| 338 | + if (hashIndex === -1) { |
| 339 | + return { target: destination, fragment: "" }; |
| 340 | + } |
| 341 | + |
| 342 | + return { |
| 343 | + target: destination.slice(0, hashIndex), |
| 344 | + fragment: destination.slice(hashIndex), |
| 345 | + }; |
| 346 | +} |
| 347 | + |
| 348 | +function isExternal(destination) { |
| 349 | + return /^(https?:|mailto:|tel:)/i.test(destination); |
| 350 | +} |
| 351 | + |
| 352 | +function pathExists(relativePath) { |
| 353 | + if (allFiles.has(relativePath)) { |
| 354 | + return true; |
| 355 | + } |
| 356 | + |
| 357 | + if (fs.existsSync(path.join(repoRoot, relativePath))) { |
| 358 | + return true; |
| 359 | + } |
| 360 | + |
| 361 | + const indexPath = normalizePosix(path.posix.join(relativePath, "README.md")); |
| 362 | + return allFiles.has(indexPath); |
| 363 | +} |
| 364 | + |
| 365 | +function walkFiles(directory) { |
| 366 | + if (!fs.existsSync(directory)) { |
| 367 | + return []; |
| 368 | + } |
| 369 | + |
| 370 | + const entries = fs.readdirSync(directory, { withFileTypes: true }); |
| 371 | + const files = []; |
| 372 | + |
| 373 | + for (const entry of entries) { |
| 374 | + if (entry.name === ".git" || entry.name === "node_modules") { |
| 375 | + continue; |
| 376 | + } |
| 377 | + |
| 378 | + const entryPath = path.join(directory, entry.name); |
| 379 | + |
| 380 | + if (entry.isDirectory()) { |
| 381 | + files.push(...walkFiles(entryPath)); |
| 382 | + } else if (entry.isFile()) { |
| 383 | + files.push(entryPath); |
| 384 | + } |
| 385 | + } |
| 386 | + |
| 387 | + return files; |
| 388 | +} |
| 389 | + |
| 390 | +function toPosixRelative(filePath) { |
| 391 | + return normalizePosix(path.relative(repoRoot, filePath)); |
| 392 | +} |
| 393 | + |
| 394 | +function normalizePosix(value) { |
| 395 | + return value.split(path.sep).join(path.posix.sep).replace(/\\/g, "/"); |
| 396 | +} |
0 commit comments