diff --git a/app/lib/shared/changelog.dart b/app/lib/shared/changelog.dart new file mode 100644 index 000000000..acc74292d --- /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 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 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 input) { + String? title; + Content? description; + final releases = []; + + String? firstReleaseLocalName; + _ParsedHeader? current; + + var nodes = []; + 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 = []; + } + + 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(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 241cc60c8..79d097dde 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 { /// /// Iterable _groupChangelogNodes(List 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 `..` 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); } } diff --git a/app/test/shared/changelog_test.dart b/app/test/shared/changelog_test.dart new file mode 100644 index 000000000..9c2d11374 --- /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('Bold text')); + expect(release.content.asHtmlText, contains('Italic text')); + expect(release.content.asHtmlText, contains('Code snippet')); + }); + + 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 908d72a45..5a066ef4b 100644 --- a/app/test/shared/markdown_test.dart +++ b/app/test/shared/markdown_test.dart @@ -410,7 +410,7 @@ void main() { expect( lines.single, '

' - '[1.0.0] - 2022-05-30 ' + '1.0.0 - 2022-05-30 ' '#' '

', );