nx/scripts/documentation/internal-link-checker.ts
Zachary DeRose d500401562
Adding internal link script to pre commit hook and checking anchors (#4846)
* chore(repo): add internal link checker to pre-push hook

* chore(repo): add anchor checks and logging to internal link checker
2021-03-29 19:36:07 -04:00

274 lines
7.6 KiB
TypeScript

import { green, red } from 'chalk';
import * as shell from 'shelljs';
import * as fs from 'fs';
import * as parseLinks from 'parse-markdown-links';
const LOGGING_KEYS = [
'LOG_DOC_TREE',
'LOG_ANCHORED_LINKS',
'LOG_INTERNAL_LINKS',
] as const;
type LoggingKey = typeof LOGGING_KEYS[number];
function replaceAll(
target: string,
toReplace: string,
toReplaceWith: string
): string {
let temp = target;
while (temp.includes(toReplace)) {
temp = temp.replace(toReplace, toReplaceWith);
}
return temp;
}
function log(environmentVariableName: LoggingKey, toLog: any) {
if (process.env[environmentVariableName]) {
console.log(toLog);
}
}
const BASE_PATH = 'docs';
const FRAMEWORK_SYMBOL = '{{framework}}';
const DIRECT_INTERNAL_LINK_SYMBOL = 'https://nx.dev';
function readFileContents(path: string): string {
const buffer = fs.readFileSync(path, { encoding: 'utf-8' });
return buffer.toString();
}
function isLinkInternal(linkPath: string): boolean {
return linkPath.startsWith('/');
}
function isNotAsset(linkPath: string): boolean {
return !linkPath.startsWith('/assets');
}
function isNotImage(linkPath: string): boolean {
return !linkPath.endsWith('.png');
}
function isNotNxCommunityLink(linkPath: string): boolean {
return linkPath !== '/nx-community';
}
function removeAnchors(linkPath: string): string {
return linkPath.split('#')[0];
}
function containsAnchor(linkPath: string): boolean {
return linkPath.includes('#');
}
function expandFrameworks(linkPaths: string[]): string[] {
return linkPaths.reduce((acc, link) => {
if (link.includes(FRAMEWORK_SYMBOL)) {
acc.push(link.replace(FRAMEWORK_SYMBOL, 'angular'));
acc.push(link.replace(FRAMEWORK_SYMBOL, 'react'));
acc.push(link.replace(FRAMEWORK_SYMBOL, 'node'));
} else {
acc.push(link);
}
return acc;
}, []);
}
function extractAllInternalLinks(): Record<string, string[]> {
return shell.ls(`${BASE_PATH}/**/*.md`).reduce((acc, path) => {
const fileContents = readFileContents(path);
const directLinks = fileContents
.split(/[ ,]+/)
.filter((word) => word.startsWith(DIRECT_INTERNAL_LINK_SYMBOL))
.map((word) => word.replace(DIRECT_INTERNAL_LINK_SYMBOL, ''))
.filter((x) => !!x);
const links = parseLinks(fileContents)
.concat(directLinks)
.filter(isLinkInternal)
.filter(isNotAsset)
.filter(isNotImage)
.filter(isNotNxCommunityLink)
.map(removeAnchors);
if (links.length) {
acc[path] = expandFrameworks(links);
}
return acc;
}, {});
}
function extractAllInternalLinksWithAnchors(): Record<string, string[]> {
return shell.ls(`${BASE_PATH}/**/*.md`).reduce((acc, path) => {
const links = parseLinks(readFileContents(path))
.filter(isLinkInternal)
.filter(isNotAsset)
.filter(isNotImage)
.filter(containsAnchor);
if (links.length) {
acc[path] = expandFrameworks(links);
}
return acc;
}, {});
}
interface DocumentTreeFileNode {
name: string;
id: string;
file?: string;
}
interface DocumentTreeCategoryNode {
name?: string;
id: string;
itemList: DocumentTree[];
}
type DocumentTree = DocumentTreeFileNode | DocumentTreeCategoryNode;
function isCategoryNode(
documentTree: DocumentTree
): documentTree is DocumentTreeCategoryNode {
return !!(documentTree as DocumentTreeCategoryNode).itemList;
}
function getDocumentMap(): DocumentTree[] {
return JSON.parse(
fs.readFileSync(`${BASE_PATH}/map.json`, { encoding: 'utf-8' })
) as DocumentTree[];
}
interface DocumentPaths {
relativeUrl: string;
relativeFilePath: string;
anchors: Record<string, boolean>;
}
function determineAnchors(filePath: string): string[] {
const fullPath = `${BASE_PATH}/${filePath}`;
const contents = readFileContents(fullPath).split('\n');
const anchors = contents
.filter((x) => x.startsWith('##'))
.map((anchorLine) =>
replaceAll(anchorLine, '#', '')
.toLowerCase()
.replace(/[^\w]+/g, '-')
.replace('-', '')
);
return anchors;
}
function buildMapOfExisitingDocumentPaths(
tree: DocumentTree[],
map: Record<string, DocumentPaths> = {},
ids: string[] = []
): Record<string, DocumentPaths> {
return tree.reduce((acc, treeNode) => {
if (isCategoryNode(treeNode)) {
buildMapOfExisitingDocumentPaths(treeNode.itemList, acc, [
...ids,
treeNode.id,
]);
} else {
acc[/*treeNode.file ||*/ `${ids.join('/')}/${treeNode.id}`] = {
relativeUrl: `${ids.join('/')}/${treeNode.id}`,
relativeFilePath: treeNode.file || `${ids.join('/')}/${treeNode.id}`,
anchors: determineAnchors(
`${treeNode.file || `${ids.join('/')}/${treeNode.id}`}.md`
).reduce((acc, anchor) => {
acc[anchor] = true;
return acc;
}, {}),
};
}
return acc;
}, map);
}
function determineErroneousInternalLinks(
internalLinks: Record<string, string[]>,
validInternalLinksMap: Record<string, DocumentPaths>
): Record<string, string[]> | undefined {
let erroneousInternalLinks: Record<string, string[]> | undefined;
for (const [docPath, links] of Object.entries(internalLinks)) {
const erroneousLinks = links.filter(
(link) => !validInternalLinksMap[`${link.slice(1)}`]
);
if (erroneousLinks.length) {
if (!erroneousInternalLinks) {
erroneousInternalLinks = {};
}
erroneousInternalLinks[docPath] = erroneousLinks;
}
}
return erroneousInternalLinks;
}
const validInternalLinksMap = buildMapOfExisitingDocumentPaths(
getDocumentMap()
);
log('LOG_DOC_TREE', validInternalLinksMap);
const internalLinks = extractAllInternalLinks();
log('LOG_INTERNAL_LINKS', internalLinks);
const erroneousInternalLinks = determineErroneousInternalLinks(
internalLinks,
validInternalLinksMap
);
function checkInternalAnchoredLinks(
internalLinksMap: Record<string, DocumentPaths>
): Record<string, string[]> | undefined {
const links = extractAllInternalLinksWithAnchors();
log('LOG_ANCHORED_LINKS', links);
let erroneousInternalLinks: Record<string, string[]> | undefined;
for (const [docPath, internalLinks] of Object.entries(links)) {
for (const link of internalLinks) {
const [fileKeyWithSlash, anchorKey] = link.split('#');
const fileKey = fileKeyWithSlash.replace('/', '');
if (!internalLinksMap[fileKey]) {
throw Error(
`Shouldn't be possible. The previous step would have failed.`
);
}
if (!internalLinksMap[fileKey].anchors[anchorKey]) {
if (!erroneousInternalLinks) {
erroneousInternalLinks = {};
}
if (!erroneousInternalLinks[docPath]) {
erroneousInternalLinks[docPath] = [];
}
erroneousInternalLinks[docPath].push(link);
}
}
}
return erroneousInternalLinks;
}
if (!erroneousInternalLinks) {
console.log(green('All internal links appear to be valid!!'));
console.log('Moving on to check internal anchors...');
const erroneousAnchoredInternalLinks = checkInternalAnchoredLinks(
validInternalLinksMap
);
if (!erroneousAnchoredInternalLinks) {
console.log(green('All internal anchored links appear to be valid!!'));
process.exit(0);
} else {
console.log(
red(
'The following files appear to contain the following invalid anchored internal links:'
)
);
console.log(red(JSON.stringify(erroneousAnchoredInternalLinks, null, 2)));
process.exit(1);
}
} else {
console.log(
red(
'The following files appear to contain the following invalid internal links:'
)
);
console.log(red(JSON.stringify(erroneousInternalLinks, null, 2)));
process.exit(1);
}