@mintlify/link-rot 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintignore +1 -0
- package/.eslintrc.json +3 -0
- package/package.json +7 -6
- package/src/link-renaming/index.ts +3 -0
- package/src/link-renaming/renameFileAndUpdateLinksInContent.ts +62 -0
- package/src/link-renaming/renameInternalLinksInPage.ts +69 -0
- package/src/prebuild.ts +64 -0
- package/src/static-checking/crawlPages.ts +1 -0
- package/src/static-checking/getBrokenInternalLinks.ts +19 -0
- package/src/static-checking/getUsedInternalLinks.ts +96 -0
- package/src/static-checking/getValidInternalLinks.ts +13 -0
- package/src/static-checking/index.ts +8 -0
- package/tsconfig.json +11 -0
package/.eslintignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dist/
|
package/.eslintrc.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mintlify/link-rot",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "Static checking for broken internal links",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
"access": "public",
|
|
27
27
|
"registry": "https://registry.npmjs.org/"
|
|
28
28
|
},
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"static-checking": "./dist/static-checking",
|
|
32
|
+
"link-renaming": "./dist/link-renaming"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
34
35
|
"scripts": {
|
|
35
36
|
"prepare": "npm run build",
|
|
36
37
|
"build": "tsc",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, lstatSync, renameSync } from "fs";
|
|
2
|
+
import { normalize, parse, ParsedPath } from "path";
|
|
3
|
+
import renameInternalLinksInPage from "./renameInternalLinksInPage.js";
|
|
4
|
+
import { isValidPage, removeFileExtension, getPagePaths } from "../prebuild.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renames a link in the file system. If the link is a directory, all links within the directory will be renamed as well.
|
|
8
|
+
* @param existingDir - The existing directory or file to rename
|
|
9
|
+
* @param newDirName - The new directory or file name
|
|
10
|
+
* @param force
|
|
11
|
+
*/
|
|
12
|
+
export const renameFileAndUpdateLinksInContent = async (
|
|
13
|
+
existingDir: string,
|
|
14
|
+
newDirName: string,
|
|
15
|
+
force = false
|
|
16
|
+
) => {
|
|
17
|
+
existingDir = normalize(existingDir);
|
|
18
|
+
newDirName = normalize(newDirName);
|
|
19
|
+
const existingDirParsed: ParsedPath = parse(existingDir);
|
|
20
|
+
const newDirParsed: ParsedPath = parse(newDirName);
|
|
21
|
+
if (!existsSync(existingDir)) {
|
|
22
|
+
throw new Error("File or folder does not exist at " + existingDir);
|
|
23
|
+
}
|
|
24
|
+
if (!force && existsSync(newDirName)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"File or folder exists at " + newDirName + ". Use --force to overwrite."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isDirectory = lstatSync(existingDir).isDirectory();
|
|
31
|
+
if (!isDirectory) {
|
|
32
|
+
if (!isValidPage(existingDirParsed)) {
|
|
33
|
+
throw new Error("File to rename must be an MDX or Markdown file.");
|
|
34
|
+
}
|
|
35
|
+
if (!isValidPage(newDirParsed)) {
|
|
36
|
+
throw new Error("New file name must be an MDX or Markdown file.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
renameSync(existingDir, newDirName);
|
|
40
|
+
console.log(`Renamed ${existingDir} to ${newDirName}.`);
|
|
41
|
+
|
|
42
|
+
const existingLink = removeFileExtension(existingDirParsed);
|
|
43
|
+
const newLink = removeFileExtension(newDirParsed);
|
|
44
|
+
const pagesInDirectory = getPagePaths(process.cwd());
|
|
45
|
+
const renameLinkPromises: Promise<void>[] = [];
|
|
46
|
+
pagesInDirectory.forEach((filePath: string) => {
|
|
47
|
+
renameLinkPromises.push(
|
|
48
|
+
(async () => {
|
|
49
|
+
const numRenamedLinks = await renameInternalLinksInPage(
|
|
50
|
+
filePath,
|
|
51
|
+
existingLink,
|
|
52
|
+
newLink
|
|
53
|
+
);
|
|
54
|
+
if (numRenamedLinks > 0) {
|
|
55
|
+
console.log(`Renamed ${numRenamedLinks} link(s) in ${filePath}`);
|
|
56
|
+
}
|
|
57
|
+
})()
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
await Promise.all(renameLinkPromises);
|
|
61
|
+
return;
|
|
62
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('remark-mdx')}
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import type { Root } from "mdast";
|
|
6
|
+
import { normalize } from "path";
|
|
7
|
+
import { remark } from "remark";
|
|
8
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
9
|
+
import remarkGfm from "remark-gfm";
|
|
10
|
+
import remarkMdx from "remark-mdx";
|
|
11
|
+
import { visit } from "unist-util-visit";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Go through fileContent and replace all links that match existingLink with
|
|
15
|
+
* newLink
|
|
16
|
+
*/
|
|
17
|
+
const getContentWithRenamedInternalLinks = async (
|
|
18
|
+
fileContent: string,
|
|
19
|
+
existingLink: string,
|
|
20
|
+
newLink: string
|
|
21
|
+
) => {
|
|
22
|
+
let numRenamedLinks = 0;
|
|
23
|
+
const remarkMdxReplaceLinks = () => {
|
|
24
|
+
return (tree: Root) => {
|
|
25
|
+
visit(tree, (node) => {
|
|
26
|
+
// ![]() format
|
|
27
|
+
if (
|
|
28
|
+
node.type === "link" &&
|
|
29
|
+
node.url &&
|
|
30
|
+
normalize(node.url) === existingLink
|
|
31
|
+
) {
|
|
32
|
+
node.url = newLink;
|
|
33
|
+
numRenamedLinks++;
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
const file = await remark()
|
|
40
|
+
.use(remarkMdx)
|
|
41
|
+
.use(remarkGfm)
|
|
42
|
+
.use(remarkFrontmatter, ["yaml", "toml"])
|
|
43
|
+
.use(remarkMdxReplaceLinks)
|
|
44
|
+
.process(fileContent);
|
|
45
|
+
return {
|
|
46
|
+
numRenamedLinks,
|
|
47
|
+
newContent: String(file),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const renameInternalLinksInPage = async (
|
|
52
|
+
filePath: string,
|
|
53
|
+
existingLink: string,
|
|
54
|
+
newLink: string
|
|
55
|
+
): Promise<number> => {
|
|
56
|
+
const fileContent = fs.readFileSync(filePath).toString();
|
|
57
|
+
const { numRenamedLinks, newContent } =
|
|
58
|
+
await getContentWithRenamedInternalLinks(
|
|
59
|
+
fileContent,
|
|
60
|
+
existingLink,
|
|
61
|
+
newLink
|
|
62
|
+
);
|
|
63
|
+
fs.outputFileSync(filePath, newContent, {
|
|
64
|
+
flag: "w",
|
|
65
|
+
});
|
|
66
|
+
return numRenamedLinks;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default renameInternalLinksInPage;
|
package/src/prebuild.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path, { ParsedPath } from "path";
|
|
2
|
+
import { getFileList } from "@mintlify/prebuild";
|
|
3
|
+
|
|
4
|
+
// TODO refactor to prebuild package
|
|
5
|
+
const NODE_MODULES_DIRECTORY = "/node_modules";
|
|
6
|
+
const SUPPORTED_PAGE_EXTENSIONS = [".mdx", ".md"];
|
|
7
|
+
const SUPPORTED_MEDIA_EXTENSIONS = [
|
|
8
|
+
"jpeg",
|
|
9
|
+
"jpg",
|
|
10
|
+
"jfif",
|
|
11
|
+
"pjpeg",
|
|
12
|
+
"pjp",
|
|
13
|
+
"png",
|
|
14
|
+
"svg",
|
|
15
|
+
"svgz",
|
|
16
|
+
"ico",
|
|
17
|
+
"webp",
|
|
18
|
+
"gif",
|
|
19
|
+
"apng",
|
|
20
|
+
"avif",
|
|
21
|
+
"bmp",
|
|
22
|
+
"mp4",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const isNotNodeModule = (filePath: ParsedPath) =>
|
|
26
|
+
!filePath.dir.startsWith(NODE_MODULES_DIRECTORY);
|
|
27
|
+
|
|
28
|
+
export const isValidPage = (filePath: ParsedPath) =>
|
|
29
|
+
SUPPORTED_PAGE_EXTENSIONS.includes(filePath.ext);
|
|
30
|
+
|
|
31
|
+
export const isValidMedia = (filePath: ParsedPath) =>
|
|
32
|
+
SUPPORTED_MEDIA_EXTENSIONS.includes(filePath.ext);
|
|
33
|
+
|
|
34
|
+
export const isValidLink = (filePath: ParsedPath) =>
|
|
35
|
+
isValidPage(filePath) || isValidMedia(filePath);
|
|
36
|
+
|
|
37
|
+
export const normalizeFilePaths = (fileList: string[]) =>
|
|
38
|
+
fileList.map(path.normalize).map(path.parse);
|
|
39
|
+
|
|
40
|
+
export const filterNodeModules = (fileList: string[]) =>
|
|
41
|
+
normalizeFilePaths(fileList).filter(isNotNodeModule);
|
|
42
|
+
|
|
43
|
+
export const filterPages = (fileList: string[]) =>
|
|
44
|
+
filterNodeModules(fileList).filter(isValidPage);
|
|
45
|
+
|
|
46
|
+
export const filterMedia = (fileList: string[]) =>
|
|
47
|
+
filterNodeModules(fileList).filter(isValidMedia);
|
|
48
|
+
|
|
49
|
+
export const filterLinks = (fileList: string[]) =>
|
|
50
|
+
filterNodeModules(fileList).filter(isValidLink);
|
|
51
|
+
|
|
52
|
+
export const getFullPath = (filePath: ParsedPath) =>
|
|
53
|
+
path.join(filePath.dir, filePath.name + filePath.ext);
|
|
54
|
+
|
|
55
|
+
export const removeFileExtension = (filePath: ParsedPath) =>
|
|
56
|
+
path.join(filePath.dir, filePath.name);
|
|
57
|
+
|
|
58
|
+
export const removeLeadingSlash = (filePathString: string) => {
|
|
59
|
+
const hasLeadingSlash = filePathString.startsWith("/");
|
|
60
|
+
return hasLeadingSlash ? filePathString.slice(1) : filePathString;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const getPagePaths = (dirName: string) =>
|
|
64
|
+
filterPages(getFileList(dirName)).map(getFullPath).map(removeLeadingSlash);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getValidInternalLinks } from "./getValidInternalLinks.js";
|
|
2
|
+
import { getUsedInternalLinksInSite } from "./getUsedInternalLinks.js";
|
|
3
|
+
import Chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
export const getBrokenInternalLinks = async (dirName: string, print = true) => {
|
|
6
|
+
const usedLinksPerFile = await getUsedInternalLinksInSite(dirName);
|
|
7
|
+
const validLinks = getValidInternalLinks(dirName);
|
|
8
|
+
const allBrokenLinks: string[] = [];
|
|
9
|
+
Object.entries(usedLinksPerFile).forEach(([fileName, usedLinks]) => {
|
|
10
|
+
const brokenLinks = usedLinks.filter((link) => !validLinks.has(link));
|
|
11
|
+
if (brokenLinks.length === 0) return;
|
|
12
|
+
allBrokenLinks.push(...brokenLinks);
|
|
13
|
+
if (!print) return;
|
|
14
|
+
console.group(`${Chalk.yellow(`Broken links in ${fileName}`)}:`);
|
|
15
|
+
console.log(brokenLinks.join("\n"));
|
|
16
|
+
console.groupEnd();
|
|
17
|
+
});
|
|
18
|
+
return allBrokenLinks;
|
|
19
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import isAbsoluteUrl from "is-absolute-url";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import type { Root } from "mdast";
|
|
4
|
+
import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx";
|
|
5
|
+
import { remark } from "remark";
|
|
6
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
7
|
+
import remarkGfm from "remark-gfm";
|
|
8
|
+
import remarkMdx from "remark-mdx";
|
|
9
|
+
import { visit } from "unist-util-visit";
|
|
10
|
+
import {
|
|
11
|
+
getFullPath,
|
|
12
|
+
getPagePaths,
|
|
13
|
+
normalizeFilePaths,
|
|
14
|
+
removeLeadingSlash,
|
|
15
|
+
} from "../prebuild.js";
|
|
16
|
+
import path from "path";
|
|
17
|
+
|
|
18
|
+
const isDataString = (str: string) => str.startsWith("data:");
|
|
19
|
+
|
|
20
|
+
export const getUsedInternalLinksInPage = async (content: string) => {
|
|
21
|
+
const links: string[] = [];
|
|
22
|
+
const visitLinks = () => {
|
|
23
|
+
return (tree: Root) => {
|
|
24
|
+
visit(tree, (node) => {
|
|
25
|
+
if (
|
|
26
|
+
// ![]() format
|
|
27
|
+
(node.type === "link" || node.type === "image") &&
|
|
28
|
+
node.url &&
|
|
29
|
+
!isAbsoluteUrl(node.url) &&
|
|
30
|
+
!isDataString(node.url)
|
|
31
|
+
) {
|
|
32
|
+
links.push(node.url);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const mdxJsxFlowElement = node as MdxJsxFlowElement;
|
|
36
|
+
if (
|
|
37
|
+
mdxJsxFlowElement.name === "img" ||
|
|
38
|
+
mdxJsxFlowElement.name === "source"
|
|
39
|
+
) {
|
|
40
|
+
const srcAttrIndex = mdxJsxFlowElement.attributes.findIndex(
|
|
41
|
+
(attr) => attr.type === "mdxJsxAttribute" && attr.name === "src"
|
|
42
|
+
);
|
|
43
|
+
const nodeUrl = mdxJsxFlowElement.attributes[srcAttrIndex].value;
|
|
44
|
+
if (
|
|
45
|
+
srcAttrIndex !== -1 &&
|
|
46
|
+
typeof nodeUrl === "string" &&
|
|
47
|
+
!isAbsoluteUrl(nodeUrl) &&
|
|
48
|
+
!isDataString(nodeUrl)
|
|
49
|
+
) {
|
|
50
|
+
links.push(nodeUrl);
|
|
51
|
+
}
|
|
52
|
+
} else if (mdxJsxFlowElement.name === "a") {
|
|
53
|
+
const hrefAttrIndex = mdxJsxFlowElement.attributes.findIndex(
|
|
54
|
+
(attr) => attr.type === "mdxJsxAttribute" && attr.name === "href"
|
|
55
|
+
);
|
|
56
|
+
const nodeUrl = mdxJsxFlowElement.attributes[hrefAttrIndex].value;
|
|
57
|
+
if (
|
|
58
|
+
hrefAttrIndex !== -1 &&
|
|
59
|
+
typeof nodeUrl === "string" &&
|
|
60
|
+
!isAbsoluteUrl(nodeUrl) &&
|
|
61
|
+
!isDataString(nodeUrl)
|
|
62
|
+
) {
|
|
63
|
+
links.push(nodeUrl);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return tree;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
await remark()
|
|
71
|
+
.use(remarkMdx)
|
|
72
|
+
.use(remarkGfm)
|
|
73
|
+
.use(remarkFrontmatter, ["yaml", "toml"])
|
|
74
|
+
.use(visitLinks)
|
|
75
|
+
.process(content);
|
|
76
|
+
return normalizeFilePaths(links).map(getFullPath).map(removeLeadingSlash);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const getUsedInternalLinksInSite = async (dirName: string) => {
|
|
80
|
+
const dirChildren = getPagePaths(dirName);
|
|
81
|
+
const getLinksPromises: Promise<void>[] = [];
|
|
82
|
+
const usedLinksInSite: Record<string, string[]> = {};
|
|
83
|
+
dirChildren.forEach((filePath) => {
|
|
84
|
+
getLinksPromises.push(
|
|
85
|
+
(async () => {
|
|
86
|
+
const fileContent = fs
|
|
87
|
+
.readFileSync(path.join(dirName, filePath))
|
|
88
|
+
.toString();
|
|
89
|
+
const usedLinksInPage = await getUsedInternalLinksInPage(fileContent);
|
|
90
|
+
usedLinksInSite[filePath] = usedLinksInPage;
|
|
91
|
+
})()
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
await Promise.all(getLinksPromises);
|
|
95
|
+
return usedLinksInSite;
|
|
96
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getFileList } from "@mintlify/prebuild";
|
|
2
|
+
import {
|
|
3
|
+
filterLinks,
|
|
4
|
+
removeFileExtension,
|
|
5
|
+
removeLeadingSlash,
|
|
6
|
+
} from "../prebuild.js";
|
|
7
|
+
|
|
8
|
+
export const getValidInternalLinks = (dirName: string) =>
|
|
9
|
+
new Set(
|
|
10
|
+
filterLinks(getFileList(dirName))
|
|
11
|
+
.map(removeFileExtension)
|
|
12
|
+
.map(removeLeadingSlash)
|
|
13
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export {
|
|
2
|
+
getUsedInternalLinksInPage,
|
|
3
|
+
getUsedInternalLinksInSite,
|
|
4
|
+
} from "./getUsedInternalLinks";
|
|
5
|
+
export { getValidInternalLinks } from "./getValidInternalLinks";
|
|
6
|
+
|
|
7
|
+
import { getBrokenInternalLinks } from "./getBrokenInternalLinks";
|
|
8
|
+
export default getBrokenInternalLinks;
|