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

Issue:
`pub.dev/<package_name>` not found page should provide relevant actions
Right now navigating to `pub.dev/<package_name>` leads to a 404 not found page. However, it's quite likely someone navigating to this path with a valid package name wants to visit that package's page.

Rather than just listing possible next steps, I think we could provide two more prominent buttons:

1. A button to go directly to searching with the same query.
2. If a package with a matching name does exist, additionally add a button that goes directly to that package's page.

An alternative or addition to **1** could be pre-filling the search bar.

Pull request description:
Fixes #8871, alternative to #8879.

<img width="641" height="257" alt="image" src="https://github.com/user-attachments/assets/c3aa7954-e229-489d-80f8-50b00e2e1949" />
<img width="716" height="247" alt="image" src="https://github.com/user-attachments/assets/f19867c4-0891-4b34-9636-7cdb9538d559" />

Patch:
diff --git a/app/lib/frontend/handlers/misc.dart b/app/lib/frontend/handlers/misc.dart
index 3915225aaa..851eb71b5c 100644
--- a/app/lib/frontend/handlers/misc.dart
+++ b/app/lib/frontend/handlers/misc.dart
@@ -272,18 +272,47 @@ Future<shelf.Response> experimentalHandler(shelf.Request request) async {
   });
 }
 
-String _notFoundMessage(Uri requestedUri) {
-  return 'You\'ve stumbled onto a page (`${requestedUri.path}`) that doesn\'t exist. '
-      'Luckily you have several options:\n\n'
-      '- Use the search box above, which will list packages that match your query.\n'
-      '- Visit the [packages](/packages) page and start browsing.\n'
-      '- Pick one of the top packages, listed on the [home page](/).\n';
-}
-
 /// Renders a formatted response when the request points to a missing or invalid path.
 shelf.Response formattedNotFoundHandler(shelf.Request request) {
+  String? package;
+  String? searchQuery;
+
+  // Extract unidentified text from the request URI.
+  final shouldSuggest = request.requestedUri.queryParameters.isEmpty &&
+      request.requestedUri.pathSegments.length == 1;
+  var unidentifiedText =
+      shouldSuggest ? request.requestedUri.pathSegments.single.trim() : '';
+
+  // may render additional content
+  if (unidentifiedText.isNotEmpty) {
+    final isPackage = nameTracker.hasPackage(unidentifiedText);
+    if (isPackage) {
+      package = unidentifiedText;
+      searchQuery = unidentifiedText;
+    } else {
+      // somewhat normalize content
+      unidentifiedText = unidentifiedText
+          .replaceAll('`', '')
+          .replaceAll(RegExp(r'\s+'), ' ')
+          .trim();
+      // clip long content
+      if (unidentifiedText.length > 100) {
+        unidentifiedText = unidentifiedText.substring(0, 100);
+      }
+      // require a few characters for search
+      if (unidentifiedText.length > 2) {
+        searchQuery = unidentifiedText;
+      }
+    }
+  }
+
   return htmlResponse(
-    renderErrorPage(default404NotFound, _notFoundMessage(request.requestedUri)),
+    renderFormattedNotFoundPage(
+      title: default404NotFound,
+      requestedPath: request.requestedUri.path,
+      package: package,
+      searchQuery: searchQuery,
+    ),
     status: 404,
   );
 }
diff --git a/app/lib/frontend/templates/misc.dart b/app/lib/frontend/templates/misc.dart
index 3f8ee7b125..1196f3469a 100644
--- a/app/lib/frontend/templates/misc.dart
+++ b/app/lib/frontend/templates/misc.dart
@@ -5,8 +5,8 @@
 import 'dart:io' as io;
 
 import 'package:path/path.dart' as p;
-import 'package:pub_dev/frontend/templates/views/page/topics_list.dart';
 
+import '../../shared/urls.dart' as urls;
 import '../dom/dom.dart' as d;
 import '../static_files.dart' as static_files;
 
@@ -15,6 +15,7 @@ import 'views/account/unauthenticated.dart';
 import 'views/account/unauthorized.dart';
 import 'views/page/error.dart';
 import 'views/page/standalone.dart';
+import 'views/page/topics_list.dart';
 
 /// The content of `/doc/policy.md`
 final _policyMarkdown = _readDocContent('policy.md');
@@ -139,6 +140,32 @@ String renderErrorPage(String title, String message) {
   );
 }
 
+/// Renders the formatted 404 page with optional content.
+String renderFormattedNotFoundPage({
+  required String title,
+  required String requestedPath,
+  String? package,
+  String? searchQuery,
+}) {
+  final options = [
+    if (package != null)
+      'Go to [package:$package](${urls.pkgPageUrl(package)}).',
+    if (searchQuery != null)
+      'Search for packages matching <a href="${urls.searchUrl(q: searchQuery)}" rel="nofollow ugc">$searchQuery</a>.'
+    else
+      'Use the search box above, which will list packages that match your query.',
+    'Visit the [packages](/packages) page and start browsing.',
+    'Pick one of the top packages, listed on the [home page](/).',
+  ];
+
+  final body =
+      'You\'ve stumbled onto a page (`$requestedPath`) that doesn\'t exist. '
+      'Luckily you have several options:\n\n'
+      '${options.map((o) => '- $o\n').join()}';
+
+  return renderErrorPage(title, body);
+}
+
 d.Node renderFatalError({
   required String title,
   required Uri requestedUri,


Test patch:
diff --git a/app/test/frontend/handlers/misc_test.dart b/app/test/frontend/handlers/misc_test.dart
new file mode 100644
index 0000000000..bfc8a28d9f
--- /dev/null
+++ b/app/test/frontend/handlers/misc_test.dart
@@ -0,0 +1,55 @@
+// 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:test/test.dart';
+
+import '../../shared/test_services.dart';
+import '_utils.dart';
+
+void main() {
+  group('404 page', () {
+    testWithProfile('without additional action', fn: () async {
+      final rs = await issueGet('/subdir/not-existing-package');
+      await expectHtmlResponse(
+        rs,
+        status: 404,
+        present: [
+          '404 Not Found',
+        ],
+        absent: [
+          '/packages/not-existing-package', // link to package page
+          '/packages?q=not-existing-package', // link to search page
+        ],
+      );
+    });
+
+    testWithProfile('link to package page', fn: () async {
+      final rs = await issueGet('/oxygen');
+      await expectHtmlResponse(
+        rs,
+        status: 404,
+        present: [
+          '404 Not Found',
+          '/packages/oxygen', // link to package page
+          '/packages?q=oxygen', // link to search page
+        ],
+      );
+    });
+
+    testWithProfile('link to search page', fn: () async {
+      final rs = await issueGet('/not-oxygen');
+      await expectHtmlResponse(
+        rs,
+        status: 404,
+        present: [
+          '404 Not Found',
+          '/packages?q=not-oxygen', // link to search page
+        ],
+        absent: [
+          '/packages/not-oxygen', // link to package page
+        ],
+      );
+    });
+  });
+}
diff --git a/pkg/pub_integration/test/browser_test.dart b/pkg/pub_integration/test/browser_test.dart
index 18d7551828..45dd54cb82 100644
--- a/pkg/pub_integration/test/browser_test.dart
+++ b/pkg/pub_integration/test/browser_test.dart
@@ -114,6 +114,27 @@ void main() {
               prefix: 'package-page/score-page', selector: 'body');
         },
       );
+
+      // 404 page
+      await user.withBrowserPage((page) async {
+        await page.gotoOrigin('/retry');
+        await page.takeScreenshots(
+          prefix: '404-page/link-to-retry-package',
+          selector: 'body',
+        );
+
+        await page.gotoOrigin('/not-retry');
+        await page.takeScreenshots(
+          prefix: '404-page/link-to-retry-search',
+          selector: 'body',
+        );
+
+        await page.gotoOrigin('/x/y/z/w');
+        await page.takeScreenshots(
+          prefix: '404-page/generic-page',
+          selector: 'body',
+        );
+      });
     });
   }, timeout: Timeout.factor(testTimeoutFactor));
 }