Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions app/lib/frontend/handlers/misc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -272,18 +272,58 @@ 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) {
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,
);
}
65 changes: 65 additions & 0 deletions app/lib/frontend/templates/misc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions app/test/frontend/handlers/misc_test.dart
Original file line number Diff line number Diff line change
@@ -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
],
);
});
});
}
21 changes: 21 additions & 0 deletions pkg/pub_integration/test/browser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down