Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 29 additions & 14 deletions src/core/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 13 additions & 29 deletions tests/unit/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading