Skip to content

Commit ca0b26d

Browse files
DanWahlinCopilot
andcommitted
fix(translations): normalize generated markdown links
Adds a deterministic cleanup step for translated Markdown before creating the translation PR. The workflow now migrates links, fixes repo-specific anchors and local paths, and runs Co-op Translator review checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 21b4711 commit ca0b26d

2 files changed

Lines changed: 412 additions & 11 deletions

File tree

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
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

Comments
 (0)