refactor(tooling): allow markdownlint to handle multiple files (#66771)

This commit is contained in:
Oliver Eyton-Williams
2026-04-08 17:15:06 +02:00
committed by GitHub
parent 448d320d21
commit d69f24b31b
8 changed files with 335 additions and 736 deletions
+12 -11
View File
@@ -42,7 +42,7 @@
"delete-challenge": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/delete-challenge",
"delete-task": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/delete-task",
"lint": "eslint --max-warnings 0 && pnpm lint-challenges",
"lint-challenges": "NODE_OPTIONS='--max-old-space-size=7168' tsx src/lint-localized",
"lint-challenges": "tsx src/lint-localized",
"reorder-tasks": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/reorder-tasks",
"update-challenge-order": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/update-challenge-order",
"update-step-titles": "tsx --tsconfig ../tools/challenge-helper-scripts/tsconfig.json ../tools/challenge-helper-scripts/update-step-titles",
@@ -61,14 +61,15 @@
"@freecodecamp/challenge-linter": "workspace:*",
"@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*",
"@total-typescript/ts-reset": "^0.6.1",
"@types/debug": "^4.1.12",
"@total-typescript/ts-reset": "0.6.1",
"@types/debug": "4.1.12",
"@types/js-yaml": "4.0.5",
"@types/polka": "^0.5.7",
"@types/string-similarity": "^4.0.2",
"@types/polka": "0.5.7",
"@types/string-similarity": "4.0.2",
"@typescript/vfs-1.6.1": "npm:@typescript/vfs@1.6.1",
"@vitest/ui": "^4.0.15",
"eslint": "^9.39.1",
"@vitest/ui": "4.0.15",
"eslint": "9.39.1",
"glob": "13.0.6",
"joi": "17.12.2",
"joi-objectid": "3.0.1",
"js-yaml": "4.0.0",
@@ -77,14 +78,14 @@
"mocha": "10.3.0",
"mock-require": "3.0.3",
"ora": "5.4.1",
"polka": "^0.5.2",
"polka": "0.5.2",
"puppeteer": "22.12.1",
"sirv": "^3.0.2",
"sirv": "3.0.2",
"string-similarity": "4.0.4",
"typescript-5.9.2": "npm:typescript@5.9.2",
"vitest": "^4.0.15"
"vitest": "4.0.15"
},
"dependencies": {
"@types/node": "^24.10.8"
"@types/node": "24.10.8"
}
}
+19 -3
View File
@@ -1,8 +1,24 @@
import path from 'node:path';
import { configure } from '@freecodecamp/challenge-linter';
import { globSync } from 'glob';
import { configure, processLintErrors } from '@freecodecamp/challenge-linter';
import { CURRICULUM_LOCALE } from './config';
const CONFIG_PATH = path.resolve(__dirname, '../challenges/.markdownlint.yaml');
const { lintAll } = configure(CONFIG_PATH);
const { lint } = configure(CONFIG_PATH);
lintAll(`challenges/${CURRICULUM_LOCALE}/**/*.md`);
const files = globSync(`challenges/${CURRICULUM_LOCALE}/**/*.md`);
const runLint = async () => {
const results = await lint(files);
const errors = processLintErrors(results);
if (errors.length > 0) {
errors.forEach(({ file, errors: fileErrors }) => {
console.log('Errors in file', file);
console.log(fileErrors);
});
process.exit(1);
}
};
void runLint();
+5 -7
View File
@@ -31,16 +31,14 @@
},
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@types/glob": "^8.1.0",
"@types/js-yaml": "4.0.5",
"@types/yargs": "^17.0.35",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"glob": "^8.1.0",
"@types/yargs": "17.0.35",
"@vitest/ui": "3.2.4",
"eslint": "9.39.1",
"markdownlint": "0.33.0",
"prismjs": "1.29.0",
"typescript": "5.9.3",
"vitest": "^3.2.4",
"yargs": "^17.7.2"
"vitest": "3.2.4",
"yargs": "17.7.2"
}
}
+16 -5
View File
@@ -2,14 +2,14 @@
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { configure } from './index.js';
import { configure, processLintErrors } from './index.js';
const argv = yargs(hideBin(process.argv))
.options({ config: { type: 'string' } })
.parseSync();
const configPath = argv.config;
const files = argv._;
const files = argv._ as string[];
if (!configPath) {
console.error(
@@ -25,6 +25,17 @@ if (files.length === 0) {
const { lint } = configure(configPath);
files.forEach(filePath => {
lint({ path: filePath });
});
const runLint = async () => {
const results = await lint(files);
const errors = processLintErrors(results);
if (errors.length > 0) {
errors.forEach(({ file, errors: fileErrors }) => {
console.log('Errors in file', file);
console.log(fileErrors);
});
process.exit(1);
}
};
void runLint();
+14 -9
View File
@@ -1,18 +1,23 @@
import { readFileSync } from 'node:fs';
import YAML from 'js-yaml';
import glob from 'glob';
import { linter } from './linter/index.js';
export const configure = (configPath: string) => {
interface LintResults {
[key: string]: unknown[];
}
const configure = (configPath: string) => {
const lintRules = readFileSync(configPath, 'utf8');
const lint = linter(YAML.load(lintRules));
const lintAll = (pattern: string) => {
glob(pattern, (err, files) => {
if (!files.length) throw Error('No files found');
files.forEach(file => lint({ path: file }));
});
};
return { lint, lintAll };
return { lint };
};
const processLintErrors = (results: LintResults) => {
return Object.entries(results)
.map(([file, errors]) => ({ file, errors }))
.filter(({ errors }) => errors.length > 0);
};
export { configure, processLintErrors };
+29 -33
View File
@@ -1,60 +1,56 @@
import path from 'path';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { configure } from './index.js';
import { configure, processLintErrors } from './index.js';
const badYMLError = {
errorContext: '```yml',
errorDetail: `bad indentation of a mapping entry at line 3, column 17:
testString: testString
^`,
errorRange: null,
fixInfo: null,
lineNumber: 19,
ruleDescription: 'YAML code blocks should be valid',
ruleInformation: null,
ruleNames: ['yaml-linter']
};
describe('markdown linter', () => {
const good = { path: path.join(__dirname, './fixtures/good.md') };
const badYML = { path: path.join(__dirname, './fixtures/badYML.md') };
const badFencing = { path: path.join(__dirname, './fixtures/badFencing.md') };
const good = path.join(__dirname, './fixtures/good.md');
const badYML = path.join(__dirname, './fixtures/badYML.md');
const badFencing = path.join(__dirname, './fixtures/badFencing.md');
const configPath = path.join(__dirname, './fixtures/rules.yaml');
let lint;
beforeAll(() => {
lint = configure(configPath).lint;
({ lint } = configure(configPath));
});
beforeEach(() => {
console.log = vi.fn();
// the linter signals that a file failed by setting
// exitCode to 1, so it needs (re)setting to 0
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = 0;
});
it('should pass `good` markdown', async () => {
await new Promise(resolve => lint(good, resolve));
expect(process.exitCode).toBe(0);
const result = await lint([good]);
expect(result[good]).toHaveLength(0);
});
it('should fail invalid YML blocks', async () => {
await new Promise(resolve => lint(badYML, resolve));
expect(process.exitCode).toBe(1);
const result = await lint([badYML]);
expect(result[badYML]).not.toHaveLength(0);
});
it('should fail when code fences are not surrounded by newlines', async () => {
await new Promise(resolve => lint(badFencing, resolve));
expect(process.exitCode).toBe(1);
const result = await lint([badFencing]);
expect(result[badFencing]).not.toHaveLength(0);
});
it('should write to the console describing the problem', async () => {
await new Promise(resolve => lint(badYML, resolve));
const results = await lint([badYML]);
const errors = processLintErrors(results);
const expected =
'badYML.md: 19: yaml-linter YAML code blocks should be valid [bad indentation of a mapping entry at line 3, column 17:\n testString: testString\n ^] [Context: "```yml"]';
expect(console.log.mock.calls.length).toBe(1);
expect(console.log.mock.calls[0][0]).toEqual(
expect.stringContaining(expected)
);
expect(errors[0].file).toContain('badYML.md');
expect(errors[0].errors).toContainEqual(badYMLError);
});
});
+3 -14
View File
@@ -5,25 +5,14 @@ import * as lintYAML from './markdown-yaml.js';
import * as fencedCodeBlock from './fenced-code-block.js';
export function linter(rules) {
const lint = (file, next) => {
const lint = async files => {
const options = {
files: [file.path],
files,
config: rules,
customRules: [lintYAML, lintPrism, fencedCodeBlock]
};
markdownlint(options, function callback(err, result) {
const resultString = (result || '').toString();
if (resultString) {
process.exitCode = 1;
console.log(resultString);
}
if (err) {
process.exitCode = 1;
console.error(err);
}
if (next) next(err, file);
});
return await markdownlint.promises.markdownlint(options);
};
return lint;
}
+237 -654
View File
File diff suppressed because it is too large Load Diff