diff --git a/CHANGELOG.md b/CHANGELOG.md index eed787f..4990858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `Unreleased` heading is now also recognized in the reversed markdown-link shape some tools/humans produce, e.g. `## (Unreleased)[unreleased]`, and normalized to `## [Unreleased]` on release. + ## [1.4.2] - 2026-07-16 ### Fixed diff --git a/src/core/changelog.ts b/src/core/changelog.ts index dbfd494..4daa50a 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -43,6 +43,22 @@ export interface ReleaseOptions { const LINK_REF_RE = /^\[([^\]]+)\]:\s*(.+?)\s*$/; const SECTION_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2}))?\s*$/; +// Some tools/humans write the heading with a reversed-link-style paren prefix, +// e.g. `## (Unreleased)[unreleased]`. Strip it before matching SECTION_RE so +// that regex stays simple — the paren text itself is never used for anything. +const HEADING_PAREN_PREFIX_RE = /^##\s+\([^)]+\)\s*(?=\[)/; + +function stripHeadingParenPrefix(line: string): string { + return line.replace(HEADING_PAREN_PREFIX_RE, '## '); +} + +function isSectionHeading(line: string): boolean { + return SECTION_RE.test(stripHeadingParenPrefix(line)); +} + +function matchSection(line: string): RegExpExecArray | null { + return SECTION_RE.exec(stripHeadingParenPrefix(line)); +} export function parse(text: string): Changelog { const eol: '\n' | '\r\n' = text.includes('\r\n') ? '\r\n' : '\n'; @@ -98,7 +114,7 @@ function extractTrailingLinkRefs(lines: string[]): { lastLink = i; break; } - if (SECTION_RE.test(lines[i]!)) break; + if (isSectionHeading(lines[i]!)) break; } if (lastLink === -1) return { mainLines: lines, links: [], dropped: [] }; @@ -163,14 +179,14 @@ function splitHeaderAndSections(mainLines: string[]): { const sections: Section[] = []; let i = 0; - while (i < mainLines.length && !SECTION_RE.test(mainLines[i]!)) { + while (i < mainLines.length && !isSectionHeading(mainLines[i]!)) { header.push(mainLines[i]!); i++; } trimTrailingBlanks(header); while (i < mainLines.length) { - const m = SECTION_RE.exec(mainLines[i]!); + const m = matchSection(mainLines[i]!); if (!m) { // Should not happen if the file is well-formed; skip stray content between sections. i++; @@ -181,7 +197,7 @@ function splitHeaderAndSections(mainLines: string[]): { i++; const body: string[] = []; - while (i < mainLines.length && !SECTION_RE.test(mainLines[i]!)) { + while (i < mainLines.length && !isSectionHeading(mainLines[i]!)) { body.push(mainLines[i]!); i++; } diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md b/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md new file mode 100644 index 0000000..228e41d --- /dev/null +++ b/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md @@ -0,0 +1,16 @@ +# Changelog + +## (Unreleased)[unreleased] + +### Added + +- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. + +## [0.5.0] - 2026-04-01 + +### Added + +- Initial. + +[unreleased]: https://github.com/foo/bar/compare/v0.5.0...main +[0.5.0]: https://github.com/foo/bar/compare/v0.4.0...v0.5.0 diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md b/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md new file mode 100644 index 0000000..514b82a --- /dev/null +++ b/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md @@ -0,0 +1,19 @@ +# Changelog + +## [Unreleased] + +## [0.6.0] - 2026-05-25 + +### Added + +- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. + +## [0.5.0] - 2026-04-01 + +### Added + +- Initial. + +[Unreleased]: https://github.com/foo/bar/compare/v0.6.0...main +[0.6.0]: https://github.com/foo/bar/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/foo/bar/compare/v0.4.0...v0.5.0 diff --git a/tests/fixtures/12-paren-bracket-unreleased/meta.json b/tests/fixtures/12-paren-bracket-unreleased/meta.json new file mode 100644 index 0000000..2f2015f --- /dev/null +++ b/tests/fixtures/12-paren-bracket-unreleased/meta.json @@ -0,0 +1,6 @@ +{ + "version": "0.6.0", + "date": "2026-05-25", + "unreleasedUrl": "https://github.com/foo/bar/compare/v0.6.0...main", + "versionUrl": "https://github.com/foo/bar/compare/v0.5.0...v0.6.0" +} diff --git a/tests/unit/changelog.test.ts b/tests/unit/changelog.test.ts index 50395f7..ce47272 100644 --- a/tests/unit/changelog.test.ts +++ b/tests/unit/changelog.test.ts @@ -38,6 +38,35 @@ describe('parse', () => { expect(cl.unreleased!.version).toBe('Unreleased'); }); + test('normalizes reversed-link-syntax `(Unreleased)[unreleased]` heading', () => { + const cl = parse('## (Unreleased)[unreleased]\n\n- foo\n'); + expect(cl.unreleased).not.toBeNull(); + expect(cl.unreleased!.version).toBe('Unreleased'); + }); + + test('ignores paren text even when it mismatches the bracket name', () => { + const cl = parse('## (UNRELEASED)[Unreleased]\n\n- foo\n'); + expect(cl.unreleased!.version).toBe('Unreleased'); + }); + + test('tolerates extra whitespace between the paren and bracket groups', () => { + const cl = parse('## (Unreleased) [unreleased]\n\n- foo\n'); + expect(cl.unreleased!.version).toBe('Unreleased'); + }); + + test('supports the paren-bracket shape on versioned headings too', () => { + const cl = parse('## (1.2.3)[1.2.3] - 2025-01-01\n'); + expect(cl.releases).toHaveLength(1); + expect(cl.releases[0]!.version).toBe('1.2.3'); + expect(cl.releases[0]!.date).toBe('2025-01-01'); + }); + + test('does not treat real link syntax `[Unreleased](unreleased)` as a heading', () => { + const cl = parse('## [Unreleased](unreleased)\n\n- foo\n'); + expect(cl.unreleased).toBeNull(); + expect(cl.releases).toHaveLength(0); + }); + test('captures dates and versions accurately', () => { const cl = parse('## [1.2.3] - 2025-01-02\n'); expect(cl.releases).toHaveLength(1);