Quellcode durchsuchen

Name the scripts of a page in its route, and serve only the bundles that routes declare

The bundle route built a bundle for any list of real scripts that its
URL named. It kept none of them, but it still read, minified and hashed
the scripts for each one, so a request could cost the server that work
as often as somebody asked. A limit on the cache would have traded
that for evicting the bundles that pages use.

Now a route definition names the scripts of its page: scripts for every
reader, and authenticated_scripts for a reader who is logged in as well.
build_routes passes them on as x-tcms-scripts and
x-tcms-authenticated_scripts, and finish_render reads them from the
dispatcher, which tPSGI sets from the route table after it reads the
request. So no list comes from the request. Before this, finish_render
read $vars->{scripts}, which a route that spreads %$query last could
take from a query parameter, and which would now be fatal.

The routes of posts come from the datastore, so _routes gives each
route whose callback is posts, series or users the scripts of a post
page, unless the route names its own. /metrics and /admin/wyzzerdd name
theirs. The fourth argument of index() is gone.

build_routes declares the two lists of each route to Trog::Bundle at
startup, so every worker knows them before it renders a page.
scripts() declares the list that it is given, since only a route can
have named it. served() returns undef for a list that nobody declared,
and the route answers 404. So the cache holds only declared bundles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
troglodyne-bot vor 3 Tagen
Ursprung
Commit
656da54486
7 geänderte Dateien mit 146 neuen und 90 gelöschten Zeilen
  1. 7 3
      design.md
  2. 16 1
      lib/TCMS.pm
  3. 58 57
      lib/Trog/Bundle.pm
  4. 16 8
      lib/Trog/Routes/HTML.pm
  5. 18 0
      t/TCMS.t
  6. 12 9
      t/Trog-Bundle.t
  7. 19 12
      t/Trog-Routes-HTML-series-lifecycle.t

+ 7 - 3
design.md

@@ -379,9 +379,13 @@ override a handful of rules without restating the sheet. A theme may ship a
 `routes.pm` to add routes of its own.
 
 A script resolves as a template does. A route names the scripts of its page in
-a list, and a template does not write a `<script src>` tag of its own.
-`finish_render` links the list as one bundle, which `Trog::Bundle` builds and
-`/bundle/{version}/{names}.js` serves. The version is a digest of the bundle,
+its definition: `scripts` for every reader, and `authenticated_scripts` for a
+reader who is logged in as well. A template does not write a `<script src>` tag
+of its own. The route of a post gets the scripts of a post page, from
+`%Trog::Routes::HTML::post_page_scripts`. `finish_render` links the lists as one
+bundle, which `Trog::Bundle` builds and `/bundle/{version}/{names}.js` serves.
+That route serves only a list that a route declares, so a request cannot make
+tCMS build a bundle. The version is a digest of the bundle,
 so a browser keeps the bundle until a script in it changes. A script whose name
 ends in `.min.js` goes into the bundle as it is, and the others are minified.
 tPSGI saves each bundle that a page links as a static, and it watches the

+ 16 - 1
lib/TCMS.pm

@@ -9,6 +9,7 @@ use Clone qw{clone};
 
 use CGI::Cookie ();
 use List::Util  ();
+use Sub::Util   ();
 use URI();
 
 #Grab our custom routes
@@ -29,6 +30,7 @@ use Trog::Config;
 use Trog::Vault;    ## no critic (ProhibitUnusedImports) -- loaded for what it does at load time, as above
 use Trog::Data;
 use Trog::Themes;
+use Trog::Bundle;
 
 #XXX Return a clone of the routing table ref, because code modifies it later
 my sub _routes ( $data = {} ) {
@@ -62,6 +64,14 @@ my sub _routes ( $data = {} ) {
     @routes{ keys(%roots) }                      = values(%roots);
     @routes{ keys(%themed) }                     = values(%themed) if %themed;
 
+    # A page of posts gets the scripts of one, unless its route names its own.
+    foreach my $path ( keys(%routes) ) {
+        my $callback = $routes{$path}{callback} // '';
+        my $name     = ref $callback ? Sub::Util::subname($callback) : $callback;
+        next if exists $routes{$path}{scripts} || !List::Util::any { $_ eq $name } @Trog::Routes::HTML::post_page_callbacks;
+        $routes{$path} = { %Trog::Routes::HTML::post_page_scripts, %{ $routes{$path} } };
+    }
+
     # Add in global routes, here because they *must* know about all other routes
     # Also, nobody should ever override these.
     $routes{'/robots.txt'} = {
@@ -119,7 +129,7 @@ F<config/setup>.
 
 =cut
 
-my @tcms_keys = qw{auth nocache noindex nomap robot_name totp_exempt};
+my @tcms_keys = qw{auth nocache noindex nomap robot_name totp_exempt scripts authenticated_scripts};
 
 sub build_routes {
     my $conf   = Trog::Config::get();
@@ -132,6 +142,11 @@ sub build_routes {
     foreach my $k ( sort grep { $_ ne 'default' } keys(%routes) ) {
         my $v = $routes{$k};
 
+        # The bundles that this route's page can link, for a reader and for a
+        # reader who is logged in.
+        my @scripts = @{ $v->{scripts} // [] };
+        Trog::Bundle::declare( \@scripts, [ @scripts, @{ $v->{authenticated_scripts} // [] } ] );
+
         my $method = lc( $v->{method} // 'GET' );
 
         # FlatFile and the tag index name the callback rather than hold it.

+ 58 - 57
lib/Trog/Bundle.pm

@@ -15,59 +15,50 @@ use Trog::Themes ();
 
 The scripts that a page names, served as one response.
 
-A route names its scripts in a list, and finish_render() in Trog::Routes::HTML
-links the bundle of them at the href that scripts() returns.  The route
-C</bundle/{version}/{names}.js> serves that bundle.
+A route names the scripts of its page in its definition: C<scripts> for every
+reader, and C<authenticated_scripts> for a reader who is logged in as well.
+finish_render() in Trog::Routes::HTML links the bundle of them at the href that
+scripts() returns, and the route C</bundle/{version}/{names}.js> serves it.
+
+The lists are the routes', and never the request's.  The route serves no list
+that a route did not declare, so a URL cannot make tCMS build anything.
 
 =cut
 
-# The names of each bundle that a page links, as its href carries them, to the
-# bundle.
+# The names of each bundle, as its href carries them, to the bundle.
 my %bundles;
 
-# The file of each name, or undef for a name that is not a script.
-my sub files_of (@names) {
-    return map {
-        my $file = m/\A\w[\w.-]*[.]js\z/ ? Trog::Themes::themed_script($_) =~ s{\A/}{www/}r : undef;
-        $file && -f $file ? $file : undef;    ## no critic (ProhibitFiletest_f) -- a name that is not a file is not a script
-    } @names;
-}
+# The names of each list that a route declared, as its href carries them.
+our %declared;
 
-# The names as an href carries them, and what changes when a file does.
-my sub identify ( $names, $files ) {
-    return (
-        join( '+',  map { s/[.]js\z//r } @$names ),
-        join( "\0", map { join( ':', $_, ( stat $_ )[ 7, 9 ] ) } @$files ),
-    );
+# The names as an href carries them.
+my sub key_of (@names) {
+    return join( '+', map { s/[.]js\z//r } @names );
 }
 
-my sub build ( $key, $signature, @files ) {
+=head2 declare(@lists)
 
-    # A newline before the semicolon, because a file can end in a line comment.
-    my $body = join(
-        "\n;\n",
-        map {
-            my $js = File::Slurper::read_binary($_);
-            m/[.]min[.]js\z/ ? $js : JavaScript::Minifier::XS::minify($js)
-        } @files
-    );
-    my $version = substr( Digest::SHA::sha256_hex($body), 0, 16 );
-    return {
-        href      => "/bundle/$version/$key.js",
-        version   => $version,
-        body      => $body,
-        signature => $signature,
-    };
+Declare each list, an ARRAY reference of names as scripts() takes them, so that
+the route serves its bundle.  TCMS::build_routes() declares the lists of every
+route at startup, so that a worker serves a bundle before it renders a page that
+links it.
+
+=cut
+
+sub declare (@lists) {
+    $declared{ key_of( List::Util::uniq(@$_) ) } = 1 for grep { @$_ } @lists;
+    return;
 }
 
 =head2 scripts(@names) = HASHREF or undef
 
-The bundle that a page links, of the scripts that @names names.  Each name is a
-file name under F<www/scripts>, such as C<post.js>.  The theme's copy of a
-script is used in place of the stock one.  The order is the order of @names, and
-a name that is given twice is used once.
+The bundle of the scripts that @names names.  Each name is a file name under
+F<www/scripts>, such as C<post.js>.  The theme's copy of a script is used in
+place of the stock one.  The order is the order of @names, and a name that is
+given twice is used once.
 
-Returns undef for an empty list, and dies if a name is not a script.
+Returns undef for an empty list, and dies if a name is not a script.  The list
+is declared, as declare() does, since only a route can have named it.
 
 The hash has these keys:
 
@@ -84,34 +75,46 @@ sub scripts (@names) {
     @names = List::Util::uniq(@names);
     return undef unless @names;
 
-    my @files   = files_of(@names);
+    my @files = map {
+        my $file = m/\A\w[\w.-]*[.]js\z/ ? Trog::Themes::themed_script($_) =~ s{\A/}{www/}r : undef;
+        $file && -f $file ? $file : undef;    ## no critic (ProhibitFiletest_f) -- a name that is not a file is not a script
+    } @names;
     my @missing = map { $names[$_] } grep { !defined $files[$_] } 0 .. $#names;
     die "There is no script to bundle named @missing\n" if @missing;
 
-    my ( $key, $signature ) = identify( \@names, \@files );
+    my $key       = key_of(@names);
+    my $signature = join( "\0", map { join( ':', $_, ( stat $_ )[ 7, 9 ] ) } @files );
+    $declared{$key} = 1;
     return $bundles{$key} if $bundles{$key} && $bundles{$key}{signature} eq $signature;
-    return $bundles{$key} = build( $key, $signature, @files );
+
+    # A newline before the semicolon, because a file can end in a line comment.
+    my $body = join(
+        "\n;\n",
+        map {
+            my $js = File::Slurper::read_binary($_);
+            m/[.]min[.]js\z/ ? $js : JavaScript::Minifier::XS::minify($js)
+        } @files
+    );
+    my $version = substr( Digest::SHA::sha256_hex($body), 0, 16 );
+    return $bundles{$key} = {
+        href      => "/bundle/$version/$key.js",
+        version   => $version,
+        body      => $body,
+        signature => $signature,
+    };
 }
 
 =head2 served(@names) = HASHREF or undef
 
-The bundle that the route serves for @names, as scripts() describes it, or undef
-if a name is not a script.
-
-It is the bundle that scripts() built, if that one is current.  Otherwise it is
-built for this request and not kept, because anyone can put any list in a URL.
+The bundle that the route serves for @names, as scripts() returns it, or undef
+if a route did not declare the list.
 
 =cut
 
 sub served (@names) {
     @names = List::Util::uniq(@names);
-    my @files = files_of(@names);
-    return undef if !@names || List::Util::any { !defined } @files;
-
-    my ( $key, $signature ) = identify( \@names, \@files );
-    my $linked = $bundles{$key};
-    return $linked if $linked && $linked->{signature} eq $signature;
-    return build( $key, $signature, @files );
+    return undef unless @names && $declared{ key_of(@names) };
+    return scripts(@names);
 }
 
 =head2 save($tpsgi, $bundle)
@@ -119,9 +122,7 @@ sub served (@names) {
 Have tPSGI save $bundle as a static render after the response, so that the
 webserver serves it from then on.  A process saves a given bundle once.
 
-finish_render() calls this for the bundle that a page links, so only the lists
-that routes name are saved.  A list that only a URL names is built and served,
-but never saved, because anyone can ask for as many of those as they like.
+finish_render() calls this for the bundle that a page links.
 
 It also has tPSGI watch the directories of the scripts.  When a script changes,
 the saved pages and bundles are thrown away: the pages are rendered again and

+ 16 - 8
lib/Trog/Routes/HTML.pm

@@ -167,6 +167,7 @@ our %routes = (
         method   => 'GET',
         auth     => 1,
         callback => \&Trog::Routes::HTML::metrics,
+        scripts  => ['chart.min.js'],
     },
     '/admin/sessions' => {
         method   => 'GET',
@@ -217,6 +218,7 @@ our %routes = (
         callback => \&Trog::Routes::HTML::post_wizard,
         noindex  => 1,
         nocache  => 1,
+        scripts  => ['post_wizard.js'],
     },
     '/admin/wyzzerdd/save' => {
         method   => 'POST',
@@ -302,6 +304,14 @@ our %routes = (
     },
 );
 
+# 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_scripts   = (
+    scripts               => [qw{highlight.min.js reveal.min.js}],
+    authenticated_scripts => [qw{post.js post_relations.js}],
+);
+
 # Grab theme routes
 my $themed    = 0;
 my $theme_dir = Trog::Themes::get_dir();
@@ -522,7 +532,7 @@ Most subsequent functions simply pass content to this function.
 
 =cut
 
-sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
+sub index ( $query, $content = '', $i_styles = [] ) {
     $query->{theme_dir} = Trog::Themes::td();
 
     absorb_feedback($query);
@@ -577,7 +587,6 @@ sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
             categories   => \@series,
             stylesheets  => \@styles,
             print_styles => \@p_styles,
-            scripts      => $i_scripts,
             show_madeby  => $Theme::show_madeby ? 1 : 0,
             embed        => $query->{embed}     ? 1 : 0,
             embed_video  => $query->{primary_post}{is_video},
@@ -1886,8 +1895,7 @@ sub posts ( $query, $direct = 0 ) {
     return $content if ref $content eq "ARRAY";
 
     return $content if $direct;
-    my @scripts = ( ( $query->{user} ? qw{post.js post_relations.js} : () ), qw{highlight.min.js reveal.min.js} );
-    return Trog::Routes::HTML::index( $query, $content, $styles, \@scripts );
+    return Trog::Routes::HTML::index( $query, $content, $styles );
 }
 
 =head2 redact_private($post, $seen)
@@ -2354,7 +2362,6 @@ sub metrics ($query) {
         },
         undef,
         ['post.css'],
-        ['chart.min.js'],
     );
 }
 
@@ -2671,7 +2678,6 @@ sub post_wizard ($query) {
         },
         undef,
         ['post.css'],
-        ['post_wizard.js'],
     );
 }
 
@@ -3484,12 +3490,14 @@ sub finish_render ( $template, $vars, %headers ) {
     $vars->{title}        //= 'tCMS';
     $vars->{stylesheets}  //= [];
     $vars->{print_styles} //= [qw{structure.css print.css}];
-    $vars->{scripts}      //= [];
 
     # Theme-ize the paths
     $vars->{stylesheets}  = [ @{ build_themed_styles( $vars->{stylesheets} ) } ];
     $vars->{print_styles} = [ @{ build_themed_styles( $vars->{print_styles} ) } ];
-    my $bundle = Trog::Bundle::scripts( @{ Trog::Utils::coerce_array( $vars->{scripts} ) } );
+
+    # From the route, and never from the request.
+    my $route  = $vars->{dispatcher} // {};
+    my $bundle = Trog::Bundle::scripts( @{ $route->{'x-tcms-scripts'} // [] }, $vars->{user} ? @{ $route->{'x-tcms-authenticated_scripts'} // [] } : () );
     $vars->{scripts} = $bundle ? [ $bundle->{href} ] : [];
     Trog::Bundle::save( $vars->{tpsgi}, $bundle ) if $bundle;
 

+ 18 - 0
t/TCMS.t

@@ -107,6 +107,24 @@ subtest 'the routes of posts' => sub {
     ok( !exists $paths->{'/posts/{curly}'}, 'but a post whose path has a brace is not, since it would be a template' );
 };
 
+subtest 'the scripts of a route' => sub {
+    my $post = $paths->{'/posts/p-one'}{get};
+    is_deeply( $post->{'x-tcms-scripts'},                              [qw{highlight.min.js reveal.min.js}], 'a post page has the scripts of one' );
+    is_deeply( $post->{'x-tcms-authenticated_scripts'},                [qw{post.js post_relations.js}],      'and the editing scripts for a reader who is logged in' );
+    is_deeply( $paths->{'/secure/posts/p-one'}{get}{'x-tcms-scripts'}, $post->{'x-tcms-scripts'},            'and so does its /secure copy' );
+
+    is_deeply( $paths->{'/metrics'}{get}{'x-tcms-scripts'}, ['chart.min.js'], 'a route that names its own has those' );
+    ok( !exists $paths->{'/metrics'}{get}{'x-tcms-authenticated_scripts'}, 'and no others' );
+    ok( !exists $paths->{'/login'}{get}{'x-tcms-scripts'},                 'a route that names none has none' );
+
+    no warnings qw{once};
+    my %declared = %Trog::Bundle::declared;
+    ok( $declared{'highlight.min+reveal.min'},                     'the bundle of a post page is declared at startup' );
+    ok( $declared{'highlight.min+reveal.min+post+post_relations'}, 'and the one for a reader who is logged in' );
+    ok( $declared{'chart.min'},                                    'and the bundle of every other route' );
+    ok( !$declared{'reveal.min+highlight.min'},                    'but no other list' );
+};
+
 subtest 'validation' => sub {
     my $token = '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0';
     is_deeply( [ $schema->validate_request( [ get => '/api/auth_change_request/{token}' ], { path => { token => $token } } ) ], [], 'a token that is a UUID is taken' );

+ 12 - 9
t/Trog-Bundle.t

@@ -50,11 +50,9 @@ subtest 'the scripts, in the order named' => sub {
 subtest 'a name that is not a script' => sub {
     my @not = ( qw{nothere.js ../one.js .hidden.js sub/one.js one.css}, 'one.js+two' );
     foreach my $name (@not) {
-        like( exception { Trog::Bundle::scripts( 'one.js', $name ) }, qr/no[ ]script[ ]to[ ]bundle[ ]named[ ]\Q$name\E/, "$name is fatal on a page, and named" );
-        is( Trog::Bundle::served( 'one.js', $name ), undef, '...and not served' );
+        like( exception { Trog::Bundle::scripts( 'one.js', $name ) }, qr/no[ ]script[ ]to[ ]bundle[ ]named[ ]\Q$name\E/, "$name is fatal, and named" );
     }
     is( Trog::Bundle::scripts(), undef, 'an empty list is no bundle' );
-    is( Trog::Bundle::served(),  undef, '...on a page or in a URL' );
 };
 
 subtest "the theme's copy" => sub {
@@ -78,13 +76,18 @@ subtest 'a script that changes' => sub {
     isnt( $after->{href}, $before->{href}, 'at a new href' );
 };
 
-subtest 'a list that only a URL names' => sub {
-    my $served = Trog::Bundle::served(qw{two.js one.js});
-    is( $served->{body}, Trog::Bundle::scripts(qw{two.js one.js})->{body}, 'is the bundle that a page would link' );
-    isnt( Trog::Bundle::served(qw{one.js two.js}), Trog::Bundle::served(qw{one.js two.js}), 'but is built for each request, and not kept' );
+subtest 'a list that a route did not declare' => sub {
+    is( Trog::Bundle::served(qw{one.js themed.js}), undef, 'is not served' );
+    is( Trog::Bundle::served(),                     undef, 'nor is an empty one' );
 
-    my $linked = Trog::Bundle::scripts(qw{two.js one.js});
-    is( Trog::Bundle::served(qw{two.js one.js}), $linked, 'while a list that a page links is the one built for the page' );
+    Trog::Bundle::declare( [qw{one.js themed.js}], [] );
+    is( Trog::Bundle::served(qw{one.js themed.js}), Trog::Bundle::scripts(qw{one.js themed.js}), 'until a route declares it' );
+
+    my $linked = Trog::Bundle::scripts(qw{vendor.min.js});
+    is( Trog::Bundle::served(qw{vendor.min.js}), $linked, 'or a page links it' );
+
+    Trog::Bundle::declare( [qw{one.js nothere.js}] );
+    like( exception { Trog::Bundle::served(qw{one.js nothere.js}) }, qr/nothere[.]js/, 'and a declared list with a missing script is fatal' );
 };
 
 done_testing();

+ 19 - 12
t/Trog-Routes-HTML-series-lifecycle.t

@@ -1238,7 +1238,11 @@ subtest 'presentation' => sub {
     like( $body, qr{\Q$deck\E}, 'deck link rendered' );
     unlike( $body, qr/Slide[ ]one[ ]body\./, 'slides themselves are not rendered inline' );
 
-    my $deck_query = sub (@who) { return ( route => "/posts/$child->{id}", id => $child->{id}, embed => 1, nochrome => 1, @who ) };
+    # The operation that tPSGI hands the callback, which carries the scripts
+    # that TCMS gives the route of a post.  t/TCMS.t makes sure that it does.
+    no warnings qw{once};
+    my %operation  = map { ( "x-tcms-$_" => $Trog::Routes::HTML::post_page_scripts{$_} ) } keys %Trog::Routes::HTML::post_page_scripts;
+    my $deck_query = sub (@who) { return ( route => "/posts/$child->{id}", id => $child->{id}, embed => 1, nochrome => 1, dispatcher => \%operation, @who ) };
     my ( $code, $page, $err ) = render( anon( $deck_query->() ), \&Trog::Routes::HTML::posts );
     is( $code, 200, 'the deck renders' ) or return diag($err);
     like( $page, qr/Reveal[.]initialize/, 'and starts reveal' );
@@ -1261,7 +1265,6 @@ subtest 'presentation' => sub {
     is( $bcode, 404, 'and a bundle of nothing is not found' );
 
     # As a fresh worker, which has saved nothing yet.
-    no warnings qw{once};
     local %Trog::Bundle::saved = ();
     my $tpsgi = Test::TPSGI->new( run_callbacks => 1 );
     render( anon( $deck_query->( tpsgi => $tpsgi ) ), \&Trog::Routes::HTML::posts );
@@ -1271,12 +1274,9 @@ subtest 'presentation' => sub {
     render( anon( $deck_query->( tpsgi => $tpsgi ) ), \&Trog::Routes::HTML::posts );
     ok( !exists $tpsgi->{renders}{"$srcs[0]:js"}, 'once' );
 
-    my $unlinked = 'reveal.min+highlight.min';
-    my ($unlinked_href) = $srcs[0] =~ s{/[^/]+/[^/]+\z}{}r;
-    ( $bcode, undef, undef, $headers ) = render( anon( route => "$unlinked_href/0000/$unlinked.js", version => '0000', names => $unlinked, tpsgi => $tpsgi ), \&Trog::Routes::HTML::bundle );
-    my %to = @$headers;
-    render( anon( route => $to{Location}, version => 'x', names => $unlinked, tpsgi => $tpsgi ), \&Trog::Routes::HTML::bundle );
-    ok( !( grep { m{/\Q$unlinked\E[.]js:js\z} } keys %{ $tpsgi->{renders} } ), 'while a list that only a URL names is not' );
+    my $undeclared = 'reveal.min+highlight.min';
+    ($bcode) = render( anon( route => "/bundle/0000/$undeclared.js", version => '0000', names => $undeclared, tpsgi => $tpsgi ), \&Trog::Routes::HTML::bundle );
+    is( $bcode, 404, 'a list of real scripts that no route declares is not found' );
 
     my ($watch) = grep { $_->{path} eq 'www/scripts' } @{ $tpsgi->{watches} };
     ok( $watch, 'the scripts are watched' ) or return;
@@ -1285,13 +1285,20 @@ subtest 'presentation' => sub {
 
     ( $code, $page, $err ) = render( query( user => 'spec_reader', user_acls => [], $deck_query->() ), \&Trog::Routes::HTML::posts );
     is( $code, 200, 'the deck renders for a reader who is logged in' ) or return diag($err);
-    like( $page, qr{src="/bundle/\w+/post[+]post_relations[+]}, 'with the editing scripts, though the reader is not an admin' );
+    my $viewing = qr{highlight[.]min[+]reveal[.]min};
+    my $editing = qr{post[+]post_relations};
+    like( $page, qr{src="/bundle/\w+/$viewing[+]$editing[.]js"}, 'with the authenticated scripts after the others, though the reader is not an admin' );
 
     ( $code, $page, $err ) = render( admin( $deck_query->() ), \&Trog::Routes::HTML::posts );
     is( $code, 200, 'the deck renders for an editor' ) or return diag($err);
-    my $editing = qr{post[+]post_relations};
-    my $viewing = qr{highlight[.]min[+]reveal[.]min};
-    like( $page, qr{<script\b[^>]*\bsrc="/bundle/\w+/$editing[+]$viewing[.]js"}, 'with the editing scripts first' );
+    my @editor_srcs = $page =~ m{<script\b[^>]*\bsrc="([^"]+)"}g;
+    my ($editor_names) = ( $editor_srcs[0] // '' ) =~ m{/([^/]+)[.]js\z};
+    ($bcode) = render( anon( route => $editor_srcs[0], version => 'x', names => $editor_names ), \&Trog::Routes::HTML::bundle );
+    is( $bcode, 200, 'and its bundle is served too' );
+
+    ( $code, $page, $err ) = render( anon( $deck_query->( query => 'scripts', scripts => 'nothere.js' ) ), \&Trog::Routes::HTML::posts );
+    is( $code, 200, 'a request that names scripts of its own still renders' ) or return diag($err);
+    like( $page, qr{src="/bundle/\w+/$viewing[.]js"}, 'with the scripts of the route' );
 };
 
 subtest 'invoice' => sub {