Skip to content

Commit d6555db

Browse files
authored
New 404 page: simple links to package page and search. (#8884)
1 parent ea88ab9 commit d6555db

File tree

4 files changed

+142
-10
lines changed

4 files changed

+142
-10
lines changed

app/lib/frontend/handlers/misc.dart

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -272,18 +272,47 @@ Future<shelf.Response> experimentalHandler(shelf.Request request) async {
272272
});
273273
}
274274

275-
String _notFoundMessage(Uri requestedUri) {
276-
return 'You\'ve stumbled onto a page (`${requestedUri.path}`) that doesn\'t exist. '
277-
'Luckily you have several options:\n\n'
278-
'- Use the search box above, which will list packages that match your query.\n'
279-
'- Visit the [packages](/packages) page and start browsing.\n'
280-
'- Pick one of the top packages, listed on the [home page](/).\n';
281-
}
282-
283275
/// Renders a formatted response when the request points to a missing or invalid path.
284276
shelf.Response formattedNotFoundHandler(shelf.Request request) {
277+
String? package;
278+
String? searchQuery;
279+
280+
// Extract unidentified text from the request URI.
281+
final shouldSuggest = request.requestedUri.queryParameters.isEmpty &&
282+
request.requestedUri.pathSegments.length == 1;
283+
var unidentifiedText =
284+
shouldSuggest ? request.requestedUri.pathSegments.single.trim() : '';
285+
286+
// may render additional content
287+
if (unidentifiedText.isNotEmpty) {
288+
final isPackage = nameTracker.hasPackage(unidentifiedText);
289+
if (isPackage) {
290+
package = unidentifiedText;
291+
searchQuery = unidentifiedText;
292+
} else {
293+
// somewhat normalize content
294+
unidentifiedText = unidentifiedText
295+
.replaceAll('`', '')
296+
.replaceAll(RegExp(r'\s+'), ' ')
297+
.trim();
298+
// clip long content
299+
if (unidentifiedText.length > 100) {
300+
unidentifiedText = unidentifiedText.substring(0, 100);
301+
}
302+
// require a few characters for search
303+
if (unidentifiedText.length > 2) {
304+
searchQuery = unidentifiedText;
305+
}
306+
}
307+
}
308+
285309
return htmlResponse(
286-
renderErrorPage(default404NotFound, _notFoundMessage(request.requestedUri)),
310+
renderFormattedNotFoundPage(
311+
title: default404NotFound,
312+
requestedPath: request.requestedUri.path,
313+
package: package,
314+
searchQuery: searchQuery,
315+
),
287316
status: 404,
288317
);
289318
}

app/lib/frontend/templates/misc.dart

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import 'dart:io' as io;
66

77
import 'package:path/path.dart' as p;
8-
import 'package:pub_dev/frontend/templates/views/page/topics_list.dart';
98

9+
import '../../shared/urls.dart' as urls;
1010
import '../dom/dom.dart' as d;
1111
import '../static_files.dart' as static_files;
1212

@@ -15,6 +15,7 @@ import 'views/account/unauthenticated.dart';
1515
import 'views/account/unauthorized.dart';
1616
import 'views/page/error.dart';
1717
import 'views/page/standalone.dart';
18+
import 'views/page/topics_list.dart';
1819

1920
/// The content of `/doc/policy.md`
2021
final _policyMarkdown = _readDocContent('policy.md');
@@ -139,6 +140,32 @@ String renderErrorPage(String title, String message) {
139140
);
140141
}
141142

143+
/// Renders the formatted 404 page with optional content.
144+
String renderFormattedNotFoundPage({
145+
required String title,
146+
required String requestedPath,
147+
String? package,
148+
String? searchQuery,
149+
}) {
150+
final options = [
151+
if (package != null)
152+
'Go to [package:$package](${urls.pkgPageUrl(package)}).',
153+
if (searchQuery != null)
154+
'Search for packages matching <a href="${urls.searchUrl(q: searchQuery)}" rel="nofollow ugc">$searchQuery</a>.'
155+
else
156+
'Use the search box above, which will list packages that match your query.',
157+
'Visit the [packages](/packages) page and start browsing.',
158+
'Pick one of the top packages, listed on the [home page](/).',
159+
];
160+
161+
final body =
162+
'You\'ve stumbled onto a page (`$requestedPath`) that doesn\'t exist. '
163+
'Luckily you have several options:\n\n'
164+
'${options.map((o) => '- $o\n').join()}';
165+
166+
return renderErrorPage(title, body);
167+
}
168+
142169
d.Node renderFatalError({
143170
required String title,
144171
required Uri requestedUri,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:test/test.dart';
6+
7+
import '../../shared/test_services.dart';
8+
import '_utils.dart';
9+
10+
void main() {
11+
group('404 page', () {
12+
testWithProfile('without additional action', fn: () async {
13+
final rs = await issueGet('/subdir/not-existing-package');
14+
await expectHtmlResponse(
15+
rs,
16+
status: 404,
17+
present: [
18+
'404 Not Found',
19+
],
20+
absent: [
21+
'/packages/not-existing-package', // link to package page
22+
'/packages?q=not-existing-package', // link to search page
23+
],
24+
);
25+
});
26+
27+
testWithProfile('link to package page', fn: () async {
28+
final rs = await issueGet('/oxygen');
29+
await expectHtmlResponse(
30+
rs,
31+
status: 404,
32+
present: [
33+
'404 Not Found',
34+
'/packages/oxygen', // link to package page
35+
'/packages?q=oxygen', // link to search page
36+
],
37+
);
38+
});
39+
40+
testWithProfile('link to search page', fn: () async {
41+
final rs = await issueGet('/not-oxygen');
42+
await expectHtmlResponse(
43+
rs,
44+
status: 404,
45+
present: [
46+
'404 Not Found',
47+
'/packages?q=not-oxygen', // link to search page
48+
],
49+
absent: [
50+
'/packages/not-oxygen', // link to package page
51+
],
52+
);
53+
});
54+
});
55+
}

pkg/pub_integration/test/browser_test.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ void main() {
114114
prefix: 'package-page/score-page', selector: 'body');
115115
},
116116
);
117+
118+
// 404 page
119+
await user.withBrowserPage((page) async {
120+
await page.gotoOrigin('/retry');
121+
await page.takeScreenshots(
122+
prefix: '404-page/link-to-retry-package',
123+
selector: 'body',
124+
);
125+
126+
await page.gotoOrigin('/not-retry');
127+
await page.takeScreenshots(
128+
prefix: '404-page/link-to-retry-search',
129+
selector: 'body',
130+
);
131+
132+
await page.gotoOrigin('/x/y/z/w');
133+
await page.takeScreenshots(
134+
prefix: '404-page/generic-page',
135+
selector: 'body',
136+
);
137+
});
117138
});
118139
}, timeout: Timeout.factor(testTimeoutFactor));
119140
}

0 commit comments

Comments
 (0)