blob: 53886e385ca9798bb9c2db81f8ba1a1103c15bb3 [file]
#!/usr/bin/env node
/**
* Used to check whether there are broken links in the modified files in the pipeline
*/
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { sourceForLocalized } = require("./i18n/paths.cjs");
const { managedLocales } = require("./i18n/state.cjs");
const translationLocales = new Set(managedLocales(path.resolve(__dirname, "..")));
const baseRef = process.argv[2] === '--base' ? process.argv[3] : null;
const commitHash = baseRef ? null : process.argv[2];
if (require.main === module && (!commitHash && !baseRef || commitHash === '--base')) {
console.error("Provide a commit hash, or --base <merge-base> to check the complete PR diff.");
process.exit(1);
}
const linkRegex = /\[.*?\]\((.*?)\)/g;
let hasBrokenLinks = false;
// Get the modified or newly added .md/.mdx files in the commit
function getModifiedMarkdownFiles(commit, base) {
const selection = base ? ['diff', '--name-only', '--diff-filter=AMR', '-z', base, 'HEAD']
: ['show', '--format=', '--name-only', '--diff-filter=AMR', '-z', commit];
return execFileSync('git', [...selection, '--', '*.md', '*.mdx'],
{encoding: 'utf8', maxBuffer: 100 * 1024 * 1024}).split('\0')
// Frozen sources are retained for redirects/PDFs and are not site pages.
.filter(file => file && !file.startsWith('archived_docs/'));
}
// Checks if the link points to an existing local file
function isLocalLink(link) {
return !link.startsWith("http://") &&
!link.startsWith("https://") &&
!link.startsWith("mailto:") &&
!link.startsWith("#") &&
!path.isAbsolute(link)&&
!/.*@.*\..*/.test(link);
}
function existingLocalTarget(filePath, rawLink) {
const candidates = [path.resolve(path.dirname(filePath), rawLink)];
// Managed docs locales serve English when a translation is absent. Resolve
// relative links from that same plugin/version; keep the EN/ZH gate unchanged.
if (filePath.startsWith("i18n/") && translationLocales.has(filePath.split("/")[1])) {
const source = sourceForLocalized(filePath);
if (source) candidates.push(path.resolve(path.dirname(source), rawLink));
}
return candidates.some(candidate => [candidate, `${candidate}.md`, `${candidate}.mdx`]
.some(file => fs.existsSync(file)));
}
// Strip Markdown code regions so the link scanner ignores their contents.
//
// The previous implementation used `/```[\s\S]*?```/g` followed by `` /`[^`]*`/g ``,
// which mishandles docs whose inline code itself contains triple backticks
// (e.g. a table cell that reads "` ```shell `"). In that case the global
// triple-backtick regex paired ``` across rows and across real fenced blocks,
// shifting the open/close boundaries and leaving real code regions visible
// to the link scanner — which then reported their contents as broken links.
//
// Correct rules (a subset of CommonMark sufficient for our docs):
// 1. A fenced code block opens with 3+ backticks at the start of a line
// (up to 3 leading spaces). It closes with another run of >= that many
// backticks at the start of a line. Anything else (e.g. ``` inside a
// table cell) is NOT a fence.
// 2. An inline code span opens with N backticks and closes with the next
// run of EXACTLY N backticks. The content may contain shorter runs.
function removeCodeBlocks(content) {
const lines = content.split("\n");
const stripped = [];
let openFenceLen = 0;
for (const line of lines) {
if (openFenceLen === 0) {
const m = line.match(/^ {0,3}(`{3,})/);
if (m) {
openFenceLen = m[1].length;
stripped.push("");
continue;
}
stripped.push(line);
} else {
const m = line.match(/^ {0,3}(`{3,})\s*$/);
if (m && m[1].length >= openFenceLen) {
openFenceLen = 0;
}
stripped.push("");
}
}
let result = stripped.join("\n");
result = result.replace(/(`+)(?:(?!\1)[\s\S])*?\1/g, "");
return result;
}
function findEsmStatementEnd(content, start) {
let depth = 0;
let quote = null;
let escaping = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = start; i < content.length; i++) {
const ch = content[i];
const next = content[i + 1];
if (quote) {
if (escaping) {
escaping = false;
} else if (ch === "\\") {
escaping = true;
} else if (ch === quote) {
quote = null;
}
continue;
}
if (inLineComment) {
if (ch === "\n") {
inLineComment = false;
}
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (ch === "/" && next === "/") {
inLineComment = true;
i++;
continue;
}
if (ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (ch === "\"" || ch === "'" || ch === "`") {
quote = ch;
continue;
}
if (ch === "{" || ch === "[" || ch === "(") {
depth++;
continue;
}
if (ch === "}" || ch === "]" || ch === ")") {
depth = Math.max(0, depth - 1);
continue;
}
if (ch === ";" && depth === 0) {
return i + 1;
}
}
return content.length;
}
function removeMdxEsmBlocks(content) {
const esmStartRegex = /^[ \t]*(?:import(?:\s|["'{*])|export\s+(?:const|let|var|function|class|default|\{|\*))/;
let result = "";
let cursor = 0;
let index = 0;
while (index < content.length) {
const lineEnd = content.indexOf("\n", index);
const end = lineEnd === -1 ? content.length : lineEnd;
const line = content.slice(index, end);
if (esmStartRegex.test(line)) {
const statementEnd = findEsmStatementEnd(content, index);
result += content.slice(cursor, index);
result += content.slice(index, statementEnd).replace(/[^\n]/g, "");
cursor = statementEnd;
index = statementEnd;
continue;
}
index = lineEnd === -1 ? content.length : lineEnd + 1;
}
return result + content.slice(cursor);
}
// Check links in files
function checkFileLinks(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const cleanedContent = removeMdxEsmBlocks(removeCodeBlocks(content));
const matches = [...cleanedContent.matchAll(linkRegex)];
for (const match of matches) {
const rawLink = match[1].split("#")[0]; // Remove anchor point
if (!isLocalLink(rawLink)) continue;
console.log('rawLink',rawLink);
if (!existingLocalTarget(filePath, rawLink)) {
console.error(`❌ ${filePath}: Broken link -> ${rawLink}`);
hasBrokenLinks = true;
}
}
}
// Main function
function main() {
const files = getModifiedMarkdownFiles(commitHash, baseRef);
if (files.length === 0) {
console.log("✅ Unmodified Markdown files");
return;
}
for (const file of files) {
if (fs.existsSync(file)) {
checkFileLinks(file);
}
}
if (hasBrokenLinks) {
console.error("❗ A broken link was detected. Please fix it and submit.");
process.exit(1);
} else {
console.log("✅ All links are OK");
}
}
if (require.main === module) main();
module.exports = { existingLocalTarget };