@@ -6,16 +6,26 @@ import 'package:logging/logging.dart';
66import 'package:markdown/markdown.dart' as m;
77import 'package:pana/pana.dart' show getRepositoryUrl;
88import 'package:path/path.dart' as p;
9+ import 'package:pub_semver/pub_semver.dart' ;
910import 'package:sanitize_html/sanitize_html.dart' ;
1011
1112final Logger _logger = Logger ('pub.markdown' );
1213
1314const _whitelistedClassNames = < String > [
15+ 'changelog-entry' ,
16+ 'changelog-version' ,
17+ 'changelog-content' ,
1418 'hash-header' ,
1519 'hash-link' ,
1620];
1721
18- String markdownToHtml (String text, String baseUrl, {String baseDir}) {
22+ /// Renders markdown [text] to HTML.
23+ String markdownToHtml (
24+ String text,
25+ String baseUrl, {
26+ String baseDir,
27+ bool isChangelog = false ,
28+ }) {
1929 if (text == null ) return null ;
2030 final sanitizedBaseUrl = _pruneBaseUrl (baseUrl);
2131
@@ -24,7 +34,11 @@ String markdownToHtml(String text, String baseUrl, {String baseDir}) {
2434 blockSyntaxes: m.ExtensionSet .gitHubWeb.blockSyntaxes);
2535
2636 final lines = text.replaceAll ('\r\n ' , '\n ' ).split ('\n ' );
27- final nodes = document.parseLines (lines);
37+ var nodes = document.parseLines (lines);
38+
39+ if (isChangelog) {
40+ nodes = _groupChangelogNodes (nodes).toList ();
41+ }
2842
2943 final urlRewriter = _RelativeUrlRewriter (sanitizedBaseUrl, baseDir);
3044 final hashLink = _HashLink ();
@@ -63,17 +77,22 @@ class _HashLink implements m.NodeVisitor {
6377 element.children.length == 1 ;
6478
6579 if (isHeaderWithHash) {
66- element.attributes['class' ] = 'hash-header' ;
67- element.children.addAll ([
68- m.Text (' ' ),
69- m.Element ('a' , [m.Text ('#' )])
70- ..attributes['href' ] = '#${element .generatedId }'
71- ..attributes['class' ] = 'hash-link' ,
72- ]);
80+ _addHashLink (element, element.generatedId);
7381 }
7482 }
7583}
7684
85+ void _addHashLink (m.Element element, String id) {
86+ final currentClasses = element.attributes['class' ] ?? '' ;
87+ element.attributes['class' ] = '$currentClasses hash-header' .trim ();
88+ element.children.addAll ([
89+ m.Text (' ' ),
90+ m.Element ('a' , [m.Text ('#' )])
91+ ..attributes['href' ] = '#$id '
92+ ..attributes['class' ] = 'hash-link' ,
93+ ]);
94+ }
95+
7796/// Filters unsafe URLs from the generated HTML.
7897class _UnsafeUrlFilter implements m.NodeVisitor {
7998 static const _trustedSchemes = < String > ['http' , 'https' , 'mailto' ];
@@ -212,3 +231,67 @@ String _pruneBaseUrl(String url) {
212231 }
213232 return null ;
214233}
234+
235+ /// Group corresponding changelog nodes together, if it matches the following
236+ /// pattern:
237+ /// - version identifiers are the only content in a single line
238+ /// - heading level or other style doesn't matter
239+ /// - optional `v` prefix is accepted
240+ /// - message logs between identifiers are copied to the version entry before the line
241+ ///
242+ /// The output is in the following structure:
243+ /// <div class="changelog-entry">
244+ /// <h2 class="changelog-version">{{version - stripped from styles}}</h2>
245+ /// <div class="changelog-content">
246+ /// {{log entries in their original HTML format}}
247+ /// </div>
248+ /// </div>
249+ Iterable <m.Node > _groupChangelogNodes (List <m.Node > nodes) sync * {
250+ m.Element lastContentDiv;
251+ for (final node in nodes) {
252+ final version = (node is m.Element &&
253+ node.children.isNotEmpty &&
254+ node.children.first is m.Text )
255+ ? _extractVersion (node.children.first.textContent)
256+ : null ;
257+ if (version != null ) {
258+ final titleElem = m.Element ('h2' , [m.Text (version.toString ())])
259+ ..attributes['class' ] = 'changelog-version' ;
260+ final generatedId = (node as m.Element ).generatedId;
261+ if (generatedId != null ) {
262+ titleElem.attributes['id' ] = generatedId;
263+ _addHashLink (titleElem, generatedId);
264+ }
265+
266+ lastContentDiv = m.Element ('div' , [])
267+ ..attributes['class' ] = 'changelog-content' ;
268+
269+ yield m.Element ('div' , [
270+ titleElem,
271+ lastContentDiv,
272+ ])
273+ ..attributes['class' ] = 'changelog-entry' ;
274+ } else if (lastContentDiv != null ) {
275+ lastContentDiv.children.add (node);
276+ } else {
277+ yield node;
278+ }
279+ }
280+ }
281+
282+ /// Returns the extracted version (if it is a specific version, not `any` or empty).
283+ Version _extractVersion (String text) {
284+ if (text == null || text.isEmpty) return null ;
285+ text = text.trim ();
286+ if (text.startsWith ('v' )) {
287+ text = text.substring (1 ).trim ();
288+ }
289+ if (text.isEmpty) return null ;
290+ try {
291+ final v = Version .parse (text);
292+ if (v.isEmpty || v.isAny) return null ;
293+ return v;
294+ } on FormatException catch (_) {
295+ return null ;
296+ }
297+ }
0 commit comments