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 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 $searchQuery.' + 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, 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)); }