diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c3930..fec65a4 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] +### Fixed + +- Headings that carry a markdown link target are now recognized and normalized — reference-style `## [Unreleased][unreleased]` and inline `## [Unreleased](unreleased)`, on both the Unreleased and versioned sections. + ## [1.5.0] - 2026-08-06 ### Added diff --git a/src/core/changelog.ts b/src/core/changelog.ts index 4daa50a..5d41a30 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -42,22 +42,37 @@ 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, '## '); +const SECTION_START_RE = /^##\s+\[([^\]]+)\]/; +// The heading text may carry a markdown link target — reference-style +// `## [Unreleased][unreleased]` or inline `## [Unreleased](unreleased)`. It is +// discarded here; link refs are regenerated on release. +const LINK_TARGET_RE = /^\[[^\]]*\]|^\([^)]*\)/; +const SECTION_DATE_RE = /^\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/; + +interface SectionMatch { + name: string; + date: string | null; } -function isSectionHeading(line: string): boolean { - return SECTION_RE.test(stripHeadingParenPrefix(line)); +function matchSection(line: string): SectionMatch | null { + const start = SECTION_START_RE.exec(line); + if (!start) { + return null; + } + let rest = line.slice(start[0].length); + const target = LINK_TARGET_RE.exec(rest); + if (target) { + rest = rest.slice(target[0].length); + } + if (rest.trim() === '') { + return { name: start[1]!, date: null }; + } + const date = SECTION_DATE_RE.exec(rest); + return date ? { name: start[1]!, date: date[1]! } : null; } -function matchSection(line: string): RegExpExecArray | null { - return SECTION_RE.exec(stripHeadingParenPrefix(line)); +function isSectionHeading(line: string): boolean { + return matchSection(line) !== null; } export function parse(text: string): Changelog { @@ -192,8 +207,8 @@ function splitHeaderAndSections(mainLines: string[]): { i++; continue; } - const version = m[1]!; - const date = m[2] ?? null; + const version = m.name; + const date = m.date; i++; const body: string[] = []; diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md b/tests/fixtures/12-link-target-unreleased/changelog-in.md similarity index 63% rename from tests/fixtures/12-paren-bracket-unreleased/changelog-in.md rename to tests/fixtures/12-link-target-unreleased/changelog-in.md index 228e41d..6fd9bee 100644 --- a/tests/fixtures/12-paren-bracket-unreleased/changelog-in.md +++ b/tests/fixtures/12-link-target-unreleased/changelog-in.md @@ -1,10 +1,10 @@ # Changelog -## (Unreleased)[unreleased] +## [Unreleased][unreleased] ### Added -- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. +- Link-target `[Unreleased][unreleased]` heading gets normalized on release. ## [0.5.0] - 2026-04-01 diff --git a/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md b/tests/fixtures/12-link-target-unreleased/changelog-out.md similarity index 77% rename from tests/fixtures/12-paren-bracket-unreleased/changelog-out.md rename to tests/fixtures/12-link-target-unreleased/changelog-out.md index 514b82a..3673c0a 100644 --- a/tests/fixtures/12-paren-bracket-unreleased/changelog-out.md +++ b/tests/fixtures/12-link-target-unreleased/changelog-out.md @@ -6,7 +6,7 @@ ### Added -- Reversed-link-syntax `(Unreleased)[unreleased]` heading gets normalized on release. +- Link-target `[Unreleased][unreleased]` heading gets normalized on release. ## [0.5.0] - 2026-04-01 diff --git a/tests/fixtures/12-paren-bracket-unreleased/meta.json b/tests/fixtures/12-link-target-unreleased/meta.json similarity index 100% rename from tests/fixtures/12-paren-bracket-unreleased/meta.json rename to tests/fixtures/12-link-target-unreleased/meta.json diff --git a/tests/unit/changelog.test.ts b/tests/unit/changelog.test.ts index ce47272..3723a97 100644 --- a/tests/unit/changelog.test.ts +++ b/tests/unit/changelog.test.ts @@ -32,39 +32,23 @@ describe('parse', () => { expect(parse('# t\r\n\r\n## [Unreleased]\r\n').eol).toBe('\r\n'); }); - test('treats lowercase `unreleased` heading as Unreleased', () => { - const cl = parse('## [unreleased]\n\n- foo\n'); + test.each([ + ['lowercase `unreleased` heading', '## [unreleased]\n\n- foo\n'], + ['reference-link heading `[Unreleased][unreleased]`', '## [Unreleased][unreleased]\n\n- foo\n'], + ['inline-link heading `[Unreleased](unreleased)`', '## [Unreleased](unreleased)\n\n- foo\n'], + ])('normalizes %s to Unreleased', (_label, input) => { + const cl = parse(input); expect(cl.unreleased).not.toBeNull(); 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('supports link-target headings on versioned sections too', () => { + const ref = parse('## [1.2.3][1.2.3] - 2025-01-01\n'); + expect(ref.releases[0]!.version).toBe('1.2.3'); + expect(ref.releases[0]!.date).toBe('2025-01-01'); + const inline = parse('## [1.2.3](https://x/1.2.3) - 2025-01-01\n'); + expect(inline.releases[0]!.version).toBe('1.2.3'); + expect(inline.releases[0]!.date).toBe('2025-01-01'); }); test('captures dates and versions accurately', () => {