@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 ADDED
@@ -0,0 +1 @@
1
+ dist/
package/.eslintrc.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@mintlify/eslint-config-typescript"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/link-rot",
3
- "version": "3.0.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
- "main": "./dist/index.js",
30
- "types": "./dist/index.d.ts",
31
- "files": [
32
- "dist"
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,3 @@
1
+ import { renameFileAndUpdateLinksInContent } from "./renameFileAndUpdateLinksInContent.js";
2
+
3
+ export default renameFileAndUpdateLinksInContent;
@@ -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;
@@ -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;
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@mintlify/ts-config",
3
+ "compilerOptions": {
4
+ "module": "ES2022",
5
+ "moduleResolution": "node",
6
+ "target": "es2022",
7
+ "outDir": "dist",
8
+ "declaration": true
9
+ },
10
+ "include": ["src/**/*.ts"]
11
+ }