import * as plugins from "./plugins.js"; export type TChangelogBucket = | "Breaking Changes" | "Features" | "Fixes" | "Documentation" | "Maintenance"; export interface IChangelogEntry { type: string; scope: string; message: string; details?: string[]; } export interface IPendingChangelog { block: string; isEmpty: boolean; } const bucketForCommitType = (commitType: string): TChangelogBucket => { switch (commitType) { case "BREAKING CHANGE": return "Breaking Changes"; case "feat": return "Features"; case "fix": return "Fixes"; case "docs": return "Documentation"; default: return "Maintenance"; } }; const readChangelog = async (filePath: string): Promise => { if (!(await plugins.smartfs.file(filePath).exists())) { return "# Changelog\n\n"; } return (await plugins.smartfs.file(filePath).encoding("utf8").read()) as string; }; const writeChangelog = async (filePath: string, content: string): Promise => { await plugins.smartfs.file(filePath).encoding("utf8").write(content.endsWith("\n") ? content : `${content}\n`); }; const findPendingSection = ( content: string, sectionName: string, ): { start: number; bodyStart: number; end: number } | null => { const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); const match = headingRegex.exec(content); if (!match || match.index === undefined) { return null; } const bodyStart = match.index + match[0].length; const rest = content.slice(bodyStart); const nextHeadingMatch = /^##\s+/m.exec(rest); const end = nextHeadingMatch ? bodyStart + nextHeadingMatch.index : content.length; return { start: match.index, bodyStart, end }; }; export const ensurePendingSection = async ( filePath: string, sectionName = "Pending", ): Promise => { let content = await readChangelog(filePath); if (findPendingSection(content, sectionName)) { return content; } const pendingSection = `## ${sectionName}\n\n`; const titleMatch = /^#\s+.+$/m.exec(content); if (titleMatch && titleMatch.index !== undefined) { const insertAt = titleMatch.index + titleMatch[0].length; content = `${content.slice(0, insertAt)}\n\n${pendingSection}${content.slice(insertAt).replace(/^\n+/, "")}`; } else { content = `# Changelog\n\n${pendingSection}${content}`; } await writeChangelog(filePath, content); return content; }; export const appendPendingChangelogEntry = async ( filePath: string, sectionName: string, entry: IChangelogEntry, ): Promise => { let content = await ensurePendingSection(filePath, sectionName); const pendingSection = findPendingSection(content, sectionName)!; let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end); const bucket = bucketForCommitType(entry.type); const bucketHeading = `### ${bucket}`; const entryLines = [`- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`]; for (const detail of entry.details || []) { entryLines.push(` - ${detail}`); } const renderedEntry = entryLines.join("\n"); const bucketRegex = new RegExp(`^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); const bucketMatch = bucketRegex.exec(pendingBody); if (!bucketMatch || bucketMatch.index === undefined) { pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`; } else { const bucketBodyStart = bucketMatch.index + bucketMatch[0].length; const afterBucket = pendingBody.slice(bucketBodyStart); const nextBucketMatch = /^###\s+/m.exec(afterBucket); const insertAt = nextBucketMatch ? bucketBodyStart + nextBucketMatch.index : pendingBody.length; const beforeInsert = pendingBody.slice(0, insertAt).trimEnd(); const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, ""); pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`; } content = `${content.slice(0, pendingSection.bodyStart)}\n${pendingBody.trim()}\n\n${content.slice(pendingSection.end).replace(/^\n+/, "")}`; await writeChangelog(filePath, content); }; export const readPendingChangelog = async ( filePath: string, sectionName = "Pending", ): Promise => { const content = await ensurePendingSection(filePath, sectionName); const pendingSection = findPendingSection(content, sectionName)!; const block = content.slice(pendingSection.bodyStart, pendingSection.end).trim(); return { block, isEmpty: block.length === 0, }; }; export const inferVersionTypeFromPending = (pendingBlock: string): "patch" | "minor" | "major" => { if (/^###\s+Breaking Changes\s*$/m.test(pendingBlock)) { return "major"; } if (/^###\s+Features\s*$/m.test(pendingBlock)) { return "minor"; } return "patch"; }; export const movePendingToVersion = async ( filePath: string, sectionName: string, versionHeading: string, version: string, dateString: string, ): Promise => { let content = await ensurePendingSection(filePath, sectionName); const pendingSection = findPendingSection(content, sectionName)!; const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim(); if (!pendingBlock) { throw new Error("No pending changelog entries. Nothing to release."); } const renderedHeading = versionHeading .replaceAll("{{version}}", version) .replaceAll("{{date}}", dateString); const nextContent = content.slice(pendingSection.end).replace(/^\n+/, ""); content = `${content.slice(0, pendingSection.bodyStart)}\n\n${renderedHeading}\n\n${pendingBlock}\n\n${nextContent}`; await writeChangelog(filePath, content); };