mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
refactor: lint challenges as part of curriculum, not root (#65665)
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
5fa67063f5
commit
20e48dd846
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable filenames-simple/naming-convention */
|
||||
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
|
||||
|
||||
export default createLintStagedConfig(import.meta.dirname);
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import './dist/cli.js';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
...configTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node // TODO: migrate to ESM and remove globals
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@freecodecamp/challenge-linter",
|
||||
"version": "0.0.1",
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
"license": "BSD-3-Clause",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"challenge-linter": "./cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
|
||||
},
|
||||
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
|
||||
"author": "freeCodeCamp <team@freecodecamp.org>",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"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",
|
||||
"markdownlint": "0.33.0",
|
||||
"prismjs": "1.29.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
import { configure } from './index.js';
|
||||
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.options({ config: { type: 'string' } })
|
||||
.parseSync();
|
||||
|
||||
const configPath = argv.config;
|
||||
const files = argv._;
|
||||
|
||||
if (!configPath) {
|
||||
console.error(
|
||||
'Error: Configuration path is required. Use --config <path-to-config>'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('Error: At least one file path is required to lint.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { lint } = configure(configPath);
|
||||
|
||||
files.forEach(filePath => {
|
||||
lint({ path: filePath });
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: ''
|
||||
title: ''
|
||||
challengeType: 0
|
||||
videoUrl: ''
|
||||
---
|
||||
|
||||
## Description
|
||||
<section id='description'>
|
||||
</section>
|
||||
|
||||
## Instructions
|
||||
<section id='instructions'>
|
||||
</section>
|
||||
|
||||
## Tests
|
||||
<section id='tests'>
|
||||
|
||||
text
|
||||
```yml
|
||||
tests:
|
||||
- text: text
|
||||
testString: testString
|
||||
|
||||
```
|
||||
moretext
|
||||
|
||||
</section>
|
||||
|
||||
## Challenge Seed
|
||||
<section id='challengeSeed'>
|
||||
|
||||
<div id='html-seed'>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
## Solution
|
||||
<section id='solution'>
|
||||
</section>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: ''
|
||||
title: ''
|
||||
challengeType: 0
|
||||
videoUrl: ''
|
||||
---
|
||||
|
||||
## Description
|
||||
<section id='description'>
|
||||
</section>
|
||||
|
||||
## Instructions
|
||||
<section id='instructions'>
|
||||
</section>
|
||||
|
||||
## Tests
|
||||
<section id='tests'>
|
||||
|
||||
```yml
|
||||
tests:
|
||||
- text: text
|
||||
testString: testString
|
||||
|
||||
```
|
||||
|
||||
</section>
|
||||
|
||||
## Challenge Seed
|
||||
<section id='challengeSeed'>
|
||||
|
||||
<div id='html-seed'>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
## Solution
|
||||
<section id='solution'>
|
||||
</section>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: ''
|
||||
title: ''
|
||||
challengeType: 0
|
||||
videoUrl: ''
|
||||
---
|
||||
|
||||
## Description
|
||||
<section id='description'>
|
||||
</section>
|
||||
|
||||
## Instructions
|
||||
<section id='instructions'>
|
||||
</section>
|
||||
|
||||
## Tests
|
||||
<section id='tests'>
|
||||
|
||||
```yml
|
||||
tests:
|
||||
- text: text
|
||||
testString: testString
|
||||
|
||||
```
|
||||
|
||||
</section>
|
||||
|
||||
## Challenge Seed
|
||||
<section id='challengeSeed'>
|
||||
|
||||
<div id='html-seed'>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
## Solution
|
||||
<section id='solution'>
|
||||
</section>
|
||||
@@ -0,0 +1,13 @@
|
||||
# This is a copy of those in the curriculum. They don't need to be in sync.
|
||||
default: true # include all rules, with exceptions below
|
||||
MD002: false # first heading should not be a top level heading
|
||||
MD013: false # lines can be any length
|
||||
MD022: false # headings don't need surrounding by newlines
|
||||
MD024: false # no duplicate headers
|
||||
MD025: false # headings are used as markers by the parser
|
||||
MD031: true # fenced blocks do need surrounding by newlines
|
||||
MD033: false # inline html is required
|
||||
MD040: true # fenced code blocks should have a language specified
|
||||
MD034: false # allow bare-URLs
|
||||
MD036: false # TODO: **Example** is the main offender, should that be a heading?
|
||||
whitespace: false # extra whitespace is ignored, so we don't enforce it.
|
||||
@@ -0,0 +1,18 @@
|
||||
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) => {
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { configure } from './index.js';
|
||||
|
||||
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 configPath = path.join(__dirname, './fixtures/rules.yaml');
|
||||
let lint;
|
||||
|
||||
beforeAll(() => {
|
||||
lint = configure(configPath).lint;
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it('should fail invalid YML blocks', async () => {
|
||||
await new Promise(resolve => lint(badYML, resolve));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should fail when code fences are not surrounded by newlines', async () => {
|
||||
await new Promise(resolve => lint(badFencing, resolve));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should write to the console describing the problem', async () => {
|
||||
await new Promise(resolve => lint(badYML, resolve));
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export const names = ['closed-code-blocks'];
|
||||
export const description = 'Code blocks must have closing triple backticks';
|
||||
export const tags = ['code'];
|
||||
function rule(params, onError) {
|
||||
params.parsers.micromark.tokens
|
||||
.filter(token => token.type === 'codeFenced')
|
||||
.forEach(token => {
|
||||
if (token.text.trim().slice(-3) !== '```') {
|
||||
onError({
|
||||
lineNumber: token.endLine,
|
||||
detail: `Code blocks must have closing triple backticks.`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
export { rule as function };
|
||||
@@ -0,0 +1,29 @@
|
||||
import markdownlint from 'markdownlint';
|
||||
|
||||
import * as lintPrism from './markdown-prism.js';
|
||||
import * as lintYAML from './markdown-yaml.js';
|
||||
import * as fencedCodeBlock from './fenced-code-block.js';
|
||||
|
||||
export function linter(rules) {
|
||||
const lint = (file, next) => {
|
||||
const options = {
|
||||
files: [file.path],
|
||||
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 lint;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import components from 'prismjs/components.js';
|
||||
|
||||
export const names = ['prism-langs'];
|
||||
export const description =
|
||||
'Code block languages should be supported by PrismJS';
|
||||
export const tags = ['prism'];
|
||||
function rule(params, onError) {
|
||||
params.tokens
|
||||
.filter(param => param.type === 'fence')
|
||||
.forEach(codeBlock => {
|
||||
// whitespace around the language is ignored by the parser, as is case:
|
||||
const baseLang = codeBlock.info.trim().toLowerCase();
|
||||
const lang = getBaseLanguageName(baseLang);
|
||||
// Rule MD040 checks if the block has a language, so this rule only
|
||||
// comes into play if a language has been specified.
|
||||
if (baseLang && !lang) {
|
||||
onError({
|
||||
lineNumber: codeBlock.lineNumber,
|
||||
detail: `'${baseLang}' is not recognised.`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { rule as function };
|
||||
|
||||
/*
|
||||
* This is the method used by https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-prismjs/src/load-prism-language.js
|
||||
*/
|
||||
|
||||
// Get the real name of a language given it or an alias
|
||||
const getBaseLanguageName = nameOrAlias => {
|
||||
if (components.languages[nameOrAlias]) {
|
||||
return nameOrAlias;
|
||||
}
|
||||
return Object.keys(components.languages).find(language => {
|
||||
const { alias } = components.languages[language];
|
||||
if (!alias) return false;
|
||||
if (Array.isArray(alias)) {
|
||||
return alias.includes(nameOrAlias);
|
||||
} else {
|
||||
return alias === nameOrAlias;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import jsYaml from 'js-yaml';
|
||||
|
||||
export const names = ['yaml-linter'];
|
||||
export const description = 'YAML code blocks should be valid';
|
||||
export const tags = ['yaml'];
|
||||
function rule(params, onError) {
|
||||
params.tokens
|
||||
.filter(param => param.type === 'fence')
|
||||
.filter(param => param.info === 'yml' || param.info === 'yaml')
|
||||
// TODO since the parser only looks for yml, should we reject yaml blocks?
|
||||
.forEach(codeBlock => {
|
||||
try {
|
||||
jsYaml.safeLoad(codeBlock.content);
|
||||
} catch (e) {
|
||||
onError({
|
||||
lineNumber: codeBlock.lineNumber,
|
||||
detail: e.message,
|
||||
context: codeBlock.line
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { rule as function };
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user