/** * Changelog append action (DRAFT-001 v0.3.2). * * Templates are the shared contract: this module reads * `templates/changelog-section.md` only for the canonical list of * section names. The actual write is a structured edit: insert * `- ` under `###
` inside `## [Unreleased]`, creating * either heading if absent. Keep-a-Changelog 1.1.0 conventions. * * Pure functions: callers get back `{ targetPath, newContents }`. The * pane handles the actual disk write so it can show a preview diff * first. */ import * as fs from "node:fs"; import * as path from "node:path"; export type ChangelogSection = | "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security"; export const CHANGELOG_SECTIONS: ChangelogSection[] = [ "Added", "Changed", "Fixed", "Removed", "Deprecated", "Security", ]; export interface ChangelogPlan { targetPath: string; relTargetPath: string; newContents: string; originalContents: string; /** True when the file did not exist and is being created from a minimal stub. */ isNew: boolean; } export function planChangelogAppend( focusedAbsPath: string, repoRoot: string, section: ChangelogSection, entryText: string, packageHeading: string | null = null, ): { ok: true; plan: ChangelogPlan } | { ok: false; reason: string } { const trimmed = entryText.trim(); if (trimmed.length === 0) return { ok: false, reason: "empty entry" }; const targetPath = resolveChangelogTarget(focusedAbsPath, repoRoot); let originalContents = ""; let isNew = false; if (fs.existsSync(targetPath)) { try { originalContents = fs.readFileSync(targetPath, "utf8"); } catch (e) { return { ok: false, reason: `cannot read changelog: ${(e as Error).message}` }; } } else { isNew = true; originalContents = seedChangelog(packageHeading ?? path.basename(path.dirname(targetPath))); } const newContents = insertEntry(originalContents, section, trimmed); return { ok: true, plan: { targetPath, relTargetPath: relativeFromCwd(targetPath), newContents, originalContents: isNew ? "" : originalContents, isNew, }, }; } function relativeFromCwd(abs: string): string { const rel = path.relative(process.cwd(), abs); return rel.length > 0 ? rel : abs; } /** * Find the right CHANGELOG.md: * - Walk up from focusedAbsPath. If we cross `packages//` before * hitting `repoRoot`, the target is `packages//CHANGELOG.md`. * - Otherwise the target is `/CHANGELOG.md`. */ function resolveChangelogTarget(focusedAbsPath: string, repoRoot: string): string { const norm = path.normalize(focusedAbsPath); const root = path.normalize(repoRoot); // Try to locate "packages/" in the path under repoRoot. const rel = path.relative(root, norm); const segs = rel.split(path.sep); if (segs.length >= 2 && segs[0] === "packages") { return path.join(root, "packages", segs[1]!, "CHANGELOG.md"); } return path.join(root, "CHANGELOG.md"); } function seedChangelog(name: string): string { return `# Changelog — ${name}\n\nAll notable changes to this package are documented here.\n\nFormat follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\n\n## [Unreleased]\n\n`; } /** * Insert `- ` under `###
` inside `## [Unreleased]`. * Creates the Unreleased heading if absent (just below the H1). Creates * the section heading if absent. New entries are *prepended* under the * section (newest at top), matching how Keep-a-Changelog files are * usually maintained in practice. */ function insertEntry(original: string, section: ChangelogSection, body: string): string { const bullet = `- ${body}`; let content = original; // 1. Ensure `## [Unreleased]` exists. const unrelRe = /^##\s*\[Unreleased\]\s*$/m; if (!unrelRe.test(content)) { // Insert Unreleased just after the H1 + any intro paragraph(s). const h1Match = content.match(/^#\s+.*$/m); if (h1Match) { const h1Idx = content.indexOf(h1Match[0]) + h1Match[0].length; // Skip to the next H2 (or EOF). Insert Unreleased before it. const rest = content.slice(h1Idx); const nextH2 = rest.search(/^##\s+/m); const insertAt = nextH2 === -1 ? content.length : h1Idx + nextH2; const block = "\n\n## [Unreleased]\n\n"; content = content.slice(0, insertAt) + block + content.slice(insertAt); } else { // No H1 at all — prepend a minimal one. content = `# Changelog\n\n## [Unreleased]\n\n` + content; } } // 2. Locate the Unreleased block. Slice to the next H2 (or EOF). const unrelMatch = unrelRe.exec(content)!; const unrelStart = unrelMatch.index; const afterUnrelHeader = unrelStart + unrelMatch[0].length; const rest = content.slice(afterUnrelHeader); const nextH2 = rest.search(/^##\s+/m); const unrelEnd = nextH2 === -1 ? content.length : afterUnrelHeader + nextH2; const unrelBlock = content.slice(afterUnrelHeader, unrelEnd); // 3. Within unrelBlock, find `###
`. If absent, create it. const sectRe = new RegExp(`^###\\s+${section}\\s*$`, "m"); let newBlock: string; if (sectRe.test(unrelBlock)) { const sectMatch = sectRe.exec(unrelBlock)!; const sectStart = sectMatch.index; const afterSectHeader = sectStart + sectMatch[0].length; const inSect = unrelBlock.slice(afterSectHeader); // Find the next ### or end of block. const nextSect = inSect.search(/^###\s+/m); const insertEnd = nextSect === -1 ? unrelBlock.length : afterSectHeader + nextSect; const before = unrelBlock.slice(0, afterSectHeader); const sectionBody = unrelBlock.slice(afterSectHeader, insertEnd); const after = unrelBlock.slice(insertEnd); // Prepend the bullet at the top of the section body. const padHeader = before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"; const trimmedSectionBody = sectionBody.replace(/^\n+/, ""); const padBody = trimmedSectionBody.length > 0 ? "\n" : ""; newBlock = before + padHeader + bullet + padBody + (trimmedSectionBody.length > 0 ? "\n" + trimmedSectionBody : "") + after; } else { // Append a new section at the bottom of the Unreleased block (above the // next H2, if any). const padHeader = unrelBlock.endsWith("\n\n") ? "" : unrelBlock.endsWith("\n") ? "\n" : "\n\n"; newBlock = unrelBlock + padHeader + `### ${section}\n\n${bullet}\n`; } return content.slice(0, afterUnrelHeader) + newBlock + content.slice(unrelEnd); }