Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
16 changes: 16 additions & 0 deletions .markdownlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# "<!-- no-empty-heading allow -->" 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
Expand Down
6 changes: 4 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
18 changes: 16 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a id="..."></a>` whose ids match configured regex patterns; optional placement (heading match, line match, require-after, max per section).
Expand All @@ -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 `<!-- no-empty-heading allow -->` 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.
Expand Down
55 changes: 52 additions & 3 deletions markdownlint-rules/README.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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"
]
}
```
Expand Down Expand Up @@ -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 `<!-- no-empty-heading allow -->` (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. `<!-- no-empty-heading: allow -->`) 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`
Expand Down
108 changes: 108 additions & 0 deletions markdownlint-rules/no-empty-heading.js
Original file line number Diff line number Diff line change
@@ -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: <!-- no-empty-heading allow --> (optional whitespace). */
const RE_SUPPRESS_COMMENT_RAW = /^\s*<!--\s*no-empty-heading\s+allow\s*-->\s*$/;

/** Match markdownlint-cleared form: comment text is replaced with dots (e.g. "................ ....."). */
const RE_SUPPRESS_COMMENT_CLEARED = /^\s*<!--\s*\.{16}\s+\.{5}\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 "<!-- no-empty-heading allow -->" 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 "<!-- no-empty-heading allow -->" 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,
};
85 changes: 85 additions & 0 deletions markdownlint-rules/no-h1-content.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading