Skip to content

Commit

Permalink
Merge pull request #1236 from Agoric/mk/deadlink-checker
Browse files Browse the repository at this point in the history
chore: add a deadlink checker for nav/sidebar items
  • Loading branch information
mujahidkay authored Dec 27, 2024
2 parents a67337b + 26875df commit e523760
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 48 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
"type": "module",
"scripts": {
"docs:dev": "NODE_OPTIONS=--openssl-legacy-provider vitepress dev main",
"docs:build": "NODE_OPTIONS=--openssl-legacy-provider vitepress build main",
"docs:build": "yarn lint:check-links && NODE_OPTIONS=--openssl-legacy-provider vitepress build main",
"docs:preview": "NODE_OPTIONS=--openssl-legacy-provider vitepress preview main",
"docs:build-cf": "DEBUG='vitepress:*' NODE_OPTIONS=--openssl-legacy-provider vitepress build main && cp _redirects dist/",
"docs:build-cf": "yarn lint:check-links && DEBUG='vitepress:*' NODE_OPTIONS=--openssl-legacy-provider vitepress build main && cp _redirects dist/",
"test": "ava",
"lint-fix": "yarn lint --fix",
"lint": "eslint 'snippets/**/*.js'",
"format": "node scripts/markdown-js-snippets-linter.mjs 'main/**/*.md' --fix && prettier --write '**/*.md' --config .prettierrc.json",
"lint:format": "node scripts/format.mjs",
"lint:check-links": "node scripts/checkLinks.mjs",
"build": "exit 0"
},
"packageManager": "[email protected]",
Expand Down
72 changes: 72 additions & 0 deletions scripts/checkLinks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const extractLinks = content => {
const noSingleLineComments = content.replace(/\/\/.*$/gm, '');
const noComments = noSingleLineComments.replace(/\/\*[\s\S]*?\*\//g, '');
const linkRegex = /link:\s*(['"])([^'"]+)\1/g;
const links = [];
let match;
while ((match = linkRegex.exec(noComments)) !== null) {
links.push(match[2]);
}
return links;
};

const fileExists = filePath => {
try {
return fs.existsSync(filePath);
} catch (err) {
return false;
}
};

const checkLink = link => {
if (link.startsWith('http')) {
return true;
}

const basePath = path.join(__dirname, '../main');
const cleanLink = link.replace(/^\//, '').replace(/\/$/, '');

// Check for index.md in directory
const indexPath = path.join(basePath, cleanLink, 'index.md');
if (fileExists(indexPath)) {
return true;
}

// Check for .md file
const mdPath = path.join(basePath, `${cleanLink}.md`);
if (fileExists(mdPath)) {
return true;
}

return false;
};

const navContent = fs.readFileSync(
path.join(__dirname, '../main/.vitepress/themeConfig/nav.js'),
'utf8',
);
const configContent = fs.readFileSync(
path.join(__dirname, '../main/.vitepress/config.mjs'),
'utf8',
);

const navLinks = extractLinks(navContent);
const configLinks = extractLinks(configContent);
const allLinks = [...new Set([...navLinks, ...configLinks])];

const deadLinks = allLinks.filter(link => !checkLink(link));

if (deadLinks.length > 0) {
console.error('Dead links found:');
deadLinks.forEach(link => console.error(link));
process.exit(1);
} else {
console.log('All links are valid.');
}
23 changes: 13 additions & 10 deletions scripts/format.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { exec } from 'child_process';

exec('node scripts/markdown-js-snippets-linter.mjs "main/**/*.md" && prettier --check "**/*.md" --config .prettierrc.json', (err, stdout, stderr) => {
if (err) {
const modifiedStderr = stderr.replace(
'Run Prettier with --write to fix',
'Run `yarn format` to fix'
);
console.warn(modifiedStderr);
process.exit(1);
}
});
exec(
'node scripts/markdown-js-snippets-linter.mjs "main/**/*.md" && prettier --check "**/*.md" --config .prettierrc.json',
(err, stdout, stderr) => {
if (err) {
const modifiedStderr = stderr.replace(
'Run Prettier with --write to fix',
'Run `yarn format` to fix',
);
console.warn(modifiedStderr);
process.exit(1);
}
},
);
105 changes: 69 additions & 36 deletions scripts/markdown-js-snippets-linter.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {promises as fs} from 'fs';
import { promises as fs } from 'fs';
import glob from 'glob';
import util from 'util';

const globPromise = util.promisify(glob);

const extractJsSnippets = (markdownContent) => {
const extractJsSnippets = markdownContent => {
const pattern = /```(?:js|javascript)\n([\s\S]*?)```/g;
const matches = [];
let match;
Expand All @@ -15,7 +15,7 @@ const extractJsSnippets = (markdownContent) => {
start: match.index,
end: match.index + match[0].length,
startLine: startLine,
language: match[0].startsWith('```javascript') ? 'javascript' : 'js'
language: match[0].startsWith('```javascript') ? 'javascript' : 'js',
});
}
return matches;
Expand All @@ -29,17 +29,22 @@ const checkSemicolonsAndEllipsis = (code, startLine) => {
let openSquareBrackets = 0;
let inMultiLineComment = false;

const isJSDocOrComment = (line) => {
return line.trim().startsWith('*') ||
const isJSDocOrComment = line => {
return (
line.trim().startsWith('*') ||
line.trim().startsWith('/**') ||
line.trim().startsWith('*/') ||
line.trim().startsWith('//');
line.trim().startsWith('//')
);
};

const isStatementEnd = (line, nextLine) => {
const strippedLine = line.replace(/\/\/.*$/, '').trim();
const strippedNextLine = nextLine ? nextLine.replace(/\/\/.*$/, '').trim() : '';
return strippedLine &&
const strippedNextLine = nextLine
? nextLine.replace(/\/\/.*$/, '').trim()
: '';
return (
strippedLine &&
!strippedLine.endsWith('{') &&
!strippedLine.endsWith('}') &&
!strippedLine.endsWith(':') &&
Expand All @@ -61,19 +66,25 @@ const checkSemicolonsAndEllipsis = (code, startLine) => {
!strippedNextLine.trim().startsWith('.finally') &&
openBrackets === 0 &&
openParens === 0 &&
openSquareBrackets === 0;
openSquareBrackets === 0
);
};

const shouldHaveSemicolon = (line, nextLine) => {
const strippedLine = line.replace(/\/\/.*$/, '').trim();
return (strippedLine.startsWith('const ') ||
strippedLine.startsWith('let ') ||
strippedLine.startsWith('var ') ||
strippedLine.includes('=') ||
/\bawait\b/.test(strippedLine) ||
(/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(strippedLine) && !strippedLine.endsWith('.')) ||
(/^[a-zA-Z_$][a-zA-Z0-9_$]*\[[0-9]+\]$/.test(strippedLine))) &&
isStatementEnd(line, nextLine);
return (
(strippedLine.startsWith('const ') ||
strippedLine.startsWith('let ') ||
strippedLine.startsWith('var ') ||
strippedLine.includes('=') ||
/\bawait\b/.test(strippedLine) ||
(/^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(
strippedLine,
) &&
!strippedLine.endsWith('.')) ||
/^[a-zA-Z_$][a-zA-Z0-9_$]*\[[0-9]+\]$/.test(strippedLine)) &&
isStatementEnd(line, nextLine)
);
};

for (let i = 0; i < lines.length; i++) {
Expand All @@ -87,9 +98,12 @@ const checkSemicolonsAndEllipsis = (code, startLine) => {
}
if (inMultiLineComment || isJSDocOrComment(line)) continue;

openBrackets += (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
openParens += (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length;
openSquareBrackets += (line.match(/\[/g) || []).length - (line.match(/\]/g) || []).length;
openBrackets +=
(line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
openParens +=
(line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length;
openSquareBrackets +=
(line.match(/\[/g) || []).length - (line.match(/\]/g) || []).length;

const codeWithoutComment = line.replace(/\/\/.*$/, '').trim();

Expand All @@ -99,22 +113,24 @@ const checkSemicolonsAndEllipsis = (code, startLine) => {
line: startLine + i,
original: line.trim(),
fixed: '// ...',
type: 'ellipsis'
type: 'ellipsis',
});
} else if (shouldHaveSemicolon(line, nextLine) && !codeWithoutComment.endsWith(';')) {
} else if (
shouldHaveSemicolon(line, nextLine) &&
!codeWithoutComment.endsWith(';')
) {
issues.push({
line: startLine + i,
original: line.trim(),
fixed: `${codeWithoutComment};${line.includes('//') ? ' ' + line.split('//')[1] : ''}`,
type: 'semicolon'
type: 'semicolon',
});
}
}

return issues;
};


const lintMarkdownFile = async (filePath, fix = false) => {
try {
const content = await fs.readFile(filePath, 'utf8');
Expand All @@ -124,7 +140,10 @@ const lintMarkdownFile = async (filePath, fix = false) => {

for (let i = jsSnippets.length - 1; i >= 0; i--) {
const snippet = jsSnippets[i];
const issues = checkSemicolonsAndEllipsis(snippet.content, snippet.startLine);
const issues = checkSemicolonsAndEllipsis(
snippet.content,
snippet.startLine,
);
allIssues.push(...issues.map(issue => ({ ...issue, snippet: i + 1 })));

if (fix) {
Expand All @@ -134,8 +153,11 @@ const lintMarkdownFile = async (filePath, fix = false) => {
fixedLines[lineIndex] = issue.fixed;
});
const fixedSnippet = fixedLines.join('\n');
fixedContent = fixedContent.slice(0, snippet.start) +
'```js\n' + fixedSnippet + '```' +
fixedContent =
fixedContent.slice(0, snippet.start) +
'```js\n' +
fixedSnippet +
'```' +
fixedContent.slice(snippet.end);
}
}
Expand All @@ -156,7 +178,7 @@ const lintMarkdownFile = async (filePath, fix = false) => {
filePath,
issues: allIssues,
fixedContent: fix ? fixedContent : null,
javascriptCount: javascriptCount
javascriptCount: javascriptCount,
};
} catch (error) {
console.error(`Error processing file ${filePath}: ${error.message}`);
Expand All @@ -176,7 +198,10 @@ const processFiles = async (globPattern, fix = false) => {
let hasErrors = false;

for (const file of files) {
const { issues, error, javascriptCount } = await lintMarkdownFile(file, fix);
const { issues, error, javascriptCount } = await lintMarkdownFile(
file,
fix,
);
if (error) {
console.error(`\nError in file ${file}:`);
console.error(error);
Expand All @@ -187,13 +212,17 @@ const processFiles = async (globPattern, fix = false) => {
issues.forEach(issue => {
console.error(`\nSnippet ${issue.snippet}, Line ${issue.line}:`);
console.error(`Original: ${issue.original}`);
console.error(`${fix ? 'Fixed: ' : 'Suggested:'} ${issue.fixed}`);
console.error(
`${fix ? 'Fixed: ' : 'Suggested:'} ${issue.fixed}`,
);
});
totalIssues += issues.length;
hasErrors = true;
}
if (javascriptCount > 0) {
console.error(`\nFound ${javascriptCount} instance(s) of \`\`\`javascript in ${file}`);
console.error(
`\nFound ${javascriptCount} instance(s) of \`\`\`javascript in ${file}`,
);
totalJavascriptInstances += javascriptCount;
hasErrors = true;
}
Expand All @@ -202,14 +231,18 @@ const processFiles = async (globPattern, fix = false) => {

if (totalIssues > 0 || totalJavascriptInstances > 0) {
console.error(`\nTotal errors found: ${totalIssues}`);
console.error(`Total \`\`\`javascript instances found: ${totalJavascriptInstances}`);
console.error(
`Total \`\`\`javascript instances found: ${totalJavascriptInstances}`,
);
if (fix) {
console.log("All matching files have been updated with the necessary changes.");
console.log(
'All matching files have been updated with the necessary changes.',
);
} else {
console.error("Run `yarn format` to automatically fix these errors");
console.error('Run `yarn format` to automatically fix these errors');
}
} else {
console.log("No errors found in any of the matching files.");
console.log('No errors found in any of the matching files.');
}

if (hasErrors && !fix) {
Expand All @@ -223,7 +256,7 @@ const processFiles = async (globPattern, fix = false) => {

const main = async () => {
if (process.argv.length < 3 || process.argv.length > 4) {
console.error("Usage: node linter.js <glob_pattern> [--fix]");
console.error('Usage: node linter.js <glob_pattern> [--fix]');
process.exit(1);
}

Expand Down

0 comments on commit e523760

Please sign in to comment.