Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
69980a6
Add util function to get lint errors from a file
mao-sz Dec 29, 2025
50249a2
Add util function to get fixed file contents (dry run)
mao-sz Dec 29, 2025
80cc34d
Add test script
mao-sz Dec 29, 2025
9e8e3e3
Update markdownlint-cli2 minimum version required
mao-sz Dec 29, 2025
8de0ae6
Add GH workflow for markdownlint custom rule testing
mao-sz Dec 29, 2025
c42c410
Split different TOP001 violation types to different test files
mao-sz Dec 29, 2025
2e147b8
Add tests for TOP001 custom rule
mao-sz Dec 29, 2025
9e42366
Add tests for TOP002 custom rule
mao-sz Dec 30, 2025
9284065
Rename TOP003 test md files
mao-sz Dec 30, 2025
55df4d2
Add tests for TOP003 custom rule
mao-sz Dec 30, 2025
b3553bb
Add tests for TOP003 fixes
mao-sz Dec 30, 2025
00997c6
Add tests for TOP004 custom rule
mao-sz Dec 30, 2025
c8c546f
Split TOP005 test .md files to valid/invalid cases
mao-sz Dec 30, 2025
84d181e
Add tests for TOP005 custom rule
mao-sz Dec 30, 2025
72c5ec9
Add tests for TOP006 custom rule
mao-sz Dec 30, 2025
3b6f24c
Add tests for TOP007 custom rule
mao-sz Dec 30, 2025
8aa6831
Add tests for TOP008 custom rule
mao-sz Dec 30, 2025
08d8270
Add tests for TOP009 custom rule
mao-sz Dec 30, 2025
49798ea
Add tests for TOP010 custom rule
mao-sz Dec 30, 2025
30447ff
Add tests for TOP011 custom rule
mao-sz Dec 30, 2025
78b6c5a
Add tests for TOP012 custom rule
mao-sz Dec 30, 2025
1c77547
Add tests for TOP013 custom rule
mao-sz Dec 30, 2025
da37a38
Ignore package{-lock}.json in codespell action
mao-sz Dec 30, 2025
9c1c2eb
Test that fixable rule violations are fully resolved by a fix run
mao-sz Dec 30, 2025
b1738a7
Ensure fixable TOP003 errors are fully resolved after fix
mao-sz Dec 30, 2025
94fc986
Ensure fixable TOP012 errors are fully resolved after fix
mao-sz Dec 30, 2025
36770e5
Document custom rule contribution protocol
mao-sz Feb 3, 2026
015d14a
Add instruction for config file custom rule path
mao-sz Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codespell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ jobs:
with:
check_filenames: true
check_hidden: true
skip: ./.git,*.png,*.csv,./archive,./legacy_submissions
skip: ./.git,./package.json,./package-lock.json,*.png,*.csv,./archive,./legacy_submissions
ignore_words_file: './.codespellignore'
4 changes: 2 additions & 2 deletions .github/workflows/markdownlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
- '!*'
- '!archive/**'
- '!templates/**'
- '!markdownlint/docs/**'
- '!markdownlint/**'
- '!.github/**'

jobs:
Expand All @@ -23,7 +23,7 @@ jobs:
!*
!archive/**
!templates/**
!markdownlint/docs/**
!markdownlint/**
!.github/**
separator: ','
- uses: DavidAnson/markdownlint-cli2-action@v14
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/markdownlint_testing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: MarkdownLint
on:
pull_request:
paths:
- 'markdownlint/**'
- '!markdownlint/docs/**'

jobs:
test_custom_rules:
name: Test custom rules
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: npm install
- run: npm run test
5 changes: 4 additions & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@
},
// link-fragments
// Disabled as it uses a different heading conversion algo to the TOP website
"MD051": false
"MD051": false,
// descriptive-link-text
// Disabled and overridden by TOP001 custom rule
"MD059": false
},
// Custom rules specific to the project
// Docs for each rule can be found in `./markdownlint/docs`
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Before submitting a PR for any lesson, you must also use our [Lesson Preview Too

## Curriculum Linting

> [!NOTE]
> For information about contributing to our custom linting rules, please read the [custom markdown linting contributing guide](./markdownlint/docs/README.md).

To help enforce the layout specified in our layout style guide, we use [markdownlint](https://github.com/DavidAnson/markdownlint). Whenever a PR is opened or has updates made to it, a workflow will run to check any files changed in the PR against common rules as well as custom rules specific to TOP. To make the workflow easier, we also strongly suggest that users who have a local clone run this linter locally before committing any changes. There are 2 ways you can do so:

1. Install the [Markdownlint VSCode Plugin](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint). This plugin will automatically pick up our markdownlint configuration and flag issues with a squiggly underline.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ module.exports = {
onError({
lineNumber: tokensAfterLinkOpen[0].lineNumber,
detail: containsThisOrHere
? `Expected text to not include the words "this" or "here". Use a more descriptive text that clearly conveys the purpose or content of the link.`
? `Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.`
: `"${linkContentString}" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.`,
context: `[${linkContentString}](${linkUrl})`,
});
Expand Down
56 changes: 56 additions & 0 deletions markdownlint/TOP001_descriptiveLinkTextLabels/tests/TOP001.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const { join } = require("node:path");
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const getLintErrors = require("../../test_utils/lint")(__dirname);
const rule = require("../TOP001_descriptiveLinkTextLabels");

const pathInRepo = "markdownlint/TOP001_descriptiveLinkTextLabels/tests";
const expected = {
name: "TOP001/descriptive-link-text-labels",
description: "Links must have descriptive text labels",
information: new URL(
"https://github.com/TheOdinProject/curriculum/blob/main/markdownlint/docs/TOP001.md",
),
};

describe("TOP001", () => {
it("Links to the TOP001 docs", () => {
assert.deepEqual(rule.information, expected.information);
});

it('Flags link text labels containing "this" or "here"', async () => {
const filePath = "./this_or_here.md";
const errorPath = join(pathInRepo, filePath);
const lintErrors = await getLintErrors(filePath);

assert.deepEqual(lintErrors, [
`${errorPath}:23 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[this](someURL)"]`,
`${errorPath}:24 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[This video](someURL)"]`,
`${errorPath}:25 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[here](someURL)"]`,
`${errorPath}:26 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[Click here](someURL)"]`,
`${errorPath}:27 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[This other thing](someURL)"]`,
`${errorPath}:28 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[This blog post about flex-grow will be flagged as a false positive, but could still be updated](someURL)"]`,
`${errorPath}:29 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[This will get caught](someURL)"]`,
`${errorPath}:29 error ${expected.name} ${expected.description} [Expected text to not include the words "this" or "here". Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[this as separate matches](someURL)"]`,
]);
});

it("Flags blacklisted link text labels", async () => {
const filePath = "./blacklisted_label_text.md";
const errorPath = join(pathInRepo, filePath);
const lintErrors = await getLintErrors(filePath);

assert.deepEqual(lintErrors, [
`${errorPath}:23 error ${expected.name} ${expected.description} ["video" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[video](someURL)"]`,
`${errorPath}:24 error ${expected.name} ${expected.description} ["videos" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[videos](someURL)"]`,
`${errorPath}:25 error ${expected.name} ${expected.description} ["a video" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[a video](someURL)"]`,
`${errorPath}:26 error ${expected.name} ${expected.description} ["docs" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[docs](someURL)"]`,
`${errorPath}:27 error ${expected.name} ${expected.description} ["the documentation" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[the documentation](someURL)"]`,
`${errorPath}:28 error ${expected.name} ${expected.description} ["their documentation" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[their documentation](someURL)"]`,
`${errorPath}:29 error ${expected.name} ${expected.description} ["page" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[page](someURL)"]`,
`${errorPath}:30 error ${expected.name} ${expected.description} ["their homepage" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[their homepage](someURL)"]`,
`${errorPath}:31 error ${expected.name} ${expected.description} ["playlist" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[playlist](someURL)"]`,
`${errorPath}:32 error ${expected.name} ${expected.description} ["a playlist" is not sufficiently descriptive by itself. Use a more descriptive label that clearly conveys the purpose or content of the link.] [Context: "[a playlist](someURL)"]`,
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
### Introduction

Text content

### Lesson overview

This section contains a general overview of topics that you will learn in this lesson.

- LO item.

### The following links should NOT be flagged

[Some descriptive link text](someURL)
[He replied](someURL)
[With is](someURL)
[Where](someURL)
[Heresy](someURL)
[Some text with the word video](someURL)
[Some text with the words documentation, an article, docs, the docs, page, a video, articles, resource, their docs](someURL)

### The following links SHOULD be flagged as blacklisted text labels

[video](someURL)
[videos](someURL)
[a video](someURL)
[docs](someURL)
[the documentation](someURL)
[their documentation](someURL)
[page](someURL)
[their homepage](someURL)
[playlist](someURL)
[a playlist](someURL)

### Assignment

<div class="lesson-content__panel" markdown="1">

Assignment content

</div>

### Knowledge check

The following questions are an opportunity to reflect on key topics in this lesson. If you can't answer a question, click on it to review the material, but keep in mind you are not expected to memorize or master this knowledge.

- KC item

### Additional resources

This section contains helpful links to related content. It isn't required, so consider it supplemental.

- AR item
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,10 @@ This section contains a general overview of topics that you will learn in this l
[Some text with the word video](someURL)
[Some text with the words documentation, an article, docs, the docs, page, a video, articles, resource, their docs](someURL)

### The following links SHOULD be flagged
### The following links SHOULD be flagged for containing "this" or "here"

[this](someURL)
[This video](someURL)
[video](someURL)
[videos](someURL)
[a video](someURL)
[docs](someURL)
[the documentation](someURL)
[their documentation](someURL)
[page](someURL)
[their homepage](someURL)
[playlist](someURL)
[a playlist](someURL)
[here](someURL)
[Click here](someURL)
[This other thing](someURL)
Expand Down
55 changes: 55 additions & 0 deletions markdownlint/TOP002_noCodeInHeadings/tests/TOP002.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const { join } = require("node:path");
const { readFile } = require("node:fs/promises");
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const getLintErrors = require("../../test_utils/lint")(__dirname);
const fixLintErrors = require("../../test_utils/fix")(__dirname);
const rule = require("../TOP002_noCodeInHeadings");

const pathInRepo = "markdownlint/TOP002_noCodeInHeadings/tests";
const expected = {
name: "TOP002/no-code-headings",
description: "No inline code in headings",
information: new URL(
"https://github.com/TheOdinProject/curriculum/blob/main/markdownlint/docs/TOP002.md",
),
};

describe("TOP002", () => {
describe("Lint", () => {
it("Links to the TOP002 docs", () => {
assert.deepEqual(rule.information, expected.information);
});

it("Flags inline code in headings", async () => {
const filePath = "test.md";
const errorPath = join(pathInRepo, filePath);
const lintErrors = await getLintErrors(filePath);

assert.deepEqual(lintErrors, [
`${errorPath}:15 error ${expected.name} ${expected.description} [Headings should not contain inline code.] [Context: "\`heading\`"]`,
`${errorPath}:19 error ${expected.name} ${expected.description} [Headings should not contain inline code.] [Context: "\`other heading\`"]`,
`${errorPath}:19 error ${expected.name} ${expected.description} [Headings should not contain inline code.] [Context: "\`flagged\`"]`,
]);
});
});

describe("Fix", () => {
it("Does not flag TOP002 in the fixed test md file", async () => {
const file = "./fixed_test.md";
const lintErrors = await getLintErrors(file);

assert(
lintErrors.every((error) => !error.includes(expected.name)),
`"${file}" contains TOP002 errors`,
);
});

it("Strips inline code blocks in headings", async () => {
const fixedFileContents = await fixLintErrors("./test.md");
const correctFile = await readFile(join(__dirname, "./fixed_test.md"));

assert.equal(fixedFileContents, correctFile.toString());
});
});
});
39 changes: 39 additions & 0 deletions markdownlint/TOP002_noCodeInHeadings/tests/fixed_test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
### Introduction

Text content.

### Lesson overview

This section contains a general overview of topics that you will learn in this lesson.

- LO item.

### This heading should NOT be flagged

Some more content.

### This heading SHOULD be flagged

Some content.

### This other heading will get flagged twice

### Assignment

<div class="lesson-content__panel" markdown="1">

Assignment content

</div>

### Knowledge check

The following questions are an opportunity to reflect on key topics in this lesson. If you can't answer a question, click on it to review the material, but keep in mind you are not expected to memorize or master this knowledge.

- KC item

### Additional resources

This section contains helpful links to related content. It isn't required, so consider it supplemental.

- AR item
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function getListSectionErrors(sectionTokens, section) {
const sectionStartsWithList = tokensAfterHeading[0].line.startsWith("- ");
const errorDetail = sectionStartsWithList
? `Expect default content to precede unordered list of ${listItemsName}: "${listSectionsDefaultContent[section]}"`
: `Expected: "${listSectionsDefaultContent[section]}"; Actual: "${tokensAfterHeading[0].line}",`;
: `Expected: "${listSectionsDefaultContent[section]}"; Actual: "${tokensAfterHeading[0].line}"`;
let replacementText = listSectionsDefaultContent[section];

if (sectionStartsWithList) {
Expand Down
Loading