282 lines
7.8 KiB
TypeScript
282 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 !== '/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)}`] &&
|
|
!validInternalLinksMap[`angular/${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);
|
|
}
|