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 @@ [![Python tests](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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. `[![alt](url)](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): [![alt](img-url)](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. +[![Python tests](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml/badge.svg?branch=main)](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`. +[![Python tests](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml/badge.svg?branch=main)](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", + "[![CI](https://example.com/ci.svg)](https://example.com)", + "[![License](https://img.shields.io/badge/License-MIT-blue.svg)](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); + }); +});