Kaynağa Gözat

Send the search of a page that is not a page of posts to /search

The search box is on every page, and it sent like to the page it was on.
A page of posts searches itself, and a datasource answers the search of its
own page, but every other page ignored it.  Since a route takes only what
it declares, every such page had to declare a like it did nothing with.

Now /search is a page of posts, as series and users are: every post the
reader can see that the search finds.  index() points the search box at
/search, unless posts() says the page searches itself.  A search that finds
nothing is a page that says so, where a page of posts with no posts is a
404 for a visitor.  Only the pages of posts and /search take like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
troglodyne-bot 2 gün önce
ebeveyn
işleme
338b322692

+ 36 - 14
lib/Trog/Routes/HTML.pm

@@ -295,11 +295,16 @@ our %routes = (
         method   => 'GET',
         callback => \&Trog::Routes::HTML::bundle,
     },
+    '/search' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::search,
+        noindex  => 1,
+    },
 );
 
 # The scripts of a page of posts.  TCMS::_routes gives them to each route whose
 # callback is one of these, unless the route names scripts of its own.
-our @post_page_callbacks = map { "Trog::Routes::HTML::$_" } qw{posts series users};
+our @post_page_callbacks = map { "Trog::Routes::HTML::$_" } qw{posts series users search};
 our %post_page_scripts   = (
     scripts               => [qw{highlight.min.js reveal.min.js}],
     authenticated_scripts => [qw{post.js post_relations.js}],
@@ -321,14 +326,11 @@ my %text   = ( type => 'string' );
 my %number = ( type => 'integer' );
 my %texts  = ( type => 'array', items => { type => 'string' } );
 
-# The search box of every page sends like to the page it is on.
-my %search = ( like => \%text );
-
-# A page of posts: its pagination, the jump to a month, which sends the year
-# and month as well as the older it makes of them, a version of a post, RSS,
-# and an embed.
+# A page of posts: its search, its pagination, the jump to a month, which
+# sends the year and month as well as the older it makes of them, a version of
+# a post, RSS, and an embed.
 my %page = (
-    %search,
+    like => \%text,
     ( map { $_ => \%number } qw{page limit version} ),
     ( map { $_ => \%text } qw{older newer format embed nochrome year month} ),
 );
@@ -338,9 +340,8 @@ my sub fields (@names) {
 }
 
 our %inputs = (
-    ( map { ( "Trog::Routes::HTML::$_" => { query => \%page } ) } qw{posts series users} ),
-    ( map { ( "Trog::Routes::HTML::$_" => { query => \%search } ) } qw{index logout totp secrets config resetpass processed manual post_wizard sitemap} ),
-    'Trog::Routes::HTML::login'          => { query => { %search, to => \%text }, body => fields(qw{app to username password token display_name contact_email}) },
+    ( map { ( "Trog::Routes::HTML::$_" => { query => \%page } ) } qw{posts series users search} ),
+    'Trog::Routes::HTML::login'          => { query => { to => \%text }, body => fields(qw{app to username password token display_name contact_email}) },
     'Trog::Routes::HTML::secrets_save'   => { body  => fields(qw{name secret}) },
     'Trog::Routes::HTML::secrets_forget' => { body  => fields(qw{name}) },
     'Trog::Routes::HTML::post_save'      => { body  => { to => \%text, tags => \%texts },                           open => 1 },
@@ -362,10 +363,10 @@ our %inputs = (
     # The page hands its query to /api/requests_per, so it takes what that does.
     'Trog::Routes::HTML::metrics' => {
         query => sub {
-            return { %search, map { $_->{name} => $_->{schema} } @{ $Trog::Routes::JSON::routes{'/api/requests_per'}{parameters} } };
+            return { map { $_->{name} => $_->{schema} } @{ $Trog::Routes::JSON::routes{'/api/requests_per'}{parameters} } };
         }
     },
-    'Trog::Routes::HTML::sessions'          => { query => { %search, limit => \%number, filter_user => \%text, filter_event => \%text } },
+    'Trog::Routes::HTML::sessions'          => { query => { limit => \%number, filter_user => \%text, filter_event => \%text } },
     'Trog::Routes::HTML::guest_act'         => { body  => fields(qw{action guest hypervisor to}) },
     'Trog::Routes::HTML::guest_reprovision' => { body  => fields(qw{guest passphrase sudo totp remember to}) },
     'Trog::Routes::HTML::post_wizard_save'  => {
@@ -641,6 +642,7 @@ sub index ( $query, $content = '', $i_styles = [] ) {
             nochrome     => $in->{nochrome},
             search_lang  => $search_lang,
             search_help  => $search_help,
+            search_route => $query->{search_route} // '/search',
             theme_dir    => Trog::Themes::td(),
             content      => $content,
             title        => $title,
@@ -1727,6 +1729,22 @@ my sub _rss ( $query, $subtitle, $posts ) {    ## no critic (ProhibitSingleUseLe
     );
 }
 
+=head2 search
+
+Implements /search, which the search box of a page that is not a page of
+posts sends its search to: every post that the reader can see whose title or
+body holds like.
+
+=cut
+
+sub search ($query) {
+    $query->{title} = 'Search';
+
+    # Finding nothing is an answer, not a page that is not there.
+    $query->{may_be_empty} = 1;
+    return posts($query);
+}
+
 =head2 posts
 
 Display multi or single posts, supports RSS and pagination.
@@ -1800,7 +1818,7 @@ sub posts ( $query, $direct = 0 ) {
         unshift @posts, $user;
     }
 
-    if ( !$is_admin ) {
+    if ( !$is_admin && !$query->{may_be_empty} ) {
         return $query->{tpsgi}->notfound($query) unless @posts;
     }
 
@@ -1810,6 +1828,10 @@ sub posts ( $query, $direct = 0 ) {
     #Correct page headers
     my $ph = ( $themed && $Theme::paths{ $query->{route} } ) || $query->{route};
 
+    # A page of posts searches itself, and every other page sends its search to
+    # /search.  A datasource answers the search of its own page.
+    $query->{search_route} = $query->{route};
+
     return _rss( $query, $ph, \@posts ) if $fmt eq 'rss';
 
     # List the available headers/footers

+ 2 - 0
t/TCMS.t

@@ -162,6 +162,8 @@ subtest 'what a route takes from the request' => sub {
         'a page of posts takes its paging, search, feed and embed, and a campaign'
     );
     is_deeply( $names->( $paths->{'/humans.txt'}{get} ), $names->( $paths->{'/posts/p-one'}{get} ), 'and so does a route of posts that fixes its tag' );
+    is_deeply( $names->( $paths->{'/search'}{get} ),     $names->( $paths->{'/posts/p-one'}{get} ), 'and so does the search, which is a page of posts' );
+    is_deeply( $names->( $paths->{'/totp'}{get} ),       \@urchin,                                  'and a page that is not one takes no search' );
     ok( !( grep { $_ eq 'map' } @{ $names->( $paths->{'/sitemap/{map}.xml'}{get} ) } ), 'a name the path holds is not one of the query too' );
     is_deeply( $names->( $paths->{'/sitemap/{map}.xml'}{get}, 'path' ), ['map'], 'but of the path' );
     ok( !( grep { $_ eq 'title' || $_ eq 'scripts' || $_ eq 'template' } @{ $names->( $paths->{'/login'}{get} ) } ), 'the login page takes none of the names its page is made of' );

+ 1 - 1
t/Trog-Component-page.t

@@ -148,7 +148,7 @@ subtest 'the whole page still composes' => sub {
                 stylesheets  => ['/styles/screen.css'],
                 print_styles => [],
                 scripts      => [],
-                ( map { $_ => undef } qw{author default_tags embed failure is_admin lang message meta_desc meta_tags search_help search_lang show_madeby to user user_class} ),
+                ( map { $_ => undef } qw{author default_tags embed failure is_admin lang message meta_desc meta_tags search_help search_lang search_route show_madeby to user user_class} ),
             },
         );
     };

+ 16 - 0
t/Trog-Routes-HTML-series-lifecycle.t

@@ -1136,6 +1136,22 @@ subtest 'blog' => sub {
     unlike( $body, qr/Spec[ ]Payee[ ]Entity/, 'no bleed from the entities series' );
 };
 
+subtest 'search' => sub {
+    my ( $code, $body, $err ) = render( anon( route => '/search', like => 'Spec blog body' ), \&Trog::Routes::HTML::search );
+    is( $code, 200, 'a search renders' ) or return diag($err);
+    like( $body, qr/Spec[ ]Blog[ ]Child/, 'with the post whose body holds what was searched for' );
+    unlike( $body, qr/Spec[ ]Payee[ ]Entity/, 'and not one whose does not' );
+    like( $body, qr{<form[ ]action="/search"}, 'and its own search box searches again' );
+
+    ( $code, $body, $err ) = render( anon( route => '/search', like => 'bogus-nothing-matches-this' ), \&Trog::Routes::HTML::search );
+    is( $code, 200, 'a search that finds nothing is a page that says so, not a 404' ) or diag($err);
+
+    ( $code, $body ) = render( anon( route => '/specblog' ), \&Trog::Routes::HTML::series );
+    like( $body, qr{<form[ ]action="/specblog"}, 'the search box of a page of posts searches that page' );
+    ( $code, $body ) = render( anon( route => '/processed' ), \&Trog::Routes::HTML::processed );
+    like( $body, qr{<form[ ]action="/search"}, 'and the search box of any other page sends its search to /search' );
+};
+
 subtest 'a post that embeds another' => sub {
     my $embedded = find_post('Spec Blog Child') or return fail('the blog child is there to embed');
     my $host     = save_post(

+ 1 - 1
www/templates/html/index.tx

@@ -28,7 +28,7 @@
         <span id="common-search">
         <a href="/sitemap" title="Sitemap" class="topbar">🗺️</a>
         <: component('TopBar') :>
-        <form action="<: $route :>" method="GET" class="ib">
+        <form action="<: $search_route :>" method="GET" class="ib">
             <input required type="search" class="coolbutton topform" name="like" autofocus></input>
             <input class="coolbutton topform" type="submit" value="🔎"></input>
             <a class="topbar" target="_blank" href="<: $search_help :>" title="<: $search_lang :>">❓</a>