nx/scripts/documentation/internal-link-checker.ts
Phillip Barta c0ce1ce65e
cleanup(core): normalized usage of fs-extra and updated fs-extra to 9.1.0 (#5199)
* cleanup(core): normalized usage of fs-extra and updated fs-extra

* cleanup(misc): use fs over fs-extra when possible

Co-authored-by: Jason Jean <jasonjean1993@gmail.com>
2021-04-22 12:52:52 -04:00

280 lines
7.8 KiB
TypeScript

import * as chalk from 'chalk';
import { readFileSync } from 'fs';
import { readJsonSync } from 'fs-extra';
import * as parseLinks from 'parse-markdown-links';
import * as glob from 'glob';
import { join } from 'path';
console.log(`${chalk.blue('i')} Internal Link Check`);
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 {
return readFileSync(path, 'utf-8');
}
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 glob.sync(`${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)
// `/latest/{{framework}}/...` are valid links too but we need to strip the version
.map((x) => x.replace(/^\/latest/, ''))
// `/{{ version }}/...` are valid links as well
.map((x) => x.replace(/^\/{{version}}/, ''))
.map(removeAnchors);
if (links.length) {
acc[path] = expandFrameworks(links);
}
return acc;
}, {});
}
function extractAllInternalLinksWithAnchors(): Record<string, string[]> {
return glob.sync(`${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 readJsonSync(join(BASE_PATH, 'map.json'));
}
interface DocumentPaths {
relativeUrl: string;
relativeFilePath: string;
anchors: Record<string, boolean>;
}
function determineAnchors(filePath: string): string[] {
const fullPath = join(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 {
const fullPath = join(join(...ids), treeNode.id);
acc[/*treeNode.file ||*/ fullPath] = {
relativeUrl: fullPath,
relativeFilePath: treeNode.file || fullPath,
anchors: determineAnchors(`${treeNode.file || fullPath}.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(`${chalk.green('🗸')} All internal links appear to be valid!`);
const erroneousAnchoredInternalLinks = checkInternalAnchoredLinks(
validInternalLinksMap
);
if (!erroneousAnchoredInternalLinks) {
console.log(
`${chalk.green('🗸')} All internal anchored links appear to be valid!`
);
process.exit(0);
} else {
console.log(`${chalk.red(
'ERROR'
)} The following files appear to contain the following invalid anchored internal links:
${JSON.stringify(erroneousAnchoredInternalLinks, null, 2)}`);
process.exit(1);
}
} else {
console.log(`${chalk.red(
'ERROR'
)} The following files appear to contain the following invalid internal links:
${JSON.stringify(erroneousInternalLinks, null, 2)}`);
process.exit(1);
}