diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc
index 299e45c..219e50d 100644
--- a/.markdownlint-cli2.jsonc
+++ b/.markdownlint-cli2.jsonc
@@ -10,12 +10,14 @@
"**/*.plan.md"
],
"customRules": [
- "markdownlint-rules/no-heading-like-lines.js",
"markdownlint-rules/allow-custom-anchors.js",
- "markdownlint-rules/no-duplicate-headings-normalized.js",
+ "markdownlint-rules/ascii-only.js",
+ "markdownlint-rules/document-length.js",
"markdownlint-rules/heading-numbering.js",
"markdownlint-rules/heading-title-case.js",
- "markdownlint-rules/ascii-only.js",
- "markdownlint-rules/document-length.js"
+ "markdownlint-rules/no-duplicate-headings-normalized.js",
+ "markdownlint-rules/no-empty-heading.js",
+ "markdownlint-rules/no-h1-content.js",
+ "markdownlint-rules/no-heading-like-lines.js"
]
}
diff --git a/.markdownlint.yml b/.markdownlint.yml
index 1d9b828..bae1a2f 100644
--- a/.markdownlint.yml
+++ b/.markdownlint.yml
@@ -53,6 +53,22 @@ document-length:
# lowercaseWords: ["through", ...] # optional; extends default list (add words)
# lowercaseWordsReplaceDefault: true # optional; true = use only lowercaseWords list, no default set
+# no-h1-content: under first h1 allow only TOC (blank, list-of-links, HTML comments)
+# - excludePathPatterns: glob list; skip this rule for matching paths (e.g. md_test_files for fixtures)
+# no-h1-content:
+# excludePathPatterns:
+# - "README.md"
+# - "CONTRIBUTING.md"
+# - "**/README.md"
+
+# no-empty-heading: H2+ must have content; allow file-level override or per-section suppress comment
+# - excludePathPatterns: glob list; skip this rule for matching paths (e.g. **/*_index.md)
+# - Other HTML comments in a section are allowed. Only the exact comment
+# "" on its own line (and nothing else) suppresses the error.
+no-empty-heading:
+ excludePathPatterns:
+ - "**/*_index.md"
+
# allow-custom-anchors: require anchor ids to match patterns; optional placement (line/heading) per pattern
# - allowedIdPatterns: list of regex strings or { pattern, placement? }; placement applies per pattern
# - strictPlacement: true (default) = enforce placement when a pattern has placement set
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 96ec4cd..1b76b1d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,10 +2,12 @@
"markdownlint.customRules": [
"./markdownlint-rules/allow-custom-anchors.js",
"./markdownlint-rules/ascii-only.js",
+ "./markdownlint-rules/document-length.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",
- "./markdownlint-rules/document-length.js"
+ "./markdownlint-rules/no-empty-heading.js",
+ "./markdownlint-rules/no-h1-content.js",
+ "./markdownlint-rules/no-heading-like-lines.js"
]
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c05fc4b..ad71b80 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,10 +1,24 @@
# Contributing
-Thanks for your interest in contributing. This project uses standard GitHub flow: open an issue or pull request from a fork.
+- [Before You Submit](#before-you-submit)
+ - [Install Testing Dependencies](#install-testing-dependencies)
+ - [Run the Same Checks as CI](#run-the-same-checks-as-ci)
+- [Checks - Makefile Targets](#checks---makefile-targets)
+ - [Lint Rule JavaScript (`make lint-js`)](#lint-rule-javascript-make-lint-js)
+ - [Markdownlint Tests (`make test-markdownlint`)](#markdownlint-tests-make-test-markdownlint)
+ - [Rule Unit Tests (`make test-rules`)](#rule-unit-tests-make-test-rules)
+ - [Python Unit Tests (`make test-python`)](#python-unit-tests-make-test-python)
+ - [Lint READMEs (`make lint-readmes`)](#lint-readmes-make-lint-readmes)
+- [Recommended Pre-Push](#recommended-pre-push)
+- [Custom Rules](#custom-rules)
+- [Sync Notes](#sync-notes)
## Before You Submit
-### Install Dependencies
+Thanks for your interest in contributing!
+This project uses standard GitHub flow: open an issue or pull request from a fork.
+
+### Install Testing Dependencies
```bash
npm install
diff --git a/README.md b/README.md
index 39ae828..ab6fe50 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,18 @@
[](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml)
[](LICENSE)
-Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAnson/markdownlint) rules (JavaScript).
+- [Features](#features)
+- [Requirements](#requirements)
+- [Install Testing Dependencies](#install-testing-dependencies)
+- [Usage](#usage)
+- [Repository Layout](#repository-layout)
+- [Contributing](#contributing)
+- [License](#license)
## Features
+Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAnson/markdownlint) rules (JavaScript).
+
- **Custom markdownlint rules** in [markdownlint-rules/](markdownlint-rules/README.md) (intended to be **copied directly** into whatever repo wishes to use them; no need to depend on this repo):
- [allow-custom-anchors.js](markdownlint-rules/allow-custom-anchors.js) - Custom anchor validation.
- Only allow `` whose ids match configured regex patterns; optional placement (heading match, line match, require-after, max per section).
@@ -29,9 +37,15 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns
- [no-duplicate-headings-normalized.js](markdownlint-rules/no-duplicate-headings-normalized.js) - duplicate-heading checks.
- Disallow duplicate heading titles after stripping numeric prefixes and normalizing case/whitespace; first occurrence is reference.
- Use when: avoiding duplicate section titles that differ only by number or formatting.
+ - [no-empty-heading.js](markdownlint-rules/no-empty-heading.js) - H2+ must have content.
+ - Every H2+ heading must have at least one line of content before the next same-or-higher-level heading; other HTML comments are allowed; only `` on its own line suppresses; configurable `excludePathPatterns` (e.g. `**/*_index.md`).
+ - Use when: avoiding placeholder sections with no body content.
- [no-heading-like-lines.js](markdownlint-rules/no-heading-like-lines.js) - no heading-like lines.
- Report lines that look like headings but are not (e.g. `**Text:**`, `1. **Text**`); prompt use of real `#` headings.
- Use when: ensuring real Markdown headings instead of bold/italic that look like headings.
+ - [no-h1-content.js](markdownlint-rules/no-h1-content.js) - no content under h1 except TOC.
+ - Under the first h1, allow only table-of-contents content (blank lines, list-of-links, HTML comments).
+ - Use when: enforcing that the only content under the doc title is a TOC.
- [document-length.js](markdownlint-rules/document-length.js) - maximum document length.
- Disallow documents longer than a configured number of lines (default 1500); reports on line 1 when over the limit.
- Use when: keeping individual docs under a line cap to encourage splitting.
diff --git a/markdownlint-rules/README.md b/markdownlint-rules/README.md
index 10aa006..a6941ac 100644
--- a/markdownlint-rules/README.md
+++ b/markdownlint-rules/README.md
@@ -1,11 +1,16 @@
# Custom Markdownlint Rules
+- [Overview](#overview)
+- [Reusing These Rules](#reusing-these-rules)
+- [Rules](#rules)
+- [Shared Helper](#shared-helper)
+
+## Overview
+
This directory contains custom rules for [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2).
In this repo they are registered in [.markdownlint-cli2.jsonc](../.markdownlint-cli2.jsonc) and configured in [.markdownlint.yml](../.markdownlint.yml).
You can reuse any of them in your own project; see [Reusing These Rules](#reusing-these-rules) below.
-## Overview
-
- **Rule modules**: Each `*.js` file here (except `utils.js`) is a custom rule.
- **Config**: Rule-specific options are set under the rule name in a markdownlint config file.
You can use `.markdownlint.yml` or `.markdownlint.json` (markdownlint accepts either).
@@ -66,7 +71,9 @@ Example for a repo that has copied rules into `.markdownlint-rules/`:
"./.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"
+ "./.markdownlint-rules/no-empty-heading.js",
+ "./.markdownlint-rules/no-heading-like-lines.js",
+ "./.markdownlint-rules/no-h1-content.js"
]
}
```
@@ -131,6 +138,48 @@ Order of entries matters: the first pattern that matches the anchor id is used.
**Behavior:** Reports lines that look like headings but are not (e.g. `**Text:**`, `**Text**:`, `1. **Text**`, and italic variants). Prompts use of real `#` headings instead.
+### `no-h1-content`
+
+**File:** `no-h1-content.js`
+
+**Description:** Under the first h1 heading, allow only table-of-contents content (blank lines, list-of-links, badges, HTML comments). No prose or other content is permitted.
+
+**Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `no-h1-content`:
+
+```yaml
+no-h1-content:
+ excludePathPatterns:
+ - "md_test_files/**" # optional; skip rule for these paths
+```
+
+- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped.
+
+**Behavior:** The block of lines after the first `#` heading and before the next heading (any level) may only contain blank lines, list items that are anchor links (e.g. `- [Section](#section)` or `1. [Section](#section)`), badge lines (e.g. `[](url)`), and HTML comments.
+Any other line (prose, code blocks, etc.) is reported.
+
+### `no-empty-heading`
+
+**File:** `no-empty-heading.js`
+
+**Description:** Every H2+ heading must have at least one line of content before the next heading of the same or higher level. Blank lines and HTML-comment-only lines do not count as content. Other HTML comments are allowed in the section. Optionally exclude files by path (e.g. index-style pages) or allow a section via the exact suppress comment on its own line.
+
+**Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `no-empty-heading`:
+
+```yaml
+no-empty-heading:
+ excludePathPatterns:
+ - "**/*_index.md" # optional; skip rule for these paths
+```
+
+- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped.
+
+Behavior:
+
+- For each H2-H6 heading, the section (from the line after the heading until the next same-or-higher-level heading or end of file) must contain at least one line that counts as content. Content is any non-blank line that is not only an HTML comment.
+- Other HTML comments in the section are allowed; they do not count as content and do not suppress the error.
+- **Suppress per section:** A section with no other content is allowed only if it contains a line that is solely the comment `` (optional whitespace around or inside the comment). The comment must be on its own line; if it appears on the same line as other text or another comment, it does not suppress. No other HTML comment format (e.g. ``) suppresses the rule.
+- When the file path matches any of `excludePathPatterns`, the rule is skipped for the whole file.
+
### `document-length`
**File:** `document-length.js`
diff --git a/markdownlint-rules/no-empty-heading.js b/markdownlint-rules/no-empty-heading.js
new file mode 100644
index 0000000..0cbfcae
--- /dev/null
+++ b/markdownlint-rules/no-empty-heading.js
@@ -0,0 +1,108 @@
+"use strict";
+
+const { extractHeadings, pathMatchesAny } = require("./utils.js");
+
+/** Match HTML comment line (single line). */
+const RE_HTML_COMMENT = /^\s*\s*$/;
+
+/** Match the exact suppress comment: (optional whitespace). */
+const RE_SUPPRESS_COMMENT_RAW = /^\s*\s*$/;
+
+/** Match markdownlint-cleared form: comment text is replaced with dots (e.g. "................ ....."). */
+const RE_SUPPRESS_COMMENT_CLEARED = /^\s*\s*$/;
+
+/**
+ * Return true if the trimmed line counts as content (non-blank, not only HTML comment).
+ *
+ * @param {string} trimmed - Trimmed line
+ * @returns {boolean}
+ */
+function isContentLine(trimmed) {
+ if (trimmed === "") {
+ return false;
+ }
+ if (RE_HTML_COMMENT.test(trimmed)) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Return true if the trimmed line is the rule's suppress comment (allows empty section).
+ * Only a line that is solely this comment (plus optional whitespace) suppresses; other
+ * HTML comments in the section are allowed but do not count as content or as suppress.
+ * Accepts raw "" or markdownlint-cleared form.
+ *
+ * @param {string} trimmed - Trimmed line
+ * @returns {boolean}
+ */
+function isSuppressComment(trimmed) {
+ return RE_SUPPRESS_COMMENT_RAW.test(trimmed) || RE_SUPPRESS_COMMENT_CLEARED.test(trimmed);
+}
+
+/**
+ * Return whether the section from heading to endLine has content or a suppress comment.
+ *
+ * @param {string[]} lines - All lines
+ * @param {{ lineNumber: number }} heading - Heading info
+ * @param {number} endLine - Last line index (1-based) of section
+ * @returns {boolean}
+ */
+function sectionHasContentOrSuppress(lines, heading, endLine) {
+ const lastLine = Math.min(endLine, lines.length);
+ for (let lineNumber = heading.lineNumber + 1; lineNumber <= lastLine; lineNumber++) {
+ const trimmed = lines[lineNumber - 1].trim();
+ if (isContentLine(trimmed)) {
+ return true;
+ }
+ if (isSuppressComment(trimmed)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * markdownlint rule: every H2+ heading must have at least one line of content
+ * before the next heading of the same or higher level. Blank lines and
+ * HTML-comment-only lines do not count as content. Other HTML comments are allowed
+ * in the section; only the exact comment "" on its
+ * own line suppresses the error.
+ *
+ * @param {object} params - markdownlint params (lines, name, config)
+ * @param {function(object): void} onError - Callback to report an error
+ */
+function ruleFunction(params, onError) {
+ const lines = params.lines;
+ const filePath = params.name || "";
+ const config = params.config || {};
+ const excludePatterns = config.excludePathPatterns;
+ if (Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns)) {
+ return;
+ }
+
+ const headings = extractHeadings(lines);
+ const h2Plus = headings.filter((h) => h.level >= 2);
+
+ for (const heading of h2Plus) {
+ const nextSameOrHigher = headings.find(
+ (h) => h.lineNumber > heading.lineNumber && h.level <= heading.level
+ );
+ const endLine = nextSameOrHigher ? nextSameOrHigher.lineNumber - 1 : lines.length;
+ if (sectionHasContentOrSuppress(lines, heading, endLine)) {
+ continue;
+ }
+ onError({
+ lineNumber: heading.lineNumber,
+ detail: "H2+ heading must have at least one line of content (blank and HTML-comment-only lines do not count).",
+ context: lines[heading.lineNumber - 1],
+ });
+ }
+}
+
+module.exports = {
+ names: ["no-empty-heading"],
+ description: "H2+ headings must have at least one line of content before the next same-or-higher-level heading.",
+ tags: ["headings"],
+ function: ruleFunction,
+};
diff --git a/markdownlint-rules/no-h1-content.js b/markdownlint-rules/no-h1-content.js
new file mode 100644
index 0000000..8d6f9a7
--- /dev/null
+++ b/markdownlint-rules/no-h1-content.js
@@ -0,0 +1,85 @@
+"use strict";
+
+const { extractHeadings, pathMatchesAny } = require("./utils.js");
+
+/** Match HTML comment line (single line). */
+const RE_HTML_COMMENT = /^\s*\s*$/;
+
+/** Match list item that is a single anchor link: - [text](#id) or 1. [text](#id). */
+const RE_TOC_LIST_ITEM = /^\s*([-*]|\d+\.)\s+\[.+\]\(#\S+\)\s*$/;
+
+/** Match badge line(s): [](link-url), optionally repeated with spaces. */
+const RE_BADGE_LINE = /^\s*(\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*)+\s*$/;
+
+/**
+ * Return true if the trimmed line is allowed under h1 (blank, TOC, badge, or HTML comment).
+ *
+ * @param {string} trimmed - Trimmed line
+ * @returns {boolean}
+ */
+function isAllowedUnderH1(trimmed) {
+ if (trimmed === "") {
+ return true;
+ }
+ if (RE_HTML_COMMENT.test(trimmed)) {
+ return true;
+ }
+ if (RE_TOC_LIST_ITEM.test(trimmed)) {
+ return true;
+ }
+ if (RE_BADGE_LINE.test(trimmed)) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * markdownlint rule: under the first h1 heading, only table-of-contents content
+ * is allowed (blank lines, list items that are anchor links, badges, HTML comments).
+ * Any other content (prose, code blocks, etc.) is reported.
+ *
+ * @param {object} params - markdownlint params (lines, name, config)
+ * @param {function(object): void} onError - Callback to report an error
+ */
+function ruleFunction(params, onError) {
+ const lines = params.lines;
+ const filePath = params.name || "";
+ const config = params.config || {};
+ const excludePatterns = config.excludePathPatterns;
+ if (Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns)) {
+ return;
+ }
+
+ const headings = extractHeadings(lines);
+ const firstH1 = headings.find((h) => h.level === 1);
+ if (!firstH1) {
+ return;
+ }
+
+ const nextHeading = headings.find((h) => h.lineNumber > firstH1.lineNumber);
+ const endLine = nextHeading ? nextHeading.lineNumber - 1 : lines.length;
+
+ for (let lineNumber = firstH1.lineNumber + 1; lineNumber <= endLine; lineNumber++) {
+ const line = lines[lineNumber - 1];
+ const trimmed = line.trim();
+
+ if (isAllowedUnderH1(trimmed)) {
+ continue;
+ }
+
+ onError({
+ lineNumber,
+ detail:
+ "Content under the first h1 heading is not allowed; only a table of contents (blank lines, list-of-links, badges, or HTML comments) is permitted.",
+ context: line,
+ });
+ }
+}
+
+module.exports = {
+ names: ["no-h1-content"],
+ description:
+ "Under the first h1 heading, allow only table-of-contents content (blank lines, list-of-links, badges, HTML comments).",
+ tags: ["headings"],
+ function: ruleFunction,
+};
diff --git a/md_test_files/README.md b/md_test_files/README.md
index 62dba2c..4a87c28 100644
--- a/md_test_files/README.md
+++ b/md_test_files/README.md
@@ -1,24 +1,51 @@
# Markdown Test Fixtures
-- **positive_general.md** - Examples that pass all markdown standards. Lint should report 0 errors.
-- **positive_heading_numbering_zero.md** - 0-indexed H2 numbering (## 0., 1., 2. and subsections); passes with 0 errors.
-- **negative_*.md** - One file per failing scenario; lint each to verify the expected custom rule(s) fail.
+[](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml)
+
+- [Fixtures Overview](#fixtures-overview)
+- [Negative Fixtures (Custom Rules Only)](#negative-fixtures-custom-rules-only)
+- [Expectations](#expectations)
+- [Linting](#linting)
+
+## Fixtures Overview
+
+- `**positive_general.md**` - Examples that pass all markdown standards, including no-empty-heading:
+ section with only `` on its own line, and section with other HTML comments plus that comment on its own line.
+ Lint should report 0 errors.
+- `**positive_general_index.md**` - Index-style page (filename matches `**/*_index.md`); excluded from no-empty-heading so empty H2 sections are allowed; passes with 0 errors.
+- `**positive_heading_numbering_zero.md**` - 0-indexed H2 numbering (## 0., 1., 2. and subsections); passes with 0 errors.
+- `**negative_*.md**` - One file per failing scenario; lint each to verify the expected custom rule(s) fail.
## Negative Fixtures (Custom Rules Only)
-| File | Expected custom rule(s) |
-| ----------------------------------------- | ----------------------------------------------------------------------- |
-| negative_anchor_algo_placement.md | allow-custom-anchors (algo placement) |
-| negative_anchor_invalid_id.md | allow-custom-anchors (id not in allowedIdPatterns) |
-| negative_anchor_multiple.md | allow-custom-anchors (multiple per line) |
-| negative_anchor_ref_placement.md | allow-custom-anchors (ref placement) |
-| negative_anchor_spec_placement.md | allow-custom-anchors (spec placement) |
-| negative_ascii_only.md | ascii-only |
-| negative_duplicate_headings_normalized.md | no-duplicate-headings-normalized |
-| negative_heading_like.md | no-heading-like-lines |
-| negative_heading_numbering.md | heading-numbering (segment, sequence, period, unnumbered, zero-indexed) |
-| negative_heading_title_case.md | heading-title-case |
-| negative_inline_html.md | allow-custom-anchors (attribute, id pattern, end-of-line) |
+Each item: **filename** - custom rule(s) that fail; sub-bullet - what the fixture exercises.
+
+- **negative_anchor_algo_placement.md** - allow-custom-anchors
+ - Anchor placement for algo/spec patterns (headingMatch, lineMatch, requireAfter, etc.).
+- **negative_anchor_invalid_id.md** - allow-custom-anchors
+ - Anchor id does not match any allowedIdPatterns.
+- **negative_anchor_multiple.md** - allow-custom-anchors
+ - Multiple anchors on the same line (one-per-line).
+- **negative_anchor_ref_placement.md** - allow-custom-anchors
+ - Ref-pattern placement (e.g. requireAfter blank/fencedBlock).
+- **negative_anchor_spec_placement.md** - allow-custom-anchors
+ - Spec-pattern placement (lineMatch, anchorImmediatelyAfterHeading).
+- **negative_ascii_only.md** - ascii-only
+ - Non-ASCII in prose (arrows, quotes, etc.) where not allowlisted.
+- **negative_duplicate_headings_normalized.md** - no-duplicate-headings-normalized
+ - Duplicate heading titles after normalizing (and heading-numbering sibling/sequence).
+- **negative_heading_like.md** - no-heading-like-lines
+ - Lines that look like headings (e.g. `**Text:**`, `1. **Text**`) but are not ATX headings.
+- **negative_heading_numbering.md** - heading-numbering
+ - Segment count, sequence, period style, unnumbered sibling, zero-indexed violations.
+- **negative_heading_title_case.md** - heading-title-case
+ - AP-style capitalization (lowercase/middle words, hyphenated compounds, etc.).
+- **negative_inline_html.md** - allow-custom-anchors
+ - Inline HTML (MD033), anchor id/format, end-of-line content.
+- **negative_no_empty_heading.md** - no-empty-heading
+ - Empty H2/H3 or H2 at end; wrong HTML comment does not suppress; suppress comment on same line as another comment does not suppress; wrong-format comment (e.g. colon) does not suppress.
+- **negative_no_h1_content.md** - no-h1-content
+ - Prose under the first h1 (only TOC-style content allowed there).
Note: some negative fixtures intentionally trigger built-in markdownlint rules in addition to custom rules (e.g. MD031/MD032/MD033), so the test suite can assert multiple errors on specific lines.
diff --git a/md_test_files/expected_errors.yml b/md_test_files/expected_errors.yml
index 61688df..c33a6bc 100644
--- a/md_test_files/expected_errors.yml
+++ b/md_test_files/expected_errors.yml
@@ -8,9 +8,12 @@ positive_general.md:
positive_heading_numbering_zero.md:
errors: []
+positive_general_index.md:
+ errors: []
+
negative_anchor_algo_placement.md:
errors:
- - line: 11
+ - line: 10
rule: allow-custom-anchors
message_contains: headingMatch
@@ -22,25 +25,25 @@ negative_anchor_invalid_id.md:
negative_anchor_multiple.md:
errors:
- - line: 10
+ - line: 9
rule: allow-custom-anchors
message_contains: one-per-line
- - line: 10
+ - line: 9
rule: MD032/blanks-around-lists
message_contains: surrounded by blank lines
negative_anchor_ref_placement.md:
errors:
- - line: 10
+ - line: 9
rule: allow-custom-anchors
message_contains: requireAfter
- - line: 11
+ - line: 10
rule: MD031/blanks-around-fences
message_contains: Fenced code blocks should be surrounded
negative_anchor_spec_placement.md:
errors:
- - line: 10
+ - line: 9
rule: allow-custom-anchors
message_contains: lineMatch
@@ -107,90 +110,117 @@ negative_document_length.md:
negative_heading_like.md:
errors:
- - line: 9
+ - line: 8
rule: no-heading-like-lines
message_contains: bold with colon inside
- - line: 11
+ - line: 10
rule: no-heading-like-lines
message_contains: italic with colon inside
- - line: 13
+ - line: 12
rule: no-heading-like-lines
message_contains: italic with colon outside
- - line: 15
+ - line: 14
rule: no-heading-like-lines
message_contains: numbered list with bold
negative_heading_numbering.md:
errors:
- - line: 13
+ - line: 14
rule: heading-numbering
message_contains: expected "1.2" to match sibling
- - line: 15
+ - line: 18
rule: heading-numbering
message_contains: period style inconsistent
- - line: 17
+ - line: 22
rule: heading-numbering
message_contains: no number prefix
- - line: 19
+ - line: 26
rule: heading-numbering
message_contains: period style inconsistent
- - line: 19
+ - line: 26
rule: heading-numbering
message_contains: expected "3" to match sibling
- - line: 23
+ - line: 30
rule: heading-numbering
message_contains: 2 segment(s) in number prefix "1.1"
- - line: 25
+ - line: 32
rule: heading-numbering
message_contains: Too Many Segments for H4
- - line: 33
+ - line: 44
rule: heading-numbering
message_contains: expected "1" to match sibling
- - line: 37
+ - line: 48
rule: heading-numbering
message_contains: expected "2" to match sibling
- - line: 41
+ - line: 54
rule: heading-numbering
message_contains: expected "0.1" to match sibling
negative_heading_title_case.md:
errors:
- - line: 7
+ - line: 6
rule: heading-title-case
message_contains: 'Word "getting" should be capitalized'
- - line: 11
+ - line: 10
rule: heading-title-case
message_contains: 'Word "And" should be lowercase'
- - line: 15
+ - line: 14
rule: heading-title-case
message_contains: 'Word "practice" should be capitalized'
- - line: 19
+ - line: 18
rule: heading-title-case
message_contains: 'Word "in" should be capitalized'
- - line: 23
+ - line: 22
rule: heading-title-case
message_contains: 'Word "in" should be capitalized'
- - line: 27
+ - line: 26
rule: heading-title-case
message_contains: 'Word "is" should be capitalized'
- - line: 31
+ - line: 30
rule: heading-title-case
message_contains: 'Word "stop" should be capitalized'
- - line: 35
+ - line: 34
rule: heading-title-case
message_contains: 'Word "the" should be capitalized'
+negative_no_h1_content.md:
+ errors:
+ - line: 3
+ rule: no-h1-content
+ message_contains: table of contents
+
+negative_no_empty_heading.md:
+ errors:
+ - line: 11
+ rule: no-empty-heading
+ message_contains: at least one line of content
+ - line: 17
+ rule: no-empty-heading
+ message_contains: at least one line of content
+ - line: 23
+ rule: no-empty-heading
+ message_contains: at least one line of content
+ - line: 25
+ rule: no-empty-heading
+ message_contains: at least one line of content
+ - line: 29
+ rule: no-empty-heading
+ message_contains: at least one line of content
+ - line: 33
+ rule: no-empty-heading
+ message_contains: at least one line of content
+
negative_inline_html.md:
errors:
- - line: 10
+ - line: 9
rule: MD033/no-inline-html
message_contains: Inline HTML
- - line: 13
+ - line: 12
rule: allow-custom-anchors
message_contains: anchor-format
- - line: 16
+ - line: 15
rule: allow-custom-anchors
message_contains: bad-np-core-package-readfile
- - line: 18
+ - line: 17
rule: allow-custom-anchors
message_contains: end-of-line
diff --git a/md_test_files/negative_anchor_algo_placement.md b/md_test_files/negative_anchor_algo_placement.md
index ffd95a0..d00e26f 100644
--- a/md_test_files/negative_anchor_algo_placement.md
+++ b/md_test_files/negative_anchor_algo_placement.md
@@ -1,8 +1,7 @@
# Negative Fixture: Algorithm Anchor Placement
-Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_algo_placement.md`
-
-Expect: allow-custom-anchors (algorithm anchor must be inside Algorithm section, etc.).
+
+
## Bad Algorithm Anchor Placement
diff --git a/md_test_files/negative_anchor_invalid_id.md b/md_test_files/negative_anchor_invalid_id.md
index 4109793..832872f 100644
--- a/md_test_files/negative_anchor_invalid_id.md
+++ b/md_test_files/negative_anchor_invalid_id.md
@@ -1,6 +1,6 @@
# Negative Fixture: Anchor Invalid ID
-Expect: `allow-custom-anchors` (id does not match any allowedIdPatterns).
+
## Bad Anchor ID
diff --git a/md_test_files/negative_anchor_multiple.md b/md_test_files/negative_anchor_multiple.md
index 593eb41..57b85e6 100644
--- a/md_test_files/negative_anchor_multiple.md
+++ b/md_test_files/negative_anchor_multiple.md
@@ -1,8 +1,7 @@
# Negative Fixture: Multiple Anchors per Line
-Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_multiple.md`
-
-Expect: allow-custom-anchors (only one anchor per line), MD032 (blanks around lists).
+
+
## Multiple Anchors per Line
diff --git a/md_test_files/negative_anchor_ref_placement.md b/md_test_files/negative_anchor_ref_placement.md
index 2d4ec3d..ab51327 100644
--- a/md_test_files/negative_anchor_ref_placement.md
+++ b/md_test_files/negative_anchor_ref_placement.md
@@ -1,8 +1,7 @@
# Negative Fixture: Reference Anchor Placement
-Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_ref_placement.md`
-
-Expect: allow-custom-anchors (ref anchor placement), MD031 (blanks around fences).
+
+
## Bad Reference Anchor Placement
diff --git a/md_test_files/negative_anchor_spec_placement.md b/md_test_files/negative_anchor_spec_placement.md
index 2ba675d..119339a 100644
--- a/md_test_files/negative_anchor_spec_placement.md
+++ b/md_test_files/negative_anchor_spec_placement.md
@@ -1,8 +1,7 @@
# Negative Fixture: Spec Anchor Placement
-Lint: `npx markdownlint-cli2 md_test_files/negative_anchor_spec_placement.md`
-
-Expect: allow-custom-anchors (spec anchor must be on Spec ID list item line).
+
+
## Bad Spec Anchor Placement
diff --git a/md_test_files/negative_ascii_only.md b/md_test_files/negative_ascii_only.md
index 5d856b7..eb481dd 100644
--- a/md_test_files/negative_ascii_only.md
+++ b/md_test_files/negative_ascii_only.md
@@ -1,6 +1,6 @@
# Negative Fixture: ASCII-Only
-This file intentionally contains non-ASCII characters to trigger the ascii-only rule.
+
## ASCII-Only Tests
diff --git a/md_test_files/negative_duplicate_headings_normalized.md b/md_test_files/negative_duplicate_headings_normalized.md
index b18d5b1..f269f42 100644
--- a/md_test_files/negative_duplicate_headings_normalized.md
+++ b/md_test_files/negative_duplicate_headings_normalized.md
@@ -1,6 +1,6 @@
# Negative Fixture: Duplicate Headings (Normalized)
-Expect: `no-duplicate-headings-normalized` (same title after stripping numbering/normalization).
+
## 1. Introduction
diff --git a/md_test_files/negative_heading_like.md b/md_test_files/negative_heading_like.md
index 6a55fe7..41a6dfb 100644
--- a/md_test_files/negative_heading_like.md
+++ b/md_test_files/negative_heading_like.md
@@ -1,8 +1,7 @@
# Negative Fixture: Heading-Like Lines
-Lint: `npx markdownlint-cli2 md_test_files/negative_heading_like.md`
-
-Expect: `no-heading-like-lines`.
+
+
## Pseudo-Headings
diff --git a/md_test_files/negative_heading_numbering.md b/md_test_files/negative_heading_numbering.md
index ebb0339..a4b21ae 100644
--- a/md_test_files/negative_heading_numbering.md
+++ b/md_test_files/negative_heading_numbering.md
@@ -1,8 +1,7 @@
# Negative Fixture: Heading Numbering
-Lint: `npx markdownlint-cli2 md_test_files/negative_heading_numbering.md`
-
-Expect: heading-numbering (segment count, sequence, period style, unnumbered sibling, zero-indexed violations). First block is H3/H4 under one H2; second block covers zero-indexed numbering errors.
+
+
## Bad Heading Numbering
@@ -10,12 +9,20 @@ Expect: heading-numbering (segment count, sequence, period style, unnumbered sib
#### 1.1 First Subsection
+Content.
+
#### 1.3 Skip 1.2 (Non-Sequential; Expected 1.2)
+Content.
+
#### 1.3. Has Period but Should Not
+Content.
+
### Unnumbered Sibling (Section Uses Numbering; Add Number Prefix)
+Content.
+
### 2 No Period (Inconsistent With ### 1.)
Wrong segment count: H3 under H2 must have 1 segment, not 2.
@@ -24,12 +31,16 @@ Wrong segment count: H3 under H2 must have 1 segment, not 2.
#### 1.1.1 Too Many Segments for H4
+Content.
+
## Zero-Indexed Numbering (Negative Examples)
0-based section: first heading is 0., so sequence must be 0, 1, 2, ... Violations below.
### 0. Zero-Based Section
+Content.
+
### 2. Skip 1 (0-Based Sequence; Expected 1.)
0-based subsections under 0.: first is 0.0., so next must be 0.1., not 0.2.
@@ -38,4 +49,8 @@ Wrong segment count: H3 under H2 must have 1 segment, not 2.
#### 0.0. First Sub
+Content.
+
#### 0.2. Skip 0.1 (Expected 0.1.)
+
+Content.
diff --git a/md_test_files/negative_heading_title_case.md b/md_test_files/negative_heading_title_case.md
index 04a3f98..3b93080 100644
--- a/md_test_files/negative_heading_title_case.md
+++ b/md_test_files/negative_heading_title_case.md
@@ -1,8 +1,7 @@
# Negative Fixture: Heading Title Case
-Lint: `npx markdownlint-cli2 md_test_files/negative_heading_title_case.md`
-
-Expect: `heading-title-case` (first word lowercase; middle "and" capitalized; last word lowercase).
+
+
## getting started
diff --git a/md_test_files/negative_inline_html.md b/md_test_files/negative_inline_html.md
index 05e8a69..0e6773d 100644
--- a/md_test_files/negative_inline_html.md
+++ b/md_test_files/negative_inline_html.md
@@ -1,8 +1,7 @@
# Negative Fixture: Inline HTML and Anchor Basics
-Lint: `npx markdownlint-cli2 md_test_files/negative_inline_html.md`
-
-Expect: MD033, allow-custom-anchors (wrong attribute, bad id, not end-of-line).
+
+
## Bad Inline HTML and Anchors
diff --git a/md_test_files/negative_no_empty_heading.md b/md_test_files/negative_no_empty_heading.md
new file mode 100644
index 0000000..eb7cc4e
--- /dev/null
+++ b/md_test_files/negative_no_empty_heading.md
@@ -0,0 +1,35 @@
+# Negative Fixture: No Empty Heading (H2+)
+
+- [Section With Content](#section-with-content)
+- [Empty H2](#empty-h2)
+- [Next Section](#next-section)
+
+## Section With Content
+
+This paragraph is content, so this H2 is valid.
+
+## Empty H2
+
+## Next Section
+
+Content here.
+
+### Empty H3
+
+### H3 With Content
+
+Valid content.
+
+## Another Empty H2 at End
+
+## Empty With Wrong Comment
+
+
+
+## Empty With Suppress on Same Line
+
+
+
+## Empty With Wrong-Format Suppress
+
+
diff --git a/md_test_files/negative_no_h1_content.md b/md_test_files/negative_no_h1_content.md
new file mode 100644
index 0000000..735d611
--- /dev/null
+++ b/md_test_files/negative_no_h1_content.md
@@ -0,0 +1,7 @@
+# Negative Fixture: No H1 Content
+
+This paragraph is under the first h1 and is not a table of contents, so no-h1-content should report it.
+
+## Section
+
+Content under a proper heading is fine.
diff --git a/md_test_files/positive_general.md b/md_test_files/positive_general.md
index abddd35..2bcef63 100644
--- a/md_test_files/positive_general.md
+++ b/md_test_files/positive_general.md
@@ -1,11 +1,35 @@
# Markdown Standards Fixtures - Positive Cases
-This file contains examples that should pass the Markdown standards enforced by markdownlint and the Python validators.
-
-It is intended to be linted explicitly via `npx markdownlint-cli2 md_test_files/positive_general.md`.
+[](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml)
+
+- [Formatting](#formatting)
+- [Lists](#lists)
+- [Links](#links)
+- [Inline Code](#inline-code)
+- [Code Blocks](#code-blocks)
+- [Unicode/Emoji in Code (No Ascii-Only Errors)](#unicodeemoji-in-code-no-ascii-only-errors)
+- [Allowed Inline HTML Anchors](#allowed-inline-html-anchors)
+ - [Spec Anchor](#spec-anchor)
+ - [Reference Anchor](#reference-anchor)
+ - [Algorithm Anchor and Step Anchors](#algorithm-anchor-and-step-anchors)
+- [Headings](#headings)
+ - [1. Heading Numbering Example](#1-heading-numbering-example)
+ - [2. Parentheses and Brackets (Are Fine) in Headings](#2-parentheses-and-brackets-are-fine-in-headings)
+ - [3. Using Tools (In Practice)](#3-using-tools-in-practice)
+ - [4. How to Do a Follow-Up](#4-how-to-do-a-follow-up)
+ - [5. Summary: The Results](#5-summary-the-results)
+- [Zero-Indexed Heading Numbering](#zero-indexed-heading-numbering)
+ - [0. Introduction (Zero-Indexed)](#0-introduction-zero-indexed)
+ - [1. First Topic](#1-first-topic)
+ - [2. Second Topic](#2-second-topic)
+- [Intentionally Empty (Suppressed)](#intentionally-empty-suppressed)
+- [Intentionally Empty With Other Comments (Suppressed)](#intentionally-empty-with-other-comments-suppressed)
## Formatting
+This file contains examples that should pass the Markdown standards enforced by markdownlint and the Python validators.
+It is intended to be linted explicitly via `npx markdownlint-cli2 md_test_files/positive_general.md`.
+
This paragraph has one sentence.
This is another sentence on its own line.
@@ -72,6 +96,8 @@ func (p *Package) ReadFile(path string) ([]byte, error)
### Algorithm Anchor and Step Anchors
+Some content here.
+
#### 1. `Package.ReadFile` Algorithm
@@ -117,12 +143,29 @@ Content under first 0-based H3.
#### 0.1 Zero Indexed Section
+Some content here.
+
### 1. First Topic
+Some content here.
+
### 2. Second Topic
Subsections under a 0-based H3 also use 0-based numbering when the first subheading is 0.
#### 2.1. First Subsection
+Some content here.
+
#### 2.2. Second Subsection
+
+Some content here.
+
+## Intentionally Empty (Suppressed)
+
+
+
+## Intentionally Empty With Other Comments (Suppressed)
+
+
+
diff --git a/md_test_files/positive_general_index.md b/md_test_files/positive_general_index.md
new file mode 100644
index 0000000..a1ccd13
--- /dev/null
+++ b/md_test_files/positive_general_index.md
@@ -0,0 +1,8 @@
+# Index Page (No-Empty-Heading Excluded)
+
+- [Section A](#section-a)
+- [Section B](#section-b)
+
+## Section A
+
+## Section B
diff --git a/md_test_files/positive_heading_numbering_zero.md b/md_test_files/positive_heading_numbering_zero.md
index bdfbcec..e1b462c 100644
--- a/md_test_files/positive_heading_numbering_zero.md
+++ b/md_test_files/positive_heading_numbering_zero.md
@@ -1,8 +1,14 @@
# Positive Fixture: 0-Indexed H2 Numbering
-Lint: `npx markdownlint-cli2 md_test_files/positive_heading_numbering_zero.md`
+
+
-This file should pass with zero errors. The first H2 is 0-indexed, so the rule treats the top-level section as 0-based.
+- [0. Introduction](#0-introduction)
+- [1. First Section](#1-first-section)
+ - [1.0 First Section First Subsection](#10-first-section-first-subsection)
+- [2. Second Section](#2-second-section)
+ - [2.0. First Subsection](#20-first-subsection)
+ - [2.1. Second Subsection](#21-second-subsection)
## 0. Introduction
@@ -22,4 +28,8 @@ Subsections under this H2 use 0-based numbering when the first is 2.0.
### 2.0. First Subsection
+Some content here.
+
### 2.1. Second Subsection
+
+Some content here.
diff --git a/test-scripts/README.md b/test-scripts/README.md
index ca6774b..d713450 100644
--- a/test-scripts/README.md
+++ b/test-scripts/README.md
@@ -1,9 +1,13 @@
# Test Scripts
-This directory contains Python scripts used to support this repository's test suite and development workflow.
+- [Scripts](#scripts)
+- [Requirements](#requirements)
+- [Usage](#usage)
## Scripts
+This directory contains Python scripts used to support this repository's test suite and development workflow.
+
- `verify_markdownlint_fixtures.py`
- Verifies markdownlint test fixtures in `md_test_files/` against expectations in `md_test_files/expected_errors.yml`.
- Positive fixtures (`positive_*.md`) must pass with zero errors; negative fixtures (`negative_*.md`) must fail with the errors listed in `expected_errors.yml`.
diff --git a/test-scripts/verify_markdownlint_fixtures.py b/test-scripts/verify_markdownlint_fixtures.py
index cc09aa5..5943c76 100644
--- a/test-scripts/verify_markdownlint_fixtures.py
+++ b/test-scripts/verify_markdownlint_fixtures.py
@@ -64,8 +64,10 @@ def find_markdownlint_cmd() -> List[str]:
def ensure_long_document_fixture(path: Path) -> None:
"""Write a markdown file with LONG_FIXTURE_LINES lines so document-length rule fails."""
- # Blank after heading to satisfy MD022; trailing newline to satisfy MD047
- lines = ["# Title", ""] + [f"line {i}" for i in range(3, LONG_FIXTURE_LINES + 1)]
+ # H2 before generated lines so content under h1 is only blank (no-h1-content passes).
+ header = ["# Title", "", "## Section", ""]
+ body_start = len(header) + 1
+ lines = header + [f"line {i}" for i in range(body_start, LONG_FIXTURE_LINES + 1)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -323,7 +325,7 @@ def main() -> int:
failures: List[str] = []
for f in files:
- if f.name == "negative_document_length.md" and not f.exists():
+ if f.name == "negative_document_length.md":
ensure_long_document_fixture(f)
if verbose:
exp_total = 0
diff --git a/test/markdownlint-rules/no-empty-heading.test.js b/test/markdownlint-rules/no-empty-heading.test.js
new file mode 100644
index 0000000..9b1bef1
--- /dev/null
+++ b/test/markdownlint-rules/no-empty-heading.test.js
@@ -0,0 +1,163 @@
+"use strict";
+
+/**
+ * Unit tests for no-empty-heading: H2+ headings must have at least one
+ * line of content before the next same-or-higher-level heading.
+ */
+
+const { describe, it } = require("node:test");
+const assert = require("node:assert");
+const rule = require("../../markdownlint-rules/no-empty-heading.js");
+const { runRule } = require("./run-rule.js");
+
+describe("no-empty-heading", () => {
+ it("reports no errors when no H2+ headings", () => {
+ const lines = ["# Title", "Content only."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when H2 has content", () => {
+ const lines = ["# Doc", "## Section", "Some prose here.", "## Next", "More."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors when H3 has content", () => {
+ const lines = ["# Doc", "## A", "Content.", "## B", "### B1", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error for H2 with no content before next H2", () => {
+ const lines = ["# Doc", "## Empty", "", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ assert.ok(errors[0].detail.includes("at least one line of content"));
+ });
+
+ it("reports error for H2 with only blank lines before next heading", () => {
+ const lines = ["# Doc", "## Empty", "", "", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ });
+
+ it("reports error for H2 at end of file with no content", () => {
+ const lines = ["# Doc", "## A", "Content.", "## Empty"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 4);
+ });
+
+ it("reports error for H3 with no content before next H3", () => {
+ const lines = ["# Doc", "## Section", "", "### Empty", "", "### Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 4);
+ });
+
+ it("reports error for H2 with only HTML comment as content", () => {
+ const lines = ["# Doc", "## Empty", "", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ });
+
+ it("reports no error when section has only the suppress comment (no-empty-heading allow)", () => {
+ const lines = ["# Doc", "## Empty", "", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no error when suppress comment has optional whitespace", () => {
+ const lines = ["# Doc", "## Empty", " ", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no error when section has only cleared-form suppress comment (markdownlint clears HTML comment text)", () => {
+ const lines = ["# Doc", "## Empty", "", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no error when section has other HTML comments plus suppress comment on its own line", () => {
+ const lines = [
+ "# Doc",
+ "## Empty",
+ "",
+ "",
+ "## Next",
+ "Content.",
+ ];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error when suppress comment is on same line as another comment (must be on its own line)", () => {
+ const lines = ["# Doc", "## Empty", " ", "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ });
+
+ it("reports error when comment is similar but not the exact suppress format", () => {
+ const badComments = [
+ "", // colon not allowed
+ "", // missing space before allow
+ "", // missing allow
+ ];
+ for (const comment of badComments) {
+ const lines = ["# Doc", "## Empty", comment, "## Next", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1, `Expected error for comment: ${comment}`);
+ }
+ });
+
+ it("reports multiple errors for multiple empty H2+ headings", () => {
+ const lines = [
+ "# Doc",
+ "## A",
+ "Content.",
+ "## Empty1",
+ "",
+ "## Empty2",
+ "",
+ "## B",
+ "Content.",
+ ];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 2);
+ const lineNumbers = errors.map((e) => e.lineNumber).sort((a, b) => a - b);
+ assert.deepStrictEqual(lineNumbers, [4, 6]);
+ });
+
+ it("does not report H1 (level 1)", () => {
+ const lines = ["# Title", "## Only section", "Content."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("skips when file path matches excludePathPatterns", () => {
+ const lines = ["# Doc", "## Empty", "", "## Next", "Content."];
+ const config = { excludePathPatterns: ["**/foo.md"] };
+ const errors = runRule(rule, lines, config, "md_test_files/foo.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("runs when file path does not match excludePathPatterns", () => {
+ const lines = ["# Doc", "## Empty", "", "## Next", "Content."];
+ const config = { excludePathPatterns: ["**/other.md"] };
+ const errors = runRule(rule, lines, config, "md_test_files/foo.md");
+ assert.strictEqual(errors.length, 1);
+ });
+
+ it("skips when file path matches excludePathPatterns **/*_index.md", () => {
+ const lines = ["# Index", "## Empty", "", "## Next", "Content."];
+ const config = { excludePathPatterns: ["**/*_index.md"] };
+ const errors = runRule(rule, lines, config, "md_test_files/positive_general_index.md");
+ assert.strictEqual(errors.length, 0);
+ });
+});
diff --git a/test/markdownlint-rules/no-h1-content.test.js b/test/markdownlint-rules/no-h1-content.test.js
new file mode 100644
index 0000000..5212b6c
--- /dev/null
+++ b/test/markdownlint-rules/no-h1-content.test.js
@@ -0,0 +1,103 @@
+"use strict";
+
+/**
+ * Unit tests for no-h1-content: under the first h1, only TOC (blank lines,
+ * list-of-links, HTML comments) is allowed; any other content is reported.
+ */
+
+const { describe, it } = require("node:test");
+const assert = require("node:assert");
+const rule = require("../../markdownlint-rules/no-h1-content.js");
+const { runRule } = require("./run-rule.js");
+
+describe("no-h1-content", () => {
+ it("reports no errors when no h1", () => {
+ const lines = ["## Section", "Some prose here."];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for h1 followed only by TOC (list-of-links)", () => {
+ const lines = [
+ "# Title",
+ "",
+ "- [One](#one)",
+ "- [Two](#two)",
+ "",
+ "## One",
+ "Content.",
+ ];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for ordered TOC list", () => {
+ const lines = ["# Doc", "1. [A](#a)", "2. [B](#b)", "## A"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for HTML comments under h1", () => {
+ const lines = ["# Title", "", "- [X](#x)", "", "## X"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for badge lines under h1", () => {
+ const lines = [
+ "# Repo",
+ "[](https://example.com)",
+ "[](LICENSE)",
+ "## Section",
+ ];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports no errors for empty content under h1", () => {
+ const lines = ["# Title", "", "## First"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("reports error for prose under h1", () => {
+ const lines = ["# Title", "This is not allowed.", "## Section"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ assert.ok(errors[0].detail.includes("table of contents"));
+ });
+
+ it("reports error for code block under h1", () => {
+ const lines = ["# Title", "```", "code", "```", "## Next"];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 3);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ });
+
+ it("reports only lines in h1 block (before next heading)", () => {
+ const lines = [
+ "# Title",
+ "Not allowed.",
+ "## Section",
+ "This is under h2, so allowed by this rule.",
+ ];
+ const errors = runRule(rule, lines);
+ assert.strictEqual(errors.length, 1);
+ assert.strictEqual(errors[0].lineNumber, 2);
+ });
+
+ it("skips when file path matches excludePathPatterns", () => {
+ const lines = ["# Title", "Prose under h1."];
+ const config = { excludePathPatterns: ["**/foo.md"] };
+ const errors = runRule(rule, lines, config, "md_test_files/foo.md");
+ assert.strictEqual(errors.length, 0);
+ });
+
+ it("runs when file path does not match excludePathPatterns", () => {
+ const lines = ["# Title", "Prose under h1."];
+ const config = { excludePathPatterns: ["**/other.md"] };
+ const errors = runRule(rule, lines, config, "md_test_files/foo.md");
+ assert.strictEqual(errors.length, 1);
+ });
+});