Skip to content

Commit 9ef5c7c

Browse files
committed
fix(heading-title-case): treat single letter after 'Phase' as phase label
A single letter immediately after the word 'Phase' (e.g. Phase A:, Phase B) is now treated as a phase label and kept capitalized. Previously the article 'a' in the default minor-words list caused 'Phase A' to be flagged or fixed to 'Phase a'.
1 parent 3ae89a4 commit 9ef5c7c

5 files changed

Lines changed: 75 additions & 11 deletions

File tree

‎markdownlint-rules/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ Example for a repo that has copied rules into `.markdownlint-rules/`:
9494
"./.markdownlint-rules/heading-title-case.js",
9595
"./.markdownlint-rules/no-duplicate-headings-normalized.js",
9696
"./.markdownlint-rules/no-empty-heading.js",
97-
"./.markdownlint-rules/no-heading-like-lines.js",
9897
"./.markdownlint-rules/no-h1-content.js",
98+
"./.markdownlint-rules/no-heading-like-lines.js",
9999
"./.markdownlint-rules/one-sentence-per-line.js"
100100
]
101101
}
@@ -540,6 +540,7 @@ Content inside inline code (backticks) is ignored.
540540

541541
- **First and last words** of the heading must be capitalized (including the first and last segment of hyphenated compounds).
542542
- **First word after a colon** (e.g. `Summary: The Results`) and the **first word inside parentheses or brackets** (e.g. `(in Practice)`, `[optional]`) are treated as phrase starts and must be capitalized even if they are in the lowercase list.
543+
- **Phase labels:** A single letter immediately after the word "Phase" (e.g. `Phase A:`, `Phase B`) is treated as a label and kept capitalized, so the article "a" in the default list does not force lowercase in that context.
543544
- **Hyphenated compounds** (e.g. `One-Stop`, `Follow-Up`) are split on hyphens; each segment is checked as above (first/last of title or minor word in the middle).
544545
- **Minor words** (in the default or configured list) must be lowercase when in the middle of the heading; all other words must be capitalized.
545546
- Leading and trailing punctuation is ignored when evaluating a word (e.g. `(Word)` is evaluated as `Word`).

‎markdownlint-rules/heading-title-case.js‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ function isAllLower(w) {
7070
}
7171

7272
/**
73-
* True if this position is exempt from lowercase (first/last/subphrase start/hyphen compound start).
74-
* @param {{ isFirst: boolean, isLast: boolean, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean }} opts
73+
* True if this position is exempt from lowercase (first/last/subphrase start/hyphen compound start/phase label).
74+
* @param {{ isFirst: boolean, isLast: boolean, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts
7575
* @returns {boolean}
7676
*/
7777
function isExemptFromLowercase(opts) {
78-
const { isFirst, isLast, isSubphraseStart, isHyphenCompoundStart } = opts;
79-
return Boolean(isFirst || isLast || isSubphraseStart || isHyphenCompoundStart);
78+
const { isFirst, isLast, isSubphraseStart, isHyphenCompoundStart, isPhaseLabel } = opts;
79+
return Boolean(isFirst || isLast || isSubphraseStart || isHyphenCompoundStart || isPhaseLabel);
8080
}
8181

8282
/**
@@ -93,7 +93,7 @@ function getCapitalizationKind(opts) {
9393

9494
/**
9595
* Validate one word for title case; returns error detail string or null if valid.
96-
* @param {{ raw: string, core: string, isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean }} opts
96+
* @param {{ raw: string, core: string, isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts
9797
* @returns {string|null}
9898
*/
9999
function checkWord(opts) {
@@ -111,7 +111,7 @@ function checkWord(opts) {
111111
* Return the corrected segment text for fixInfo: lowercase or capitalize per AP rules, preserving punctuation.
112112
* @param {string} rawSeg - Segment as in source (e.g. "And", "(in", "practice)")
113113
* @param {string} core - stripWordPunctuation(rawSeg)
114-
* @param {{ isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean }} opts
114+
* @param {{ isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts
115115
* @returns {string}
116116
*/
117117
function getCorrectedSegment(rawSeg, core, opts) {
@@ -140,13 +140,24 @@ function getSegmentPosition(rawSegments, j) {
140140
return { segmentOffset, segmentLength: rawSegments[j].length };
141141
}
142142

143+
/**
144+
* True when the segment is a single letter immediately after the word "Phase" (phase label).
145+
* @param {number} j - Segment index (must be 0 for first segment of word)
146+
* @param {string} core - stripWordPunctuation(segment)
147+
* @param {string} previousWordCore - Previous word core, lowercased
148+
* @returns {boolean}
149+
*/
150+
function isPhaseLabelSegment(j, core, previousWordCore) {
151+
return j === 0 && core.length === 1 && /^[A-Za-z]$/.test(core) && previousWordCore === "phase";
152+
}
153+
143154
/**
144155
* Check one segment of a word for title-case; returns error result or null.
145-
* @param {{ words: string[], rawSegments: string[], i: number, j: number, wordIsSubphraseStart: boolean, lowercaseWords: Set<string> }} opts
156+
* @param {{ words: string[], rawSegments: string[], i: number, j: number, wordIsSubphraseStart: boolean, lowercaseWords: Set<string>, previousWordCore?: string }} opts
146157
* @returns {{ valid: false, detail: string, wordIndex: number, segmentOffset?: number, segmentLength?: number }|null}
147158
*/
148159
function checkOneSegment(opts) {
149-
const { words, rawSegments, i, j, wordIsSubphraseStart, lowercaseWords } = opts;
160+
const { words, rawSegments, i, j, wordIsSubphraseStart, lowercaseWords, previousWordCore } = opts;
150161
const rawSeg = rawSegments[j];
151162
const core = stripWordPunctuation(rawSeg);
152163
if (!core || !/[a-zA-Z]/.test(core)) return null;
@@ -155,6 +166,7 @@ function checkOneSegment(opts) {
155166
const isLast = i === words.length - 1 && j === rawSegments.length - 1;
156167
const isSubphraseStart = j === 0 && wordIsSubphraseStart;
157168
const isHyphenCompoundStart = rawSegments.length > 1 && j === 0;
169+
const isPhaseLabel = isPhaseLabelSegment(j, core, previousWordCore);
158170

159171
const detail = checkWord({
160172
raw: rawSeg,
@@ -164,6 +176,7 @@ function checkOneSegment(opts) {
164176
lowercaseWords,
165177
isSubphraseStart,
166178
isHyphenCompoundStart,
179+
isPhaseLabel,
167180
});
168181
if (!detail) return null;
169182

@@ -173,6 +186,7 @@ function checkOneSegment(opts) {
173186
lowercaseWords,
174187
isSubphraseStart,
175188
isHyphenCompoundStart,
189+
isPhaseLabel,
176190
});
177191
const seg = getSegmentPosition(rawSegments, j);
178192
return {
@@ -206,6 +220,7 @@ function checkTitleCase(titleText, lowercaseWords) {
206220
const prefix = firstAlphaIdx > 0 ? raw.slice(0, firstAlphaIdx) : "";
207221
const afterColon = i > 0 && words[i - 1].replace(/\s+$/, "").endsWith(":");
208222
const wordIsSubphraseStart = prefix.includes("(") || prefix.includes("[") || afterColon;
223+
const previousWordCore = i > 0 ? stripWordPunctuation(words[i - 1]).toLowerCase() : "";
209224

210225
const rawSegments = raw.split(/-/);
211226
for (let j = 0; j < rawSegments.length; j++) {
@@ -216,6 +231,7 @@ function checkTitleCase(titleText, lowercaseWords) {
216231
j,
217232
wordIsSubphraseStart,
218233
lowercaseWords,
234+
previousWordCore,
219235
});
220236
if (result) errors.push({ detail: result.detail, wordIndex: result.wordIndex, insertText: result.insertText, segmentOffset: result.segmentOffset, segmentLength: result.segmentLength });
221237
}
@@ -245,21 +261,23 @@ function getLowercaseWordsFromOptions(options) {
245261
* Correct one segment of a word for AP title case.
246262
* @param {string} rawSeg - Segment as in source
247263
* @param {string} core - stripWordPunctuation(rawSeg)
248-
* @param {{ i: number, j: number, words: string[], rawSegments: string[], wordIsSubphraseStart: boolean, lowercaseWords: Set<string> }} ctx
264+
* @param {{ i: number, j: number, words: string[], rawSegments: string[], wordIsSubphraseStart: boolean, lowercaseWords: Set<string>, previousWordCore?: string }} ctx
249265
* @returns {string}
250266
*/
251267
function correctSegmentForTitleCase(rawSeg, core, ctx) {
252-
const { i, j, words, rawSegments, wordIsSubphraseStart, lowercaseWords } = ctx;
268+
const { i, j, words, rawSegments, wordIsSubphraseStart, lowercaseWords, previousWordCore } = ctx;
253269
const isFirst = i === 0 && j === 0;
254270
const isLast = i === words.length - 1 && j === rawSegments.length - 1;
255271
const isSubphraseStart = j === 0 && wordIsSubphraseStart;
256272
const isHyphenCompoundStart = rawSegments.length > 1 && j === 0;
273+
const isPhaseLabel = isPhaseLabelSegment(j, core, previousWordCore);
257274
return getCorrectedSegment(rawSeg, core, {
258275
isFirst,
259276
isLast,
260277
lowercaseWords,
261278
isSubphraseStart,
262279
isHyphenCompoundStart,
280+
isPhaseLabel,
263281
});
264282
}
265283

@@ -276,6 +294,7 @@ function processWordForTitleCase(raw, i, words, lowercaseWords) {
276294
const prefix = firstAlphaIdx > 0 ? raw.slice(0, firstAlphaIdx) : "";
277295
const afterColon = i > 0 && words[i - 1].replace(/\s+$/, "").endsWith(":");
278296
const wordIsSubphraseStart = prefix.includes("(") || prefix.includes("[") || afterColon;
297+
const previousWordCore = i > 0 ? stripWordPunctuation(words[i - 1]).toLowerCase() : "";
279298
const rawSegments = raw.split(/-/);
280299
const segmentParts = [];
281300
for (let j = 0; j < rawSegments.length; j++) {
@@ -292,6 +311,7 @@ function processWordForTitleCase(raw, i, words, lowercaseWords) {
292311
rawSegments,
293312
wordIsSubphraseStart,
294313
lowercaseWords,
314+
previousWordCore,
295315
}));
296316
}
297317
return segmentParts.join("-");

‎md_test_files/positive_general.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
- [3. Using Tools (In Practice)](#3-using-tools-in-practice)
1919
- [4. How to Do a Follow-Up](#4-how-to-do-a-follow-up)
2020
- [5. Summary: The Results](#5-summary-the-results)
21+
- [6. Phase A: Fixable Rules and Scripts (One-Time)](#6-phase-a-fixable-rules-and-scripts-one-time)
22+
- [7. This Has a Lowercase a in the Title](#7-this-has-a-lowercase-a-in-the-title)
2123
- [Zero-Indexed Heading Numbering](#zero-indexed-heading-numbering)
2224
- [0. Introduction (Zero-Indexed)](#0-introduction-zero-indexed)
2325
- [1. First Topic](#1-first-topic)
@@ -135,6 +137,14 @@ AP style: hyphenated compounds (each segment capitalized); "to" and "a" lowercas
135137

136138
AP style: first word after a colon is capitalized (subphrase start).
137139

140+
### 6. Phase A: Fixable Rules and Scripts (One-Time)
141+
142+
Single letter after "Phase" is treated as a phase label and stays capitalized (not the article "a").
143+
144+
### 7. This Has a Lowercase a in the Title
145+
146+
Should not catch.
147+
138148
## Zero-Indexed Heading Numbering
139149

140150
When the first numbered heading in a section starts at 0, the rule treats that section as 0-based and does not report errors.

‎test-scripts/test_fix_heading_title_case.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ def test_fix_heading_with_backticks_and_parentheses(self) -> None:
108108
self.assertNotIn("(module ", actual, "(module should have been fixed")
109109
self.assertNotIn(" of file)", actual, "file should have been fixed to File")
110110

111+
def test_phase_a_label_unchanged_by_fix(self) -> None:
112+
"""Single letter after 'Phase' is a label; --fix must not lowercase it."""
113+
content = """# Doc
114+
115+
## Section
116+
117+
Overview.
118+
119+
### Phase A: Fixable Rules and Scripts (One-Time)
120+
121+
Content.
122+
"""
123+
with tempfile.TemporaryDirectory(prefix="fix_heading_title_case_") as tmp:
124+
path = Path(tmp) / "test.md"
125+
path.write_text(content, encoding="utf-8")
126+
proc = _run_markdownlint(path, fix=False)
127+
self.assertEqual(proc.returncode, 0, f"Phase A heading should pass lint: {proc.stderr}")
128+
proc_fix = _run_markdownlint(path, fix=True)
129+
self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}")
130+
actual = path.read_text(encoding="utf-8")
131+
self.assertIn("Phase A:", actual, "Phase A must remain capitalized after --fix")
132+
111133
def test_fix_filenames_in_parens_get_backticks_not_title_case(self) -> None:
112134
"""Filenames like (utils.js, allow-custom-anchors.js) get backticks, not Title Case."""
113135
content_before = """# Suppressions

‎test/markdownlint-rules/heading-title-case.test.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ describe("heading-title-case", () => {
136136
assert.ok(errors[0].detail.includes("the") && errors[0].detail.includes("capitalized"));
137137
});
138138

139+
it("allows single letter after 'Phase' as phase label (e.g. Phase A, Phase B)", () => {
140+
const lines = ["### Phase A: Fixable Rules and Scripts (One-Time)"];
141+
const errors = runRule(rule, lines);
142+
assert.strictEqual(errors.length, 0, "Phase A should not be flagged; single letter after 'Phase' is a label");
143+
});
144+
139145
it("reports no errors for heading that is only inline code (words.length === 0)", () => {
140146
const lines = ["## `code only`"];
141147
const errors = runRule(rule, lines);
@@ -290,6 +296,11 @@ describe("heading-title-case", () => {
290296
assert.strictEqual(result, "A and the B");
291297
});
292298

299+
it("preserves single letter after 'Phase' as phase label", () => {
300+
const result = rule.applyTitleCase("Phase A: Fixable Rules and Scripts (One-Time)");
301+
assert.strictEqual(result, "Phase A: Fixable Rules and Scripts (One-Time)", "Phase A label stays capitalized");
302+
});
303+
293304
it("returns empty or whitespace as-is (words.length === 0 branch)", () => {
294305
assert.strictEqual(rule.applyTitleCase(""), "");
295306
assert.strictEqual(rule.applyTitleCase(" "), " ");

0 commit comments

Comments
 (0)