|
| 1 | +import { readdirSync, statSync, readFileSync } from "node:fs"; |
| 2 | +import path from "node:path"; |
| 3 | +import chalk from "chalk"; |
| 4 | +import * as github from "@actions/core"; |
| 5 | +const cwd = process.env.GITHUB_ACTIONS ? process.env.GITHUB_WORKSPACE! : process.cwd(); |
| 6 | + |
| 7 | +function importDirectory(directory: string, extension: string, subdirectories = true) { |
| 8 | + try { |
| 9 | + const output = new Map<string, string>(); |
| 10 | + const files = readdirSync(directory); |
| 11 | + for (const fileOrPath of files) { |
| 12 | + const currentPath = path.join(directory, fileOrPath); |
| 13 | + if (statSync(currentPath).isDirectory()) { |
| 14 | + if (!subdirectories) continue; |
| 15 | + const subdir = importDirectory(currentPath, extension, subdirectories); |
| 16 | + if (!subdir) continue; |
| 17 | + for (const [name, read] of subdir) { |
| 18 | + output.set(`/${fileOrPath}${name}`, read); |
| 19 | + } |
| 20 | + continue; |
| 21 | + } |
| 22 | + if (!fileOrPath.endsWith(extension)) continue; |
| 23 | + const read = readFileSync(currentPath, "utf8"); |
| 24 | + output.set(`/${fileOrPath}`, read); |
| 25 | + } |
| 26 | + return output; |
| 27 | + } catch { |
| 28 | + // Directory likely does not exist, we should be able to safely discard this error |
| 29 | + return null; |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +function printResults(resultMap: Map<string, github.AnnotationProperties[]>): void { |
| 34 | + let output = "\n"; |
| 35 | + let total = 0; |
| 36 | + for (const [resultFile, resultArr] of resultMap) { |
| 37 | + if (resultArr.length <= 0) continue; |
| 38 | + const filePath = path.join(cwd, resultFile); |
| 39 | + output += `${chalk.underline(filePath)}\n`; |
| 40 | + output += resultArr.reduce<string>((result, props) => { |
| 41 | + total += 1; |
| 42 | + return `${result} ${props.startLine ?? ""}:${props.startColumn ?? ""}-${props.endColumn ?? ""} ${chalk.yellow( |
| 43 | + "warning" |
| 44 | + )} ${props.title ?? ""}\n`; |
| 45 | + }, ""); |
| 46 | + output += "\n"; |
| 47 | + } |
| 48 | + output += "\n"; |
| 49 | + if (total > 0) { |
| 50 | + output += chalk.red.bold(`\u2716 ${total} problem${total === 1 ? "" : "s"}\n`); |
| 51 | + } |
| 52 | + console.log(output); |
| 53 | +} |
| 54 | + |
| 55 | +function annotateResults(resultMap: Map<string, github.AnnotationProperties[]>): void { |
| 56 | + let total = 0; |
| 57 | + for (const [resultFile, resultArr] of resultMap) { |
| 58 | + if (resultArr.length <= 0) continue; |
| 59 | + github.startGroup(resultFile); |
| 60 | + for (const result of resultArr) { |
| 61 | + total += 1; |
| 62 | + console.log( |
| 63 | + `::warning file=${resultFile},title=Invalid Link,line=${result.startLine ?? 0},endLine=${ |
| 64 | + result.startLine ?? 0 |
| 65 | + },col=${result.startColumn ?? 0},endColumn=${result.endColumn ?? result.startColumn ?? 0}::${ |
| 66 | + result.title ?? "Invalid Link" |
| 67 | + }` |
| 68 | + ); |
| 69 | + } |
| 70 | + github.endGroup(); |
| 71 | + } |
| 72 | + if (total > 0) { |
| 73 | + github.setFailed("One or more links are invalid!"); |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +const docFiles = importDirectory(path.join(cwd, "docs"), ".md"); |
| 78 | + |
| 79 | +if (!docFiles) { |
| 80 | + console.error("No doc files found!"); |
| 81 | + process.exit(1); |
| 82 | +} |
| 83 | + |
| 84 | +const validLinks = new Map<string, string[]>([ |
| 85 | + ["APPLICATIONS", []], |
| 86 | + ["SERVERS", []], |
| 87 | + ["TEAMS", []], |
| 88 | +]); |
| 89 | + |
| 90 | +const extLength = ".md".length; |
| 91 | + |
| 92 | +// Gather valid links |
| 93 | +for (const [name, raw] of docFiles) { |
| 94 | + const keyName = `DOCS${name.slice(0, -extLength).replaceAll("/", "_").toUpperCase()}`; |
| 95 | + if (!validLinks.has(keyName)) { |
| 96 | + validLinks.set(keyName, []); |
| 97 | + } |
| 98 | + const validAnchors = validLinks.get(keyName)!; |
| 99 | + |
| 100 | + let parentAnchor = ""; |
| 101 | + let multilineCode = false; |
| 102 | + for (const line of raw.split("\n")) { |
| 103 | + if (line.trim().startsWith("```")) { |
| 104 | + multilineCode = !multilineCode; |
| 105 | + if (line.trim().length > 3 && line.trim().endsWith("```")) multilineCode = !multilineCode; |
| 106 | + } |
| 107 | + if (multilineCode || !line.startsWith("#")) continue; |
| 108 | + const anchor = line |
| 109 | + .split("%")[0] |
| 110 | + .replace(/[^ A-Z0-9]/gi, "") |
| 111 | + .trim() |
| 112 | + .replace(/ +/g, "-") |
| 113 | + .toLowerCase(); |
| 114 | + if (/^#{1,4}(?!#)/.test(line.trim())) { |
| 115 | + parentAnchor = `${anchor}-`; |
| 116 | + validAnchors.push(anchor); |
| 117 | + continue; |
| 118 | + } |
| 119 | + validAnchors.push(`${parentAnchor}${anchor}`); |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +const results = new Map<string, github.AnnotationProperties[]>(); |
| 124 | + |
| 125 | +// Check Links |
| 126 | +for (const [name, raw] of docFiles) { |
| 127 | + const fileName = `docs${name}`; |
| 128 | + const file = raw.split("\n"); |
| 129 | + if (!results.has(fileName)) { |
| 130 | + results.set(fileName, []); |
| 131 | + } |
| 132 | + const ownResults = results.get(fileName)!; |
| 133 | + let multilineCode = false; |
| 134 | + file.forEach((line, lineNum) => { |
| 135 | + if (line.trim().startsWith("```")) { |
| 136 | + multilineCode = !multilineCode; |
| 137 | + if (line.trim().length > 3 && line.trim().endsWith("```")) multilineCode = !multilineCode; |
| 138 | + } |
| 139 | + if (multilineCode) return; |
| 140 | + const matches = line.matchAll(/(?<![!`])\[.+?\]\((?!https?|mailto)(.+?)\)(?!`)/g); |
| 141 | + |
| 142 | + for (const match of matches) { |
| 143 | + const split = match[1].split("#")[1].split("/"); |
| 144 | + const page = split[0]; |
| 145 | + const anchor = split[1]; |
| 146 | + if (!validLinks.has(page)) { |
| 147 | + ownResults.push({ |
| 148 | + title: `Base url ${chalk.blueBright(page)} does not exist`, |
| 149 | + startLine: lineNum + 1, |
| 150 | + startColumn: match.index, |
| 151 | + endColumn: (match.index ?? 0) + match[0].length, |
| 152 | + }); |
| 153 | + continue; |
| 154 | + } |
| 155 | + |
| 156 | + if (!anchor) continue; |
| 157 | + if (!validLinks.get(page)!.includes(anchor)) { |
| 158 | + ownResults.push({ |
| 159 | + title: `Anchor ${chalk.cyan(anchor)} does not exist on ${chalk.blueBright(page)}`, |
| 160 | + startLine: lineNum + 1, |
| 161 | + startColumn: match.index, |
| 162 | + endColumn: (match.index ?? 0) + match[0].length, |
| 163 | + }); |
| 164 | + } |
| 165 | + } |
| 166 | + }); |
| 167 | +} |
| 168 | + |
| 169 | +if (results.size > 0) { |
| 170 | + if (process.env.GITHUB_ACTIONS) { |
| 171 | + annotateResults(results); |
| 172 | + } else { |
| 173 | + printResults(results); |
| 174 | + } |
| 175 | +} |
0 commit comments