Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Default owners for everything in the repo
* @cypher0n3
8 changes: 8 additions & 0 deletions .github/workflows/markdownlint-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,13 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'

- name: Install Python dependencies
run: pip install PyYAML

- name: Run markdownlint fixture tests
run: make test-markdownlint
24 changes: 23 additions & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Python tests

# Unit tests for test-scripts/*.py (unittest). NOTE: Keep in sync with 'test-python' Makefile target.
# Unit tests and coverage for test-scripts/*.py. Keep in sync with 'test-python' and
# 'test-python-coverage' Makefile targets.

on:
push:
Expand All @@ -22,5 +23,26 @@ jobs:
with:
python-version: '3.11'

- name: Install test dependencies
run: pip install PyYAML

- name: Run Python unit tests
run: make test-python

coverage:
name: Python coverage
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'

- name: Install test dependencies
run: pip install PyYAML coverage

- name: Run Python coverage (fail if < 90%)
run: make test-python-coverage
8 changes: 4 additions & 4 deletions .github/workflows/rule-unit-tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Rule unit tests

# Unit tests for .markdownlint-rules/*.js (Node test runner).
# NOTE: Keep in sync with the 'test-rules' target in the root Makefile.
# Unit tests for .markdownlint-rules/*.js (Node test runner) with coverage (fails if any file < 90%).
# NOTE: Keep in sync with the 'test-rules' and 'test-rules-coverage' targets in the root Makefile.

on:
push:
Expand Down Expand Up @@ -29,5 +29,5 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run rule unit tests
run: make test-rules
- name: Run rule unit tests with coverage
run: make test-rules-coverage
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ tmp/
*.tar.gz

# Ignore coverage files
coverage/
coverage.*
.coverage

# Ignore Python cache files
**/__pycache__/
Expand Down
48 changes: 42 additions & 6 deletions .markdownlint-rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ heading-title-case:

The same structure works in `.markdownlint.json` (use JSON object keys and arrays instead of YAML).

### Using in `VS Code` and its Forks

This repo includes [.vscode/settings.json](../.vscode/settings.json) so the [markdownlint extension](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) uses the same custom rules as the CLI when you open the repo in VS Code or a fork (e.g. Cursor).

- **In another repo:** If you copied these rules into that repo, add a `.vscode/settings.json` there with a `markdownlint.customRules` array listing the paths to each rule file (e.g. `"./.markdownlint-rules/ascii-only.js"`).
Use paths relative to the workspace root. Rule options still come from that repo's `.markdownlint.yml` or `.markdownlint.json`; the extension reads both the custom rule paths and the config file.

Example for a repo that has copied rules into `.markdownlint-rules/`:

```json
{
"markdownlint.customRules": [
"./.markdownlint-rules/allow-custom-anchors.js",
"./.markdownlint-rules/ascii-only.js",
"./.markdownlint-rules/heading-numbering.js",
"./.markdownlint-rules/heading-title-case.js",
"./.markdownlint-rules/no-duplicate-headings-normalized.js",
"./.markdownlint-rules/no-heading-like-lines.js"
]
}
```

Do not list `utils.js` in `markdownlint.customRules`; it is a helper, not a rule.

## Rules

### `allow-custom-anchors`
Expand Down Expand Up @@ -152,7 +176,8 @@ Relative patterns (no leading `/` or `*`) match both path-prefix (e.g. `dev_docs

**File:** `heading-title-case.js`

**Description:** Enforce title case (capital case) for headings. Words inside backticks are not checked. A configurable set of words (e.g. "vs", "and", "the") stay lowercase except when they are the first or last word.
**Description:** Enforce AP-style (Associated Press) headline capitalization for headings.
Words inside backticks are not checked. A configurable set of minor words (e.g. "vs", "and", "the", "is") stay lowercase except when they are the first word, last word, or the first word after a colon or after `(` / `[`.

**Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `heading-title-case`:

Expand All @@ -167,14 +192,25 @@ heading-title-case:
- "or"
```

- **`lowercaseWords`** (array of strings, optional): Words that must be lowercase in the middle of a heading. If omitted, a default list is used: a, an, the, and, or, but, nor, so, yet, as, at, by, for, in, of, on, to, vs, via, per, into, with, from, than, when, if, unless, because, although, while.
- **`lowercaseWords`** (array of strings, optional): Words that must be lowercase in the middle of a heading.
If omitted, a default list aligned with AP headline style is used (articles, coordinating conjunctions, short prepositions, and short verb/pronoun):

```text
a, an, the,
and, but, for, nor, or, so, yet,
as, at, by, in, of, off, on, out, per, to, up, via,
is, its,
v, vs
```

**Behavior:** For each ATX heading, the title part (after stripping any numeric prefix like `1.2.3`) is checked. Content inside inline code (backticks) is ignored.
**Behavior (AP headline rules):** For each ATX heading, the title part (after stripping any numeric prefix like `1.2.3`) is checked.
Content inside inline code (backticks) is ignored.

- First and last words must be capitalized; middle words that are in the lowercase list must be lowercase; all other words must be capitalized.
- **First and last words** of the heading must be capitalized (including the first and last segment of hyphenated compounds).
- **First word after a colon** (e.g. `Summary: The Results`) and the **first word inside parentheses or brackets** (e.g. `(in Practice)`, `[optional]`) are treated as phrase starts and must be capitalized even if they are in the lowercase list.
- **Hyphenated compounds** (e.g. `One-Stop`, `Follow-Up`) are split on hyphens; each segment is checked as above (first/last of title or minor word in the middle).
- **Minor words** (in the default or configured list) must be lowercase when in the middle of the heading; all other words must be capitalized.
- Leading and trailing punctuation is ignored when evaluating a word (e.g. `(Word)` is evaluated as `Word`).
- Parenthesized and bracketed phrases behave like a sentence start for title-case purposes:
the first word inside `(...)` or `[...]` must be capitalized, even if it is in the lowercase list.

### `no-duplicate-headings-normalized`

Expand Down
29 changes: 16 additions & 13 deletions .markdownlint-rules/allow-custom-anchors.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const { stripInlineCode } = require("./utils.js");
function safeRegExp(str) {
if (typeof str !== "string" || !str) return null;
try {
// Config-provided pattern; invalid patterns caught below. Required for allowedIdPatterns.
// eslint-disable-next-line security/detect-non-literal-regexp
return new RegExp(str);
} catch {
return null;
Expand Down Expand Up @@ -93,7 +95,7 @@ function checkPlacementLineMatch(opts) {
const anchorPos = line.lastIndexOf("<a");
const before = (anchorPos >= 0 ? line.slice(0, anchorPos) : line).trim();
if (rule.lineMatch.test(before)) return null;
return { lineNumber, detail: "[lineMatch] Anchor line must match the configured lineMatch pattern for this id.", context };
return { lineNumber, detail: "[lineMatch] The text before the anchor on this line must match the lineMatch pattern configured for this id.", context };
}

/**
Expand All @@ -104,7 +106,7 @@ function checkPlacementLineMatch(opts) {
function checkPlacementStandalone(opts) {
const { trimmed, id, rule, lineNumber, context } = opts;
if (!rule.standaloneLine || trimmed === `<a id="${id}"></a>`) return null;
return { lineNumber, detail: "[standaloneLine] This anchor must be on its own line (no other content).", context };
return { lineNumber, detail: `[standaloneLine] Anchor id "${id}" must be on its own line with no other content before or after.`, context };
}

/**
Expand All @@ -116,11 +118,11 @@ function checkPlacementHeadingSection(opts) {
const { matchIndex, rule, sectionStack, sectionAnchorCount, lineNumber, context } = opts;
if (!rule.headingMatch) return null;
const inSection = sectionStack.some((s) => s.patternIndex === matchIndex);
if (!inSection) return { lineNumber, detail: "[headingMatch] This anchor must appear within a section whose heading matches the configured headingMatch.", context };
if (!inSection) return { lineNumber, detail: "[headingMatch] This anchor must appear under a section heading that matches the headingMatch pattern for this id.", context };
if (rule.maxPerSection == null) return null;
const count = sectionAnchorCount.get(matchIndex) || 0;
if (count >= rule.maxPerSection) {
return { lineNumber, detail: `[maxPerSection] Only ${rule.maxPerSection} anchor(s) of this type allowed per section.`, context };
return { lineNumber, detail: `[maxPerSection] This section already has ${rule.maxPerSection} anchor(s) of this type; only ${rule.maxPerSection} allowed per section.`, context };
}
sectionAnchorCount.set(matchIndex, count + 1);
return null;
Expand All @@ -139,7 +141,7 @@ function checkPlacementImmediatelyAfter(opts) {
const prevLine = prev >= 0 ? lines[prev].trim() : "";
const matches = rule.headingMatch ? rule.headingMatch.test(prevLine) : /^\s*#{1,6}\s+/.test(prevLine);
if (prev >= 0 && matches) return null;
return { lineNumber, detail: "[anchorImmediatelyAfterHeading] This anchor must appear immediately after the section heading (blank lines allowed).", context };
return { lineNumber, detail: "[anchorImmediatelyAfterHeading] This anchor must appear immediately after the section heading with only blank lines in between.", context };
}

/**
Expand All @@ -151,7 +153,7 @@ function checkPlacementImmediatelyAfter(opts) {
*/
function checkRequireAfterBlank(next, lineNumber, context) {
if (next == null || next.trim() !== "") {
return { lineNumber, detail: "[requireAfter] Anchor line must be followed by a blank line.", context };
return { lineNumber, detail: "[requireAfter] Anchor line must be followed immediately by a blank line (no content on the next line).", context };
}
return null;
}
Expand All @@ -165,7 +167,7 @@ function checkRequireAfterBlank(next, lineNumber, context) {
*/
function checkRequireAfterFenced(checkLine, lineNumber, context) {
if (checkLine == null || !checkLine.trim().match(/^(```+|~~~+)/)) {
return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a fenced code block." };
return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a fenced code block (``` or ~~~)." };
}
return null;
}
Expand All @@ -179,7 +181,7 @@ function checkRequireAfterFenced(checkLine, lineNumber, context) {
*/
function checkRequireAfterList(checkLine, lineNumber, context) {
if (checkLine == null || !/^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(checkLine.trim())) {
return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a list (ordered or unordered)." };
return { lineNumber, context, detail: "[requireAfter] Anchor line must be followed by a blank line and then a list (ordered or unordered list)." };
}
return null;
}
Expand Down Expand Up @@ -239,15 +241,16 @@ const ANCHOR_END_RE = /<a id="([^"]+)"><\/a>\s*$/;
*/
function getBasicAnchorError(scanLine, line, lineNumber, allowedPatterns) {
if (scanLine.indexOf("<a", scanLine.indexOf("<a") + 1) !== -1) {
return { lineNumber, detail: "[one-per-line] Only one <a id=\"...\"></a> anchor is allowed per line.", context: line };
return { lineNumber, detail: "[one-per-line] Only one <a id=\"...\"></a> anchor is allowed per line; this line contains more than one.", context: line };
}
const match = scanLine.match(ANCHOR_TAG_RE);
if (!match) return { lineNumber, detail: "[anchor-format] Only <a id=\"...\"></a> anchors are allowed, with id as the only attribute.", context: line };
if (!allowedPatterns.some((re) => re.test(match[1]))) {
return { lineNumber, detail: "[allowedIdPatterns] Anchor id must match one of the configured allowedIdPatterns.", context: line };
if (!match) return { lineNumber, detail: "[anchor-format] Line must use only <a id=\"...\"></a> anchors with id as the only attribute (no other attributes or tags).", context: line };
const anchorId = match[1];
if (!allowedPatterns.some((re) => re.test(anchorId))) {
return { lineNumber, detail: `[allowedIdPatterns] Anchor id "${anchorId}" does not match any configured allowedIdPatterns.`, context: line };
}
if (!scanLine.match(ANCHOR_END_RE)) {
return { lineNumber, detail: "[end-of-line] Anchors must appear at the end of the line (or be a standalone reference anchor line above a fenced code block).", context: line };
return { lineNumber, detail: "[end-of-line] Anchor must be at the end of the line (no content after <a id=\"...\"></a>), or be a standalone reference anchor above a fenced code block.", context: line };
}
return null;
}
Expand Down
16 changes: 12 additions & 4 deletions .markdownlint-rules/heading-numbering.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ function getSiblings(sorted, parentIndex, i) {
if (parentIndex[j] !== parentIndex[i]) {
continue;
}
/* c8 ignore start -- same parent implies same level by tree construction */
if (sorted[j].level !== h.level) {
continue;
}
/* c8 ignore stop */
siblings.push({ index: j, ...sorted[j] });
}
siblings.sort((a, b) => a.lineNumber - b.lineNumber);
Expand All @@ -76,17 +78,21 @@ function getExpectedNumberInSection(sorted, parentIndex, i) {

const siblings = getSiblings(sorted, parentIndex, i);
const myIdx = siblings.findIndex((s) => s.lineNumber === h.lineNumber);
/* c8 ignore start -- current heading is always in its sibling list */
if (myIdx < 0) {
return null;
}
/* c8 ignore stop */

const firstNumbered = siblings.find((s) =>
parseHeadingNumberPrefix(s.rawText).numbering != null
);
/* c8 ignore start -- sectionUsesNum ensures at least one numbered sibling */
const firstNumbering =
firstNumbered != null
? parseHeadingNumberPrefix(firstNumbered.rawText).numbering
: null;
/* c8 ignore stop */
const startAtZero = firstNumbering === "0";
const nextNum = startAtZero ? myIdx : myIdx + 1;
const prefix = parentNum ? parentNum + "." : "";
Expand All @@ -111,9 +117,11 @@ function getSectionPeriodStyle(sorted, parentIndex, i) {
const firstNumbered = siblings.find((s) =>
parseHeadingNumberPrefix(s.rawText).numbering != null
);
/* c8 ignore start -- getPeriodStyleError only called for numbered headings */
if (firstNumbered == null) {
return null;
}
/* c8 ignore stop */
return parseHeadingNumberPrefix(firstNumbered.rawText).hasH2Dot;
}

Expand All @@ -129,7 +137,7 @@ function getPeriodStyleError(ctx) {
if (sectionPeriodStyle == null || hasH2Dot === sectionPeriodStyle) return null;
return {
lineNumber: h.lineNumber,
detail: `Period inconsistency in this section: use ${sectionPeriodStyle ? "period" : "no period"} after number to match sibling.`,
detail: `Numbering period style inconsistent: use ${sectionPeriodStyle ? "a period" : "no period"} after the number (e.g. "${sectionPeriodStyle ? "1.2." : "1.2"}") to match other numbered headings in this section.`,
context: contextLine,
};
}
Expand All @@ -147,7 +155,7 @@ function checkSegmentCount(ctx) {
const expectedSegmentCount = h.level - rootLevel;
const segments = numbering.split(".");
if (segments.length !== expectedSegmentCount) {
return { lineNumber: h.lineNumber, detail: `H${h.level} heading has ${segments.length} number(s), expected ${expectedSegmentCount} (level - numbering root).`, context: contextLine };
return { lineNumber: h.lineNumber, detail: `H${h.level} heading has ${segments.length} segment(s) in number prefix "${numbering}"; expected ${expectedSegmentCount} (one per level from numbering root).`, context: contextLine };
}
return null;
}
Expand All @@ -166,7 +174,7 @@ function getHeadingErrors(h, i, ctx) {
const sectionUsesNum = sectionUsesNumbering(sorted, parentIndex, i);

if (sectionUsesNum && numbering == null) {
errors.push({ lineNumber: h.lineNumber, detail: "This section uses numbering; add a number prefix to match siblings.", context: contextLine });
errors.push({ lineNumber: h.lineNumber, detail: "This heading has no number prefix but other headings in this section are numbered; add a number prefix to match siblings (e.g. \"1.2\" for second under 1).", context: contextLine });
return errors;
}
if (numbering == null) return errors;
Expand All @@ -182,7 +190,7 @@ function getHeadingErrors(h, i, ctx) {
if (periodErr) errors.push(periodErr);
const expected = getExpectedNumberInSection(sorted, parentIndex, i);
if (expected != null && numbering !== expected) {
errors.push({ lineNumber: h.lineNumber, detail: `Non-sequential numbering in this section: got '${numbering}', expected '${expected}'.`, context: contextLine });
errors.push({ lineNumber: h.lineNumber, detail: `Number prefix "${numbering}" is out of sequence in this section; expected "${expected}" to match sibling order.`, context: contextLine });
}
return errors;
}
Expand Down
Loading