feat: normalize generated markdown for markdownlint compliance (#1112)
Add normalizeMd() utility that fixes common markdownlint violations in GSD-generated .planning/ files, improving IDE readability: MD022 — Blank lines around headings MD031 — Blank lines around fenced code blocks MD032 — Blank lines around lists MD012 — No multiple consecutive blank lines (collapsed to max 2) MD047 — Files end with a single newline Applied at key write points: - state.cjs: writeStateMd() normalizes STATE.md on every write - frontmatter.cjs: frontmatter set/merge commands normalize output - template.cjs: template fill normalizes generated plan/summary files - milestone.cjs: MILESTONES.md archive writes normalized Includes 14 new tests covering all rules, edge cases (CRLF, frontmatter, ordered lists, code blocks, real-world STATE.md patterns). Closes #1040
This commit is contained in:
@@ -131,6 +131,110 @@ function isGitIgnored(cwd, targetPath) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Markdown normalization ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize markdown to fix common markdownlint violations.
|
||||
* Applied at write points so GSD-generated .planning/ files are IDE-friendly.
|
||||
*
|
||||
* Rules enforced:
|
||||
* MD022 — Blank lines around headings
|
||||
* MD031 — Blank lines around fenced code blocks
|
||||
* MD032 — Blank lines around lists
|
||||
* MD012 — No multiple consecutive blank lines (collapsed to 2 max)
|
||||
* MD047 — Files end with a single newline
|
||||
*/
|
||||
function normalizeMd(content) {
|
||||
if (!content || typeof content !== 'string') return content;
|
||||
|
||||
// Normalize line endings to LF for consistent processing
|
||||
let text = content.replace(/\r\n/g, '\n');
|
||||
|
||||
const lines = text.split('\n');
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const prev = i > 0 ? lines[i - 1] : '';
|
||||
const prevTrimmed = prev.trimEnd();
|
||||
const trimmed = line.trimEnd();
|
||||
|
||||
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
|
||||
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
|
||||
result.push('');
|
||||
}
|
||||
|
||||
// MD031: Blank line before fenced code blocks
|
||||
if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
|
||||
result.push('');
|
||||
}
|
||||
|
||||
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
|
||||
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
|
||||
prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
|
||||
prevTrimmed !== '---') {
|
||||
result.push('');
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
|
||||
// MD022: Blank line after headings
|
||||
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
|
||||
const next = lines[i + 1];
|
||||
if (next !== undefined && next.trimEnd() !== '') {
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// MD031: Blank line after closing fenced code blocks
|
||||
if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
|
||||
const next = lines[i + 1];
|
||||
if (next !== undefined && next.trimEnd() !== '') {
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// MD032: Blank line after last list item in a block
|
||||
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
||||
const next = lines[i + 1];
|
||||
if (next !== undefined && next.trimEnd() !== '' &&
|
||||
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
|
||||
!/^\s/.test(next)) {
|
||||
// Only add blank line if next line is not a continuation/indented line
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text = result.join('\n');
|
||||
|
||||
// MD012: Collapse 3+ consecutive blank lines to 2
|
||||
text = text.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// MD047: Ensure file ends with exactly one newline
|
||||
text = text.replace(/\n*$/, '\n');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Check if line index i is inside an already-open fenced code block */
|
||||
function isInsideFencedBlock(lines, i) {
|
||||
let fenceCount = 0;
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
||||
}
|
||||
return fenceCount % 2 === 1;
|
||||
}
|
||||
|
||||
/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
|
||||
function isClosingFence(lines, i) {
|
||||
let fenceCount = 0;
|
||||
for (let j = 0; j <= i; j++) {
|
||||
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
||||
}
|
||||
return fenceCount % 2 === 0;
|
||||
}
|
||||
|
||||
function execGit(cwd, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
@@ -479,6 +583,7 @@ module.exports = {
|
||||
loadConfig,
|
||||
isGitIgnored,
|
||||
execGit,
|
||||
normalizeMd,
|
||||
escapeRegex,
|
||||
normalizePhaseName,
|
||||
comparePhaseNum,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { safeReadFile, output, error } = require('./core.cjs');
|
||||
const { safeReadFile, normalizeMd, output, error } = require('./core.cjs');
|
||||
|
||||
// ─── Parsing engine ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -255,7 +255,7 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
||||
try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
|
||||
fm[field] = parsedValue;
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
fs.writeFileSync(fullPath, newContent, 'utf-8');
|
||||
fs.writeFileSync(fullPath, normalizeMd(newContent), 'utf-8');
|
||||
output({ updated: true, field, value: parsedValue }, raw, 'true');
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
||||
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
|
||||
Object.assign(fm, mergeData);
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
fs.writeFileSync(fullPath, newContent, 'utf-8');
|
||||
fs.writeFileSync(fullPath, normalizeMd(newContent), 'utf-8');
|
||||
output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, getMilestonePhaseFilter, output, error } = require('./core.cjs');
|
||||
const { escapeRegex, getMilestonePhaseFilter, normalizeMd, output, error } = require('./core.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd } = require('./state.cjs');
|
||||
|
||||
@@ -169,21 +169,21 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
const existing = fs.readFileSync(milestonesPath, 'utf-8');
|
||||
if (!existing.trim()) {
|
||||
// Empty file — treat like new
|
||||
fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8');
|
||||
fs.writeFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
|
||||
} else {
|
||||
// Insert after the header line(s) for reverse chronological order (newest first)
|
||||
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[1];
|
||||
const rest = existing.slice(header.length);
|
||||
fs.writeFileSync(milestonesPath, header + milestoneEntry + rest, 'utf-8');
|
||||
fs.writeFileSync(milestonesPath, normalizeMd(header + milestoneEntry + rest), 'utf-8');
|
||||
} else {
|
||||
// No recognizable header — prepend the entry
|
||||
fs.writeFileSync(milestonesPath, milestoneEntry + existing, 'utf-8');
|
||||
fs.writeFileSync(milestonesPath, normalizeMd(milestoneEntry + existing), 'utf-8');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8');
|
||||
fs.writeFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
|
||||
}
|
||||
|
||||
// Update STATE.md
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
|
||||
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, output, error } = require('./core.cjs');
|
||||
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
||||
|
||||
// Shared helper: extract a field value from STATE.md content.
|
||||
@@ -680,7 +680,7 @@ function syncStateFrontmatter(content, cwd) {
|
||||
*/
|
||||
function writeStateMd(statePath, content, cwd) {
|
||||
const synced = syncStateFrontmatter(content, cwd);
|
||||
fs.writeFileSync(statePath, synced, 'utf-8');
|
||||
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
}
|
||||
|
||||
function cmdStateJson(cwd, raw) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosixPath, output, error } = require('./core.cjs');
|
||||
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, output, error } = require('./core.cjs');
|
||||
const { reconstructFrontmatter } = require('./frontmatter.cjs');
|
||||
|
||||
function cmdTemplateSelect(cwd, planPath, raw) {
|
||||
@@ -214,7 +214,7 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outPath, fullContent, 'utf-8');
|
||||
fs.writeFileSync(outPath, normalizeMd(fullContent), 'utf-8');
|
||||
const relPath = toPosixPath(path.relative(cwd, outPath));
|
||||
output({ created: true, path: relPath, template: templateType }, raw, relPath);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
escapeRegex,
|
||||
generateSlugInternal,
|
||||
normalizePhaseName,
|
||||
normalizeMd,
|
||||
comparePhaseNum,
|
||||
safeReadFile,
|
||||
pathExistsInternal,
|
||||
@@ -814,3 +815,112 @@ describe('getMilestonePhaseFilter', () => {
|
||||
assert.strictEqual(filter.phaseCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeMd ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd', () => {
|
||||
test('returns null/undefined/empty unchanged', () => {
|
||||
assert.strictEqual(normalizeMd(null), null);
|
||||
assert.strictEqual(normalizeMd(undefined), undefined);
|
||||
assert.strictEqual(normalizeMd(''), '');
|
||||
});
|
||||
|
||||
test('MD022: adds blank lines around headings', () => {
|
||||
const input = 'Some text\n## Heading\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('\n\n## Heading\n\n'), 'heading should have blank lines around it');
|
||||
});
|
||||
|
||||
test('MD032: adds blank line before list after non-list content', () => {
|
||||
const input = 'Some text\n- item 1\n- item 2\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Some text\n\n- item 1'), 'list should have blank line before it');
|
||||
});
|
||||
|
||||
test('MD032: adds blank line after list before non-list content', () => {
|
||||
const input = '- item 1\n- item 2\nSome text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('- item 2\n\nSome text'), 'list should have blank line after it');
|
||||
});
|
||||
|
||||
test('MD032: does not add extra blank lines between list items', () => {
|
||||
const input = '- item 1\n- item 2\n- item 3\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('- item 1\n- item 2\n- item 3'), 'consecutive list items should not get blank lines');
|
||||
});
|
||||
|
||||
test('MD031: adds blank lines around fenced code blocks', () => {
|
||||
const input = 'Some text\n```js\ncode\n```\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
|
||||
});
|
||||
|
||||
test('MD012: collapses 3+ consecutive blank lines to 2', () => {
|
||||
const input = 'Line 1\n\n\n\n\nLine 2\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
|
||||
assert.ok(result.includes('Line 1\n\nLine 2'), 'should collapse to double newline');
|
||||
});
|
||||
|
||||
test('MD047: ensures file ends with single newline', () => {
|
||||
const input = 'Content';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.endsWith('\n'), 'should end with newline');
|
||||
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
|
||||
});
|
||||
|
||||
test('MD047: trims trailing multiple newlines', () => {
|
||||
const input = 'Content\n\n\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.endsWith('Content\n'), 'should end with single newline after content');
|
||||
});
|
||||
|
||||
test('preserves frontmatter delimiters', () => {
|
||||
const input = '---\nkey: value\n---\n\n# Heading\n\nContent\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.startsWith('---\n'), 'should preserve opening frontmatter');
|
||||
assert.ok(result.includes('---\n\n# Heading'), 'should preserve frontmatter closing');
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
const input = 'Some text\r\n## Heading\r\nMore text\r\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(!result.includes('\r'), 'should normalize to LF');
|
||||
assert.ok(result.includes('\n\n## Heading\n\n'), 'should add blank lines around heading');
|
||||
});
|
||||
|
||||
test('handles ordered lists', () => {
|
||||
const input = 'Some text\n1. First\n2. Second\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Some text\n\n1. First'), 'ordered list should have blank line before');
|
||||
});
|
||||
|
||||
test('does not add blank line between table and list', () => {
|
||||
const input = '| Col |\n|-----|\n| val |\n- item\n';
|
||||
const result = normalizeMd(input);
|
||||
// Table rows start with |, should not add extra blank before list after table
|
||||
assert.ok(result.includes('| val |\n\n- item'), 'list after table should have blank line');
|
||||
});
|
||||
|
||||
test('complex real-world STATE.md-like content', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'## Current Position',
|
||||
'Phase: 5 of 10',
|
||||
'Status: Executing',
|
||||
'## Decisions',
|
||||
'- Decision 1',
|
||||
'- Decision 2',
|
||||
'## Blockers',
|
||||
'None',
|
||||
].join('\n');
|
||||
const result = normalizeMd(input);
|
||||
// Every heading should have blank lines around it
|
||||
assert.ok(result.includes('\n\n## Current Position\n\n'), 'section heading needs blank lines');
|
||||
assert.ok(result.includes('\n\n## Decisions\n\n'), 'decisions heading needs blank lines');
|
||||
assert.ok(result.includes('\n\n## Blockers\n\n'), 'blockers heading needs blank lines');
|
||||
// List should have blank line before it
|
||||
assert.ok(result.includes('\n\n- Decision 1'), 'list needs blank line before');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user