Repository: dart-lang/pub-dev

Issue:
Show versions in changelog consistently
Currently we allow prefixing the versions with a `v`, we could make a decision on how we show it on pub.dev , so it looks the same on all package pages - either with or without the `v`.



Fix patch:
diff --git a/app/lib/shared/changelog.dart b/app/lib/shared/changelog.dart
new file mode 100644
index 0000000000..acc74292db
--- /dev/null
+++ b/app/lib/shared/changelog.dart
@@ -0,0 +1,326 @@
+// Copyright (c) 2025, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// The library provides support for parsing `CHANGELOG.md` files formatted
+/// with Markdown. It converts the file's content into a structured [Changelog]
+/// object, which encapsulates individual [Release] entries.
+
+/// The [ChangelogParser] accommodates various formatting styles. It can
+/// effectively parse changelogs with inconsistent header levels or those
+/// that include additional information beyond just the version number in
+/// the release header.
+///
+/// The parser is designed to support the widely adopted "Keep a Changelog"
+/// format (see https://keepachangelog.com/en/1.1.0/ for details).
+/// Additionally, it has been tested with a diverse set of changelog files
+/// available as part of the packages on https://pub.dev/.
+library;
+
+import 'package:collection/collection.dart';
+import 'package:html/dom.dart' as html;
+import 'package:html/parser.dart' as html_parser;
+import 'package:pub_semver/pub_semver.dart';
+
+/// Represents the entire changelog, containing a list of releases.
+class Changelog {
+  /// The main title of the changelog (e.g., 'Changelog').
+  final String? title;
+
+  /// An optional introductory description for the changelog.
+  final Content? description;
+
+  /// A list of releases, typically in reverse chronological order.
+  final List<Release> releases;
+
+  Changelog({
+    this.title,
+    this.description,
+    required this.releases,
+  });
+}
+
+/// Represents a single version entry in the changelog,
+/// such as '[1.2.0] - 2025-07-10' or the 'Unreleased' section.
+class Release {
+  /// The version string or section title (e.g., '1.2.0', 'Unreleased').
+  final String version;
+
+  /// The HTML anchor value (`id` attribute).
+  final String? anchor;
+
+  /// The text of the header after the version.
+  final String? label;
+
+  /// The release date for this version.
+  /// `null` if it's the 'Unreleased' section or is missing
+  final DateTime? date;
+
+  /// The additional text of the label, without the [date] part (if present).
+  final String? note;
+
+  /// The content of the release.
+  final Content content;
+
+  Release({
+    required this.version,
+    this.anchor,
+    this.label,
+    this.date,
+    this.note,
+    required this.content,
+  });
+}
+
+/// Describes an arbitrary piece of content (e.g. the description of a single version).
+///
+/// If the content is specified as parsed HTML nodes, the class will store it as-is,
+/// and serialize them only when needed.
+class Content {
+  String? _asText;
+  html.Node? _asNode;
+
+  Content.fromHtmlText(String text) : _asText = text;
+  Content.fromParsedHtml(List<html.Node> nodes) {
+    _asNode = html.DocumentFragment();
+    for (final node in nodes) {
+      _asNode!.append(node);
+    }
+  }
+
+  late final asHtmlText = () {
+    if (_asText != null) return _asText!;
+    final root = _asNode is html.DocumentFragment
+        ? _asNode as html.DocumentFragment
+        : html.DocumentFragment()
+      ..append(_asNode!);
+    return root.outerHtml;
+  }();
+
+  late final asHtmlNode = () {
+    if (_asNode != null) return _asNode!;
+    return html_parser.parseFragment(_asText!);
+  }();
+}
+
+/// Parses the changelog with pre-configured options.
+class ChangelogParser {
+  final _acceptedHeaderTags = ['h1', 'h2', 'h3', 'h4'];
+  final bool _strictLevels;
+  final int _partOfLevelThreshold;
+
+  ChangelogParser({
+    bool strictLevels = false,
+    int partOfLevelThreshold = 2,
+  })  : _strictLevels = strictLevels,
+        _partOfLevelThreshold = partOfLevelThreshold;
+
+  /// Parses markdown nodes into a [Changelog] structure.
+  Changelog parseHtmlNodes(List<html.Node> input) {
+    String? title;
+    Content? description;
+    final releases = <Release>[];
+
+    String? firstReleaseLocalName;
+    _ParsedHeader? current;
+
+    var nodes = <html.Node>[];
+    void finalizeNodes() {
+      if (current == null) {
+        description = Content.fromParsedHtml(nodes);
+        if (description!.asHtmlText.trim().isEmpty) {
+          description = null;
+        }
+      } else {
+        releases.add(Release(
+          version: current.version,
+          anchor: current.anchor,
+          label: current.label,
+          date: current.date,
+          note: current.note,
+          content: Content.fromParsedHtml(nodes),
+        ));
+      }
+      nodes = <html.Node>[];
+    }
+
+    for (final node in [...input]) {
+      if (node is html.Element &&
+          _acceptedHeaderTags.contains(node.localName)) {
+        if (_strictLevels &&
+            firstReleaseLocalName != null &&
+            node.localName != firstReleaseLocalName) {
+          continue;
+        }
+        final headerText = node.text.trim();
+
+        // Check if this looks like a version header first
+        final parsed = _tryParseAsHeader(node, headerText);
+
+        final isNewVersion = parsed != null &&
+            releases.every((r) => r.version != parsed.version) &&
+            current?.version != parsed.version;
+        final isPartOfCurrent = current != null &&
+            parsed != null &&
+            current.level + _partOfLevelThreshold <= parsed.level;
+        if (isNewVersion && !isPartOfCurrent) {
+          firstReleaseLocalName ??= node.localName!;
+          finalizeNodes();
+          current = parsed;
+          continue;
+        }
+
+        // only consider as title if it's h1 and we haven't found any versions yet
+        if (node.localName == 'h1' && title == null && current == null) {
+          title = headerText;
+          continue;
+        }
+      }
+
+      // collect nodes for description (before any version) or current release
+      nodes.add(node);
+    }
+
+    // complete last section
+    finalizeNodes();
+
+    return Changelog(
+      title: title,
+      description: description,
+      releases: releases,
+    );
+  }
+
+  /// Parses the release header line or return `null` when no version part was recognized.
+  ///
+  /// Handles some of the common formats:
+  /// - `1.2.0`
+  /// - `v1.2.0`
+  /// - `[1.2.0] - 2025-07-14`
+  /// - `unreleased`
+  /// - `next release (...)`
+  _ParsedHeader? _tryParseAsHeader(html.Element elem, String input) {
+    final level = _acceptedHeaderTags.indexOf(elem.localName!);
+
+    final anchor = elem.attributes['id'];
+    // special case: unreleased
+    final inputLowerCase = input.toLowerCase().trim();
+    final unreleasedTexts = ['unreleased', 'next release'];
+    for (final unreleasedText in unreleasedTexts) {
+      if (inputLowerCase == unreleasedText) {
+        return _ParsedHeader(level, 'Unreleased', null, null, anchor, null);
+      }
+      if (inputLowerCase.startsWith('$unreleasedText ')) {
+        String? label = input.substring(unreleasedText.length + 1).trim();
+        if (label.isEmpty) {
+          label = null;
+        }
+        return _ParsedHeader(level, 'Unreleased', label, null, anchor, null);
+      }
+    }
+
+    // extract version
+    final versionPart = input.split(' ').firstWhereOrNull((e) => e.isNotEmpty);
+    if (versionPart == null) {
+      return null;
+    }
+    final version = _parseVersionPart(versionPart.trim());
+    if (version == null) {
+      return null;
+    }
+
+    // rest of the release header
+    String? label =
+        input.substring(input.indexOf(versionPart) + versionPart.length).trim();
+    if (label.startsWith('- ')) {
+      label = label.substring(2).trim();
+    }
+    if (label.isEmpty) {
+      label = null;
+    }
+
+    DateTime? date;
+    String? note;
+
+    if (label != null) {
+      final parts = label.split(' ');
+      date = _parseDatePart(parts[0].trim());
+      if (date != null) {
+        parts.removeAt(0);
+      }
+
+      if (parts.isNotEmpty) {
+        note = parts.join(' ');
+      }
+    }
+
+    return _ParsedHeader(level, version, label, date,
+        anchor ?? version.replaceAll('.', ''), note);
+  }
+
+  /// Parses the version part of a release title.
+  ///
+  /// Returns the extracted version string, or null if no version was recognized.
+  String? _parseVersionPart(String input) {
+    // remove brackets or 'v' if present
+    if (input.startsWith('[') && input.endsWith(']')) {
+      input = input.substring(1, input.length - 1).trim();
+    }
+    if (input.startsWith('v')) {
+      input = input.substring(1).trim();
+    }
+
+    // sanity check if it's a valid semantic version
+    try {
+      final version = Version.parse(input);
+      if (!version.isEmpty && !version.isAny) {
+        return input;
+      }
+    } on FormatException catch (_) {}
+
+    return null;
+  }
+
+  final _yyyymmddDateFormats = <RegExp>[
+    RegExp(r'^(\d{4})-(\d{2})-(\d{2})$'), // 2025-07-10
+    RegExp(r'^(\d{4})/(\d{2})/(\d{2})$'), // 2025/07/10
+  ];
+
+  /// Parses the date part of a release title.
+  ///
+  /// Returns the parsed date or null if no date was recognized.
+  ///
+  /// Note: currently only date formats that start with a year are recognized.
+  DateTime? _parseDatePart(String input) {
+    if (input.startsWith('(') && input.endsWith(')')) {
+      input = input.substring(1, input.length - 1);
+    }
+    for (final format in _yyyymmddDateFormats) {
+      final match = format.matchAsPrefix(input);
+      if (match == null) continue;
+      final year = int.parse(match.group(1)!);
+      final month = int.parse(match.group(2)!);
+      final day = int.parse(match.group(3)!);
+      final date = DateTime(year, month, day);
+      // sanity check for overflow dates
+      if (date.year != year || date.month != month || date.day != day) {
+        continue;
+      }
+      return date;
+    }
+
+    return null;
+  }
+}
+
+class _ParsedHeader {
+  final int level;
+  final String version;
+  final String? label;
+  final DateTime? date;
+  final String? anchor;
+  final String? note;
+
+  _ParsedHeader(
+      this.level, this.version, this.label, this.date, this.anchor, this.note);
+}
diff --git a/app/lib/shared/markdown.dart b/app/lib/shared/markdown.dart
index 241cc60c8a..79d097dde6 100644
--- a/app/lib/shared/markdown.dart
+++ b/app/lib/shared/markdown.dart
@@ -8,7 +8,7 @@ import 'package:html/parser.dart' as html_parser;
 import 'package:logging/logging.dart';
 import 'package:markdown/markdown.dart' as m;
 import 'package:pub_dev/frontend/static_files.dart';
-import 'package:pub_semver/pub_semver.dart';
+import 'package:pub_dev/shared/changelog.dart';
 import 'package:sanitize_html/sanitize_html.dart';
 
 import 'urls.dart' show UriExt;
@@ -360,72 +360,38 @@ class _TaskListRewriteTreeVisitor extends html_parsing.TreeVisitor {
 ///   </div>
 /// </div>
 Iterable<html.Node> _groupChangelogNodes(List<html.Node> nodes) sync* {
-  html.Element? lastContentDiv;
-  String? firstHeaderTag;
-  for (final node in nodes) {
-    final nodeTag = node is html.Element ? node.localName : null;
-    final isNewHeaderTag = firstHeaderTag == null &&
-        nodeTag != null &&
-        _structuralHeaderTags.contains(nodeTag);
-    final matchesFirstHeaderTag =
-        firstHeaderTag != null && nodeTag == firstHeaderTag;
-    final mayBeVersion = node is html.Element &&
-        (isNewHeaderTag || matchesFirstHeaderTag) &&
-        node.nodes.isNotEmpty &&
-        node.nodes.first is html.Text;
-    final versionText = mayBeVersion ? node.nodes.first.text?.trim() : null;
-    final version = mayBeVersion ? _extractVersion(versionText) : null;
-    if (version != null) {
-      firstHeaderTag ??= nodeTag;
-      var id = node.attributes['id'];
-      if (id == null || id.isEmpty) {
-        // `package:markdown` generates ids without dots (`.`), using similar
-        // normalization here.
-        // TODO: consider replacing all uses with `<a>.<b>.<c>` id attributes
-        id = version.toString().replaceAll('.', '');
-      }
-      final titleElem = html.Element.tag('h2')
-        ..attributes['class'] = 'changelog-version'
-        ..attributes['id'] = id
-        ..append(html.Text(versionText!));
-
-      lastContentDiv = html.Element.tag('div')
-        ..attributes['class'] = 'changelog-content';
-
-      yield html.Element.tag('div')
-        ..attributes['class'] = 'changelog-entry'
-        ..append(html.Text('\n'))
-        ..append(titleElem)
-        ..append(html.Text('\n'))
-        ..append(lastContentDiv);
-    } else if (lastContentDiv != null) {
-      final lastChild = lastContentDiv.nodes.lastOrNull;
-      if (lastChild is html.Element && lastChild.localName == 'div') {
-        lastContentDiv.append(html.Text('\n'));
-      }
-      lastContentDiv.append(node);
-    } else {
-      yield node;
-    }
+  final changelog = ChangelogParser().parseHtmlNodes(nodes);
+  if (changelog.title != null) {
+    yield html.Element.tag('h1')
+      ..text = changelog.title
+      ..attributes['id'] = 'changelog';
+    yield html.Text('\n');
   }
-}
-
-/// Returns the extracted version (if it is a specific version, not `any` or empty).
-Version? _extractVersion(String? text) {
-  if (text == null || text.isEmpty) return null;
-  text = text.trim().split(' ').first;
-  if (text.startsWith('[') && text.endsWith(']')) {
-    text = text.substring(1, text.length - 1).trim();
+  if (changelog.description != null) {
+    yield changelog.description!.asHtmlNode;
   }
-  if (text.startsWith('v')) {
-    text = text.substring(1).trim();
-  }
-  if (text.isEmpty) return null;
-  try {
-    final v = Version.parse(text);
-    if (v.isEmpty || v.isAny) return null;
-    return v;
-  } on FormatException catch (_) {
-    return null;
+  for (final release in changelog.releases) {
+    final versionLine = [
+      release.version,
+      if (release.date != null)
+        '- ${release.date!.toIso8601String().split('T').first}',
+      if (release.note != null) release.note,
+    ].join(' ');
+    final titleElem = html.Element.tag('h2')
+      ..attributes['class'] = 'changelog-version'
+      ..append(html.Text(versionLine));
+    if (release.anchor != null) {
+      titleElem.attributes['id'] = release.anchor!;
+    }
+    final contentElem = html.Element.tag('div')
+      ..attributes['class'] = 'changelog-content';
+    contentElem.append(release.content.asHtmlNode);
+
+    yield html.Element.tag('div')
+      ..attributes['class'] = 'changelog-entry'
+      ..append(html.Text('\n'))
+      ..append(titleElem)
+      ..append(html.Text('\n'))
+      ..append(contentElem);
   }
 }


Test patch:
diff --git a/app/test/shared/changelog_test.dart b/app/test/shared/changelog_test.dart
new file mode 100644
index 0000000000..9c2d11374f
--- /dev/null
+++ b/app/test/shared/changelog_test.dart
@@ -0,0 +1,542 @@
+// Copyright (c) 2025, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:html/parser.dart' as html_parser;
+import 'package:markdown/markdown.dart' as m;
+import 'package:pub_dev/shared/changelog.dart';
+import 'package:test/test.dart';
+
+Changelog _parse(String input) {
+  final nodes = m.Document(extensionSet: m.ExtensionSet.gitHubWeb).parse(input);
+  final rawHtml = m.renderToHtml(nodes);
+  final root = html_parser.parseFragment(rawHtml);
+  return ChangelogParser().parseHtmlNodes(root.nodes);
+}
+
+void main() {
+  group('Changelog parsing', () {
+    test('basic changelog with releases that specify title, date and sections',
+        () {
+      const markdown = '''
+# Changelog
+
+## [1.2.0] - 2025-07-10
+
+### Added
+- New feature A
+- New feature B
+
+### Fixed
+- Bug fix 1
+
+## [1.1.0] - 2025-06-15
+
+### Added
+- Previous feature
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, equals('Changelog'));
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(2));
+
+      final firstRelease = changelog.releases[0];
+      expect(firstRelease.version, equals('1.2.0'));
+      expect(firstRelease.date, equals(DateTime(2025, 7, 10)));
+      expect(firstRelease.content.asHtmlText, contains('New feature A'));
+      expect(firstRelease.content.asHtmlText, contains('Bug fix 1'));
+
+      final secondRelease = changelog.releases[1];
+      expect(secondRelease.version, equals('1.1.0'));
+      expect(secondRelease.date, equals(DateTime(2025, 6, 15)));
+      expect(secondRelease.content.asHtmlText, contains('Previous feature'));
+    });
+
+    test('parses changelog with description', () {
+      const markdown = '''
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+
+## [1.0.0] - 2025-01-01
+
+### Added
+- Initial release
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, equals('Changelog'));
+      expect(changelog.description, isNotNull);
+      expect(
+          changelog.description!.asHtmlText, contains('All notable changes'));
+      expect(changelog.description!.asHtmlText, contains('Keep a Changelog'));
+      expect(changelog.releases, hasLength(1));
+    });
+
+    test('parses Unreleased section', () {
+      const markdown = '''
+# Changelog
+
+## Unreleased
+
+### Added
+- Work in progress feature
+
+## [1.0.0] - 2025-01-01
+
+### Added
+- Initial release
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(2));
+
+      final unreleasedSection = changelog.releases[0];
+      expect(unreleasedSection.version, equals('Unreleased'));
+      expect(unreleasedSection.date, isNull);
+      expect(
+          unreleasedSection.content.asHtmlText, contains('Work in progress'));
+    });
+
+    test('handles different version formats', () {
+      const markdown = '''
+# Changelog
+
+## v2.0.0 - 2025-08-01
+
+### Added
+- Version with v prefix
+
+## 1.5.0
+
+### Added
+- Version without date
+
+## [1.0.0-beta.1] - 2025-01-15
+
+### Added
+- Pre-release version
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(3));
+
+      expect(changelog.releases[0].version, equals('2.0.0'));
+      expect(changelog.releases[0].date, equals(DateTime(2025, 8, 1)));
+
+      expect(changelog.releases[1].version, equals('1.5.0'));
+      expect(changelog.releases[1].date, isNull);
+
+      expect(changelog.releases[2].version, equals('1.0.0-beta.1'));
+      expect(changelog.releases[2].date, equals(DateTime(2025, 1, 15)));
+    });
+
+    test('handles different date formats', () {
+      const markdown = '''
+# Changelog
+
+## [1.3.0] - 2025-07-10
+
+### Added
+- ISO date format
+
+## [1.2.0] - 2025/06/15
+
+### Added
+- Slash date format
+
+## [1.1.0] - 07/10/2025
+
+### Added
+- MM/DD/YYYY format
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(3));
+
+      expect(changelog.releases[0].date, equals(DateTime(2025, 7, 10)));
+      expect(changelog.releases[1].date, equals(DateTime(2025, 6, 15)));
+      expect(changelog.releases[2].date, isNull);
+    });
+
+    test('preserves formatting in text content', () {
+      const markdown = '''
+# Changelog
+
+## [1.0.0] - 2025-01-01
+
+### Added
+- **Bold text** feature
+- *Italic text* feature
+- `Code snippet` feature
+
+### Links
+- [External link](https://example.com)
+- Internal reference
+
+### Code blocks
+```dart
+void main() {
+  print('Hello, World!');
+}
+```
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(1));
+
+      final release = changelog.releases[0];
+      expect(
+          release.content.asHtmlText, contains('<strong>Bold text</strong>'));
+      expect(release.content.asHtmlText, contains('<em>Italic text</em>'));
+      expect(release.content.asHtmlText, contains('<code>Code snippet</code>'));
+    });
+
+    test('handles changelog without title', () {
+      const markdown = '''
+## [1.0.0] - 2025-01-01
+
+### Added
+- Feature without title
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.releases, hasLength(1));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+    });
+
+    test('handles mixed header levels', () {
+      const markdown = '''
+# Changelog
+
+## [2.0.0] - 2025-08-01
+
+### Added
+- New feature
+
+### [1.0.0] - 2025-01-01
+
+#### Added
+- Initial release
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('2.0.0'));
+      expect(changelog.releases[1].version, equals('1.0.0'));
+    });
+
+    test('handles embedded header levels', () {
+      const markdown = '''
+# Changelog
+
+## 2.0.0
+
+### Upgrade
+
+#### 1.0.0
+
+- Instruction.
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(1));
+      expect(changelog.releases[0].version, equals('2.0.0'));
+      expect(changelog.releases[0].content.asHtmlText, contains('1.0.0'));
+    });
+
+    test('handles repetition', () {
+      const markdown = '''
+# Changelog
+
+## 2.0.0
+
+- Feature A.
+
+## 2.0.0
+
+- Feature B.
+
+## 1.0.0
+
+- Feature C.
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('2.0.0'));
+      expect(changelog.releases[1].version, equals('1.0.0'));
+      expect(changelog.releases[0].content.asHtmlText, contains('Feature B.'));
+      expect(changelog.releases[1].content.asHtmlText, contains('Feature C.'));
+    });
+
+    test('handles empty changelog', () {
+      const markdown = '''
+# Changelog
+
+No releases yet.
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, equals('Changelog'));
+      expect(changelog.description!.asHtmlText, contains('No releases yet'));
+      expect(changelog.releases, isEmpty);
+    });
+
+    test('handles invalid date formats gracefully', () {
+      const markdown = '''
+# Changelog
+
+## [1.0.0] - Invalid Date
+
+### Added
+- Feature with invalid date
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(1));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+      expect(changelog.releases[0].date, isNull);
+    });
+
+    test('handles non-version headers', () {
+      const markdown = '''
+# Changelog
+
+## About
+
+This is about section.
+
+## [1.0.0] - 2025-01-01
+
+### Added
+- Actual release
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(1));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+      expect(changelog.description!.asHtmlText, contains('About'));
+      expect(
+          changelog.description!.asHtmlText, contains('This is about section'));
+    });
+
+    test('handles complex version numbers', () {
+      const markdown = '''
+# Changelog
+
+## [1.0.0-alpha.1+build.123] - 2025-01-01
+
+### Added
+- Complex version number
+
+## [0.1.0] - 2025-01-01
+
+### Added
+- Zero major version
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('1.0.0-alpha.1+build.123'));
+      expect(changelog.releases[1].version, equals('0.1.0'));
+    });
+
+    test('handles changelog without title', () {
+      const markdown = '''
+## [1.0.0] - 2025-01-01
+
+### Added
+- Feature without title
+
+## [0.9.0] - 2024-12-01
+
+### Added
+- Previous feature
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+      expect(changelog.releases[1].version, equals('0.9.0'));
+    });
+
+    test('handles changelog without description', () {
+      const markdown = '''
+# Changelog
+
+## [1.0.0] - 2025-01-01
+
+### Added
+- Feature without description
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, equals('Changelog'));
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(1));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+    });
+
+    test('handles version entries starting with # (h1)', () {
+      const markdown = '''
+# [2.0.0] - 2025-08-01
+
+### Added
+- Major version with h1
+
+# [1.0.0] - 2025-01-01
+
+### Added
+- Initial release with h1
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('2.0.0'));
+      expect(changelog.releases[1].version, equals('1.0.0'));
+    });
+
+    test('handles version entries starting with ## (h2)', () {
+      const markdown = '''
+## [2.0.0] - 2025-08-01
+
+### Added
+- Major version with h2
+
+## [1.0.0] - 2025-01-01
+
+### Added
+- Initial release with h2
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('2.0.0'));
+      expect(changelog.releases[1].version, equals('1.0.0'));
+    });
+
+    test('handles mixed header levels for versions', () {
+      const markdown = '''
+# [3.0.0] - 2025-09-01
+
+### Added
+- Version with h1
+
+## [2.0.0] - 2025-08-01
+
+### Added
+- Version with h2
+
+### [1.0.0] - 2025-01-01
+
+### Added
+- Version with h3
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(3));
+      expect(changelog.releases[0].version, equals('3.0.0'));
+      expect(changelog.releases[1].version, equals('2.0.0'));
+      expect(changelog.releases[2].version, equals('1.0.0'));
+    });
+
+    test('prioritizes version detection over title', () {
+      const markdown = '''
+# [1.0.0] - 2025-01-01
+
+### Added
+- Version that looks like title
+
+## [0.9.0] - 2024-12-01
+
+### Added
+- Previous version
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+      expect(changelog.releases[1].version, equals('0.9.0'));
+    });
+
+    test('handles minimal changelog with just versions', () {
+      const markdown = '''
+# 1.0.0
+
+Initial release
+
+## 0.9.0
+
+Beta release
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, isNull);
+      expect(changelog.description, isNull);
+      expect(changelog.releases, hasLength(2));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+      expect(changelog.releases[0].content.asHtmlText,
+          contains('Initial release'));
+      expect(changelog.releases[1].version, equals('0.9.0'));
+      expect(
+          changelog.releases[1].content.asHtmlText, contains('Beta release'));
+    });
+
+    test('handles changelog with title and version headers at same level', () {
+      const markdown = '''
+# Project Changelog
+
+This is the changelog for the project.
+
+# [1.0.0] - 2025-01-01
+
+### Added
+- Initial release
+''';
+
+      final changelog = _parse(markdown);
+
+      expect(changelog.title, equals('Project Changelog'));
+      expect(
+          changelog.description!.asHtmlText, contains('This is the changelog'));
+      expect(changelog.releases, hasLength(1));
+      expect(changelog.releases[0].version, equals('1.0.0'));
+    });
+  });
+}
diff --git a/app/test/shared/markdown_test.dart b/app/test/shared/markdown_test.dart
index 908d72a450..5a066ef4b9 100644
--- a/app/test/shared/markdown_test.dart
+++ b/app/test/shared/markdown_test.dart
@@ -410,7 +410,7 @@ void main() {
       expect(
         lines.single,
         '<h2 class="changelog-version hash-header" id="100---2022-05-30">'
-        '[1.0.0] - 2022-05-30 '
+        '1.0.0 - 2022-05-30 '
         '<a href="#100---2022-05-30" class="hash-link">#</a>'
         '</h2>',
       );