Repository: dart-lang/pub-dev (dart)

Issue:
Generated link in Example tab is broken
The first element in the Example tab is a generated link. This is not a content of `example/README.md`. According to my observation, there are two scenarios:

1. `pubspec.yaml` contains `repository` (usually pointing to GitHub). In this case the generated link works fine.
2. `pubspec.yaml` DOES NOT contain `repository`. In this case the generated link is broken.

I observed this behavior on many different packages.

Note: File `example/README.md` does exist and the content of this file a rendered right under the generated link.

![pub-dev-example-link](https://github.com/user-attachments/assets/caa6b6a3-8e74-4bb6-9908-2a639952c546)

Pull request description:
Fixes #7892.

Patch:
diff --git a/app/lib/frontend/dom/dom.dart b/app/lib/frontend/dom/dom.dart
index 6ba20582c5..892274b4f8 100644
--- a/app/lib/frontend/dom/dom.dart
+++ b/app/lib/frontend/dom/dom.dart
@@ -121,12 +121,27 @@ Node xAgoTimestamp(DateTime timestamp, {String? datePrefix}) {
 /// Creates a DOM node with markdown content using the default [DomContext].
 Node markdown(
   String text, {
-  bool disableHashIds = false,
+  /// When set, the markdown output is pruned more heavily than usual, like:
+  ///  - generated hash IDs are removed
+  ///  - relative URLs of links and images are removed (and replaced with their text content)
+  ///
+  /// Should be used for markdown content that is not entirely in our control.
+  bool strictPruning = false,
 }) {
-  return dom.unsafeRawHtml(markdownToHtml(
-    text,
-    disableHashIds: disableHashIds,
-  ));
+  return dom.unsafeRawHtml(
+    markdownToHtml(
+      text,
+      disableHashIds: strictPruning,
+      urlResolverFn: strictPruning
+          ? null
+          : (
+              url, {
+              String? relativeFrom,
+              bool? isEmbeddedObject,
+            }) =>
+              url,
+    ),
+  );
 }
 
 /// Creates DOM elements with <pre> and <code> for HLJS and pub.dev's copy-to-clipboard.
diff --git a/app/lib/frontend/templates/views/pkg/score_tab.dart b/app/lib/frontend/templates/views/pkg/score_tab.dart
index 664f9e3ae2..9f8997646d 100644
--- a/app/lib/frontend/templates/views/pkg/score_tab.dart
+++ b/app/lib/frontend/templates/views/pkg/score_tab.dart
@@ -159,7 +159,7 @@ d.Node _section(ReportSection section) {
               classes: ['pkg-report-content-summary', 'markdown-body'],
               child: d.markdown(
                 _updatedSummary(section.summary),
-                disableHashIds: true,
+                strictPruning: true,
               ),
             ),
           ),
diff --git a/app/lib/shared/markdown.dart b/app/lib/shared/markdown.dart
index 8c073c9fcb..1b6d738779 100644
--- a/app/lib/shared/markdown.dart
+++ b/app/lib/shared/markdown.dart
@@ -220,6 +220,8 @@ class _RelativeUrlRewriter implements m.NodeVisitor {
 
         if (r.children != null && r.children!.isNotEmpty) {
           element.children!.insertAll(index, r.children!);
+        } else if (r.tag == 'img' && r.attributes.containsKey('alt')) {
+          element.children!.insert(index, m.Text('[${r.attributes['alt']}]'));
         }
         element.children!.remove(r);
         _elementsToRemove.remove(r);
@@ -237,25 +239,41 @@ class _RelativeUrlRewriter implements m.NodeVisitor {
     }
   }
 
+  /// Returns a new URL based on [url] and [urlResolverFn].
+  ///
+  /// When the returned value is `null`, the containing DOM node will be pruned
+  /// from the output, otherwise this method may return the same or an updated URL.
   String? _rewriteUrl(String? url, {bool raw = false}) {
+    // pass-through for simple cases
     if (url == null || url.startsWith('#')) {
       return url;
     }
-    if (Uri.tryParse(url) == null) {
+    // reject unparseable URLs
+    final uri = Uri.tryParse(url);
+    if (uri == null) {
       return null;
     }
-    try {
-      if (urlResolverFn != null) {
-        return urlResolverFn!(
-          url,
-          relativeFrom: relativeFrom,
-          isEmbeddedObject: raw,
-        );
+    if (urlResolverFn == null) {
+      // When we have no URL resolver, we may allow absolute URLs (with scheme) or
+      // in-page URLs (only fragments without any path reference).
+      if (uri.hasScheme || (uri.path.isEmpty && uri.hasFragment)) {
+        return url;
       }
+      // Otherwise we should not display the URL and remove the DOM node.
+      return null;
+    }
+    try {
+      // Trying to resolve the URL using the given resolver function.
+      return urlResolverFn!(
+        url,
+        relativeFrom: relativeFrom,
+        isEmbeddedObject: raw,
+      );
     } catch (e, st) {
       _logger.warning('Link rewrite failed: $url', e, st);
     }
-    return url;
+    // Safe URL fallback: removing the node containing the URL that we were not able to resolve.
+    return null;
   }
 }
 


Test patch:
diff --git a/app/test/shared/markdown_test.dart b/app/test/shared/markdown_test.dart
index 1f6b45ddbb..2d63aaae77 100644
--- a/app/test/shared/markdown_test.dart
+++ b/app/test/shared/markdown_test.dart
@@ -47,15 +47,13 @@ void main() {
     });
 
     test('sibling link within site', () {
-      expect(markdownToHtml('[text](README.md)'),
-          '<p><a href="README.md">text</a></p>\n');
+      expect(markdownToHtml('[text](README.md)'), '<p>text</p>\n');
       expect(markdownToHtml('[text](README.md)', urlResolverFn: urlResolverFn),
           '<p><a href="https://github.com/example/project/blob/master/README.md" rel="ugc">text</a></p>\n');
     });
 
     test('sibling image within site', () {
-      expect(markdownToHtml('![text](image.png)'),
-          '<p><img src="image.png" alt="text"></p>\n');
+      expect(markdownToHtml('![text](image.png)'), '<p>[text]</p>\n');
       expect(markdownToHtml('![text](image.png)', urlResolverFn: urlResolverFn),
           '<p><img src="https://github.com/example/project/raw/master/image.png" alt="text"></p>\n');
     });
@@ -64,7 +62,7 @@ void main() {
       expect(
           markdownToHtml('![text](image.png)',
               relativeFrom: 'example/README.md'),
-          '<p><img src="image.png" alt="text"></p>\n');
+          '<p>[text]</p>\n');
       expect(
           markdownToHtml('![text](image.png)',
               urlResolverFn: urlResolverFn, relativeFrom: 'example/README.md'),
@@ -72,8 +70,7 @@ void main() {
     });
 
     test('sibling link plus relative link', () {
-      expect(markdownToHtml('[text](README.md#section)'),
-          '<p><a href="README.md#section">text</a></p>\n');
+      expect(markdownToHtml('[text](README.md#section)'), '<p>text</p>\n');
       expect(
           markdownToHtml('[text](README.md#section)',
               urlResolverFn: urlResolverFn),
@@ -81,8 +78,7 @@ void main() {
     });
 
     test('child link within site', () {
-      expect(markdownToHtml('[text](example/README.md)'),
-          '<p><a href="example/README.md">text</a></p>\n');
+      expect(markdownToHtml('[text](example/README.md)'), '<p>text</p>\n');
       expect(
           markdownToHtml('[text](example/README.md)',
               urlResolverFn: urlResolverFn),
@@ -90,8 +86,7 @@ void main() {
     });
 
     test('child image within site', () {
-      expect(markdownToHtml('![text](example/image.png)'),
-          '<p><img src="example/image.png" alt="text"></p>\n');
+      expect(markdownToHtml('![text](example/image.png)'), '<p>[text]</p>\n');
       expect(
           markdownToHtml('![text](example/image.png)',
               urlResolverFn: urlResolverFn),
@@ -99,8 +94,7 @@ void main() {
     });
 
     test('root link within site', () {
-      expect(markdownToHtml('[text](/README.md)'),
-          '<p><a href="/README.md">text</a></p>\n');
+      expect(markdownToHtml('[text](/README.md)'), '<p>text</p>\n');
       expect(
           markdownToHtml('[text](/example/README.md)',
               urlResolverFn: urlResolverFn),
@@ -108,8 +102,7 @@ void main() {
     });
 
     test('root image within site', () {
-      expect(markdownToHtml('![text](/image.png)'),
-          '<p><img src="/image.png" alt="text"></p>\n');
+      expect(markdownToHtml('![text](/image.png)'), '<p>[text]</p>\n');
       expect(
           markdownToHtml('![text](/example/image.png)',
               urlResolverFn: urlResolverFn),
@@ -131,14 +124,14 @@ void main() {
       expect(
           markdownToHtml('[text](README.md)',
               urlResolverFn: fallbackUrlResolverFn('ftp://example.com/blah')),
-          '<p><a href="README.md">text</a></p>\n');
+          '<p>text</p>\n');
     });
 
     test('not valid host', () {
       expect(
           markdownToHtml('[text](README.md)',
               urlResolverFn: fallbackUrlResolverFn('http://com/blah')),
-          '<p><a href="README.md">text</a></p>\n');
+          '<p>text</p>\n');
     });
   });
 
@@ -170,7 +163,7 @@ void main() {
 
     test('bad image link with attribute', () {
       expect(markdownToHtml('![demo](src="https://github.com/a/b/c.gif")'),
-          '<p></p>\n');
+          '<p>[demo]</p>\n');
     });
   });