diff --git a/app/lib/frontend/handlers/misc.dart b/app/lib/frontend/handlers/misc.dart index 3915225aaa..1cf9fd1bea 100644 --- a/app/lib/frontend/handlers/misc.dart +++ b/app/lib/frontend/handlers/misc.dart @@ -272,18 +272,58 @@ 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) { + LinkToAction? linkToAction; + + // 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) { + linkToAction = LinkToAction( + leadTitle: 'Matching package?', + leadText: + 'We have identified a matching package that you may be looking for.', + href: urls.pkgPageUrl(unidentifiedText), + buttonLabel: 'Go to $unidentifiedText', + buttonTitle: 'Visit the package page.', + ); + } 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) { + linkToAction = LinkToAction( + leadTitle: 'Search for it!', + leadText: 'You seem to be looking for (`$unidentifiedText`).\n\n' + 'We have prepared a link to the search page with the unidentified text in the URL.', + href: urls.searchUrl(q: unidentifiedText), + buttonLabel: 'Search', + buttonTitle: 'Visit the search page.', + ); + } + } + } + return htmlResponse( - renderErrorPage(default404NotFound, _notFoundMessage(request.requestedUri)), + renderFormattedNotFoundPage( + title: default404NotFound, + requestedPath: request.requestedUri.path, + linkToAction: linkToAction, + ), status: 404, ); } diff --git a/app/lib/frontend/templates/misc.dart b/app/lib/frontend/templates/misc.dart index 3f8ee7b125..e80e62b250 100644 --- a/app/lib/frontend/templates/misc.dart +++ b/app/lib/frontend/templates/misc.dart @@ -139,6 +139,71 @@ String renderErrorPage(String title, String message) { ); } +/// Describes an additional action that the formatted not found page may display. +class LinkToAction { + // The lead title will be rendered as section header. + final String leadTitle; + // The lead text will be rendered as section paragraph, right before the action button. + final String leadText; + + /// The URI to go to via the link. + final String href; + + /// The visible text of the action button. + final String buttonLabel; + + /// The on-hover help text of the action button. + final String buttonTitle; + + LinkToAction({ + required this.leadTitle, + required this.leadText, + required this.href, + required this.buttonLabel, + required this.buttonTitle, + }); +} + +/// Renders the formatted 404 page with optional content. +String renderFormattedNotFoundPage({ + required String title, + required String requestedPath, + LinkToAction? linkToAction, +}) { + final mainMessage = + 'You\'ve stumbled onto a page (`$requestedPath`) 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'; + + return renderLayoutPage( + PageType.error, + errorPageNode( + title: title, + content: d.fragment([ + d.markdown(mainMessage), + if (linkToAction != null) + d.fragment([ + d.h3(text: linkToAction.leadTitle), + d.markdown(linkToAction.leadText), + d.p( + child: d.a( + classes: ['link-button'], + href: linkToAction.href, + text: linkToAction.buttonLabel, + title: linkToAction.buttonTitle, + rel: 'nofollow ugc', + ), + ), + ]), + ]), + ), + title: title, + noIndex: true, + ); +} + 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..79133f49f2 --- /dev/null +++ b/app/test/frontend/handlers/misc_test.dart @@ -0,0 +1,57 @@ +// 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 + ], + absent: [ + '/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)); }