Explorar o código

Merge pull request #379 from Troglodyne-Internet-Widgets/bundle-route-js

Serve the scripts of a page as one versioned bundle
George S. Baugh hai 3 días
pai
achega
4b5f97c642

+ 2 - 2
.gitignore

@@ -11,8 +11,8 @@ pm_to_blib
 pod2htmd.tmp
 www/scripts/list.min.json
 www/scripts/highlight.min.js
-www/scripts/chart.js
-www/scripts/reveal.js
+www/scripts/chart.min.js
+www/scripts/reveal.min.js
 www/styles/obsidian.min.css
 www/styles/reveal.css
 www/styles/reveal-white.css

+ 2 - 0
Installer.mk

@@ -22,6 +22,8 @@ prereq-frontend:
 		"https://raw.githubusercontent.com/highlightjs/cdn-release/main/build/highlight.min.js" \
 		"https://cdn.jsdelivr.net/npm/chart.js" \
 		"https://raw.githubusercontent.com/hakimel/reveal.js/master/dist/reveal.js"; popd
+	mv www/scripts/chart.js www/scripts/chart.min.js
+	mv www/scripts/reveal.js www/scripts/reveal.min.js
 	mkdir -p www/styles; pushd www/styles && curl -L --remote-name-all \
 		"https://raw.githubusercontent.com/highlightjs/cdn-release/main/build/styles/obsidian.min.css" \
 	    "https://raw.githubusercontent.com/hakimel/reveal.js/master/dist/reveal.css" \

+ 1 - 0
Makefile.PL

@@ -133,6 +133,7 @@ WriteMakefile(
         'WWW::Sitemap::XML'                   => '0',
         'WWW::SitemapIndex::XML'              => '0',
         'CSS::Minifier::XS'                   => '0',
+        'JavaScript::Minifier::XS'            => '0',
         'Path::Tiny'                          => '0',
         'Log::Dispatch'                       => '0',
         'Log::Dispatch::FileRotate'           => '0',

+ 14 - 0
design.md

@@ -378,6 +378,20 @@ Stylesheets are the exception and are loaded both, theme last, so a theme can
 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
+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
+script directories. When a script changes, it throws away the saved pages and
+bundles.
+
 Composition: relations and enrichment
 =====================================
 

+ 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.

+ 163 - 0
lib/Trog/Bundle.pm

@@ -0,0 +1,163 @@
+package Trog::Bundle;
+
+use 5.040;
+use re '/aasx';
+use warnings FATAL => 'all';
+
+use Digest::SHA              ();
+use File::Slurper            ();
+use JavaScript::Minifier::XS ();
+use List::Util               ();
+
+use Trog::Themes ();
+
+=head1 Trog::Bundle
+
+The scripts that a page names, served as one response.
+
+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, as its href carries them, to the bundle.
+my %bundles;
+
+# The names of each list that a route declared, as its href carries them.
+our %declared;
+
+# The names as an href carries them.
+my sub key_of (@names) {
+    return join( '+', map { s/[.]js\z//r } @names );
+}
+
+=head2 declare(@lists)
+
+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 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.  The list
+is declared, as declare() does, since only a route can have named it.
+
+The hash has these keys:
+
+    href     where the bundle is served, which changes when its body does
+    version  a digest of the body, which is in the href
+    body     the scripts, each one minified unless its name ends in .min.js
+
+A process builds each bundle once, and builds it again when a file in it
+changes.
+
+=cut
+
+sub scripts (@names) {
+    @names = List::Util::uniq(@names);
+    return undef unless @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       = 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;
+
+    # 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() returns it, or undef
+if a route did not declare the list.
+
+=cut
+
+sub served (@names) {
+    @names = List::Util::uniq(@names);
+    return undef unless @names && $declared{ key_of(@names) };
+    return scripts(@names);
+}
+
+=head2 save($tpsgi, $bundle)
+
+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.
+
+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
+link the new bundle, and a page that still links an old one is redirected by
+the route.  The route also serves a bundle whose static is gone, so a worker
+that still counts it as saved is slower, not wrong.
+
+=cut
+
+# The hrefs of the bundles that this process saved.
+our %saved;
+
+sub save ( $tpsgi, $bundle ) {
+    return 0 if !$tpsgi || $saved{ $bundle->{href} };
+
+    if ( $tpsgi->can('add_watch') ) {
+        my $theme = Trog::Themes::get_dir();
+        foreach my $dir ( grep { -d } ( 'www/scripts', $theme ? "$theme/scripts" : () ) ) {
+            $tpsgi->add_watch(
+                $dir,
+                sub {
+                    my ( $watcher, $change ) = @_;
+                    $watcher->invalidate_renders($_) for qw{html js};
+                    return 1;
+                },
+                key => "Trog::Bundle:$dir",
+            );
+        }
+    }
+
+    $tpsgi->add_post_close_callback(
+        sub {
+            $tpsgi->save_render( $bundle->{href}, 'js', $bundle->{body} );
+            $saved{ $bundle->{href} } = 1;
+        }
+    );
+    return 1;
+}

+ 54 - 9
lib/Trog/Routes/HTML.pm

@@ -33,6 +33,7 @@ use Trog::Config;
 use Trog::Auth;
 use Trog::Data;
 use Trog::Themes;
+use Trog::Bundle;
 use Trog::Renderer;
 use Trog::Email;
 use Trog::DataSource;
@@ -166,6 +167,7 @@ our %routes = (
         method   => 'GET',
         auth     => 1,
         callback => \&Trog::Routes::HTML::metrics,
+        scripts  => ['chart.min.js'],
     },
     '/admin/sessions' => {
         method   => 'GET',
@@ -216,6 +218,7 @@ our %routes = (
         callback => \&Trog::Routes::HTML::post_wizard,
         noindex  => 1,
         nocache  => 1,
+        scripts  => ['post_wizard.js'],
     },
     '/admin/wyzzerdd/save' => {
         method   => 'POST',
@@ -295,6 +298,18 @@ our %routes = (
         method   => 'GET',
         callback => \&Trog::Routes::HTML::rss_style,
     },
+    '/bundle/{version}/{names}.js' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::bundle,
+    },
+);
+
+# 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
@@ -517,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);
@@ -572,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},
@@ -838,7 +852,6 @@ sub config ( $query = {} ) {
             title           => 'Configure tCMS',
             theme_dir       => Trog::Themes::td(),
             stylesheets     => [qw{config.css}],
-            scripts         => [qw{post.js}],
             themes          => get_themes() || [],
             config_sections => config_sections(),
             message         => $query->{message},
@@ -1043,7 +1056,6 @@ sub resetpass ($query) {
             title       => 'Request Authentication Resets',
             theme_dir   => Trog::Themes::td(),
             stylesheets => [qw{config.css}],
-            scripts     => [qw{post.js}],
             message     => $query->{message},
             failure     => $query->{failure},
             scheme      => $query->{scheme},
@@ -2350,7 +2362,6 @@ sub metrics ($query) {
         },
         undef,
         ['post.css'],
-        ['chart.js'],
     );
 }
 
@@ -3379,6 +3390,37 @@ sub icon ($query) {
     return $query->{tpsgi}->serve( $path, $tpath, $query->{start}, $query->{streaming}, $query->{ranges}, $query->{last_fetched}, $query->{deflate} );
 }
 
+=head2 bundle
+
+Implements /bundle/{version}/{names}.js, the scripts of a page as one response.
+See Trog::Bundle.
+
+The body of a bundle is the same at a given href, so the browser keeps it.  An
+href with an old version is redirected to the current one, so a page that was
+cached before a script changed still gets its scripts.
+
+It serves only a list that a route declared, and is a 404 for any other list,
+even one of real scripts.  TCMS::build_routes() declares the C<scripts> and
+C<authenticated_scripts> of every route at startup, and finish_render() declares
+the list of each page that it links.  Both take the lists from the routing
+table, so a URL cannot name a list for this route to build.
+
+=cut
+
+sub bundle ($query) {
+    my $bundle = Trog::Bundle::served( map { "$_.js" } split( /[+]/, $query->{names} ) );
+    return $query->{tpsgi}->notfound($query) unless $bundle;
+    return $query->{tpsgi}->redirect( $bundle->{href} ) if $query->{route} ne $bundle->{href};
+    return [
+        200,
+        [
+            'Content-type'  => 'text/javascript',
+            'Cache-Control' => $Trog::Vars::cache_control{static},
+        ],
+        [ $bundle->{body} ],
+    ];
+}
+
 =head2 totp_qr
 
 Implements the /totp/* routes.
@@ -3454,20 +3496,23 @@ 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} ) } ];
-    $vars->{scripts}      = [ map { Trog::Themes::themed_script("$_") =~ s/^www\///r } @{ 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;
 
     # Add in avatars.css, it's special
     push( @{ $vars->{stylesheets} }, "/styles/avatars.css" );
 
-    # Absolute-ize the paths for scripts & stylesheets
+    # Absolute-ize the paths for stylesheets
     @{ $vars->{stylesheets} }  = map { CORE::index( $_, '/' ) == 0 ? $_ : "/$_" } @{ $vars->{stylesheets} };
     @{ $vars->{print_styles} } = map { CORE::index( $_, '/' ) == 0 ? $_ : "/$_" } @{ $vars->{print_styles} };
-    @{ $vars->{scripts} }      = map { CORE::index( $_, '/' ) == 0 ? $_ : "/$_" } @{ $vars->{scripts} };
 
     # TODO Smash together the stylesheets and minify
 

+ 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' );

+ 93 - 0
t/Trog-Bundle.t

@@ -0,0 +1,93 @@
+#!/usr/bin/env perl
+use 5.040;
+use re '/aasx';
+use warnings FATAL => 'all';
+
+use Test::More;
+use Test::MockModule qw{strict};
+use Test::Fatal      qw{exception};
+use Cwd();
+use File::Path();
+use File::Temp();
+use Path::Tiny();
+
+use FindBin::libs;
+
+# Every path in tCMS is relative to the instance root, so this stands in one.
+my $root   = File::Temp::tempdir( 'tcms-bundle-XXXXXX', TMPDIR => 1, CLEANUP => 0 );
+my $oldcwd = Cwd::getcwd();
+chdir($root) or die "could not chdir to the sandbox: $!";
+
+END {
+    chdir($oldcwd)                 if $oldcwd;
+    File::Path::remove_tree($root) if $root && -d $root;
+}
+
+require_ok('Trog::Bundle') or BAIL_OUT("Can't find SUT");
+
+my $stock = Path::Tiny->new('www/scripts');
+my $theme = Path::Tiny->new('www/themes/spec/scripts');
+$_->mkpath foreach ( $stock, $theme );
+
+$stock->child('one.js')->spew_raw("function one () {\n    return 1;\n}\n");
+$stock->child('two.js')->spew_raw("// two\nvar two = 2;\n");
+$stock->child('vendor.min.js')->spew_raw("var   vendor = 'kept as it is'; // a trailing comment");
+$stock->child('themed.js')->spew_raw("var themed = 'stock';\n");
+$theme->child('themed.js')->spew_raw("var themed = 'theme';\n");
+
+my $themedir = '';
+my $mock     = Test::MockModule->new('Trog::Themes');
+$mock->redefine( 'get_dir', sub { $themedir } );
+
+subtest 'the scripts, in the order named' => sub {
+    my $bundle = Trog::Bundle::scripts(qw{two.js vendor.min.js one.js two.js});
+
+    is( $bundle->{body}, "var two=2;\n;\nvar   vendor = 'kept as it is'; // a trailing comment\n;\nfunction one(){return 1;}", 'minified, except the one that already is, and a name given twice is used once' );
+    like( $bundle->{version}, qr/\A[[:xdigit:]]{16}\z/, 'the version is a digest' );
+    is( $bundle->{href}, "/bundle/$bundle->{version}/two+vendor.min+one.js", 'and the href carries it, and the names' );
+};
+
+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, and named" );
+    }
+    is( Trog::Bundle::scripts(), undef, 'an empty list is no bundle' );
+};
+
+subtest "the theme's copy" => sub {
+    is( Trog::Bundle::scripts(qw{themed.js})->{body}, "var themed='stock';", 'is not used without the theme' );
+
+    $themedir = 'www/themes/spec';
+    is( Trog::Bundle::scripts(qw{themed.js})->{body}, "var themed='theme';",       'and is used in place of the stock one with it' );
+    is( Trog::Bundle::scripts(qw{one.js})->{body},    'function one(){return 1;}', 'while a script the theme lacks is the stock one' );
+    $themedir = '';
+};
+
+subtest 'a script that changes' => sub {
+    my $before = Trog::Bundle::scripts(qw{one.js});
+    is( Trog::Bundle::scripts(qw{one.js}), $before, 'the bundle is built once' );
+
+    $stock->child('one.js')->spew_raw("function one () {\n    return 'one';\n}\n");
+    utime( time + 10, time + 10, $stock->child('one.js') ) or die "could not touch one.js: $!";
+
+    my $after = Trog::Bundle::scripts(qw{one.js});
+    is( $after->{body}, "function one(){return'one';}", 'and built again when a file in it changes' );
+    isnt( $after->{href}, $before->{href}, 'at a new href' );
+};
+
+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' );
+
+    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();

+ 77 - 3
t/Trog-Routes-HTML-series-lifecycle.t

@@ -134,6 +134,12 @@ BEGIN {
     print {$fh} '{"emojis":[{"category":"Smileys","emoji":"X"}]}';
     close($fh) or die $!;
 
+    # The vendored scripts are downloaded at install, so they are not in the
+    # checkout; these stand in for them.
+    File::Slurper::Temp::write_text( "$ROOT/www/scripts/reveal.min.js",    "var Reveal = 'spec reveal';" );
+    File::Slurper::Temp::write_text( "$ROOT/www/scripts/highlight.min.js", "var hljs = 'spec highlight';" );
+    File::Copy::copy( "$REPO/www/scripts/$_", "$ROOT/www/scripts/$_" ) or die $! for qw{post.js post_relations.js post_wizard.js};
+
     # Text::Xslate computes its default cache dir from $HOME once, at load time.
     # Without this the sandbox writes compiled templates into the real ~/.xslate_cache.
     $ENV{HOME} = $ROOT;
@@ -302,6 +308,12 @@ my sub render {
     return ( $code, Encode::decode_utf8($body), '', $headers );
 }
 
+# The pages that a stand-in tPSGI was asked to save, without the bundles of
+# scripts, which every page links.
+my sub pages_saved ($tpsgi) {
+    return [ grep { !m/:js\z/ } keys %{ $tpsgi->{renders} } ];
+}
+
 # FlatFile snapshots the tag list at load time and then filters each query's
 # tags against it IN PLACE -- so a tag created later isn't merely unindexed, it
 # is deleted from the query, and the filter silently degrades to "every public
@@ -684,7 +696,7 @@ subtest 'a datasource-backed series searches and paginates' => sub {
     # not going to tell us about it.
     my $vtpsgi = Test::TPSGI->new( run_callbacks => 1 );
     render( anon( route => '/specguests', tpsgi => $vtpsgi ), \&Trog::Routes::HTML::series );
-    is_deeply( [ keys %{ $vtpsgi->{renders} } ], [], 'a guests page is never saved as a static' );
+    is_deeply( pages_saved($vtpsgi), [], 'a guests page is never saved as a static' );
 
     # The cursor paginator would be worse than useless here: every guest shares
     # a created, so Prev would ask for everything older than that one instant.
@@ -811,7 +823,7 @@ subtest 'a directory index series' => sub {
     # the renderer hands anonymous, unparameterised, successful renders to tPSGI
     # to save, and a directory listing is one.  Which it may be, unlike the
     # guests page above, precisely because it registers the watch below.
-    ok( scalar( keys %{ $tpsgi->{renders} } ),                    'the listing was handed to tPSGI to save as a static' );
+    ok( scalar( @{ pages_saved($tpsgi) } ),                       'the listing was handed to tPSGI to save as a static' );
     ok( ( grep { m{^/specfiles:} } keys %{ $tpsgi->{renders} } ), 'under its own route' );
 
     my ($watch) = grep { $_->{path} =~ m/spec_downloads/ } @{ $tpsgi->{watches} };
@@ -1225,6 +1237,68 @@ subtest 'presentation' => sub {
     my $deck = qq{<a href="/posts/$child->{id}?embed=1&nochrome=1">Click Here to view</a>};
     like( $body, qr{\Q$deck\E}, 'deck link rendered' );
     unlike( $body, qr/Slide[ ]one[ ]body\./, 'slides themselves are not rendered inline' );
+
+    # 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' );
+
+    my @srcs = $page =~ m{<script\b[^>]*\bsrc="([^"]+)"}g;
+    is( scalar(@srcs), 1, 'from one script' ) or return diag( explain( \@srcs ) );
+    my ( $version, $names ) = $srcs[0] =~ m{\A/bundle/(\w+)/([^/]+)[.]js\z} or return fail("$srcs[0] is a bundle");
+    is( $names, 'highlight.min+reveal.min', 'which bundles what the deck needs, and no editing scripts for a visitor' );
+
+    my ( $bcode, $bundle, $berr, $headers ) = render( anon( route => $srcs[0], version => $version, names => $names ), \&Trog::Routes::HTML::bundle );
+    is( $bcode, 200, 'the bundle is served' ) or return diag($berr);
+    like( $bundle, qr/spec[ ]highlight.*spec[ ]reveal/s, 'with both scripts in it, in order' );
+    my %h = @$headers;
+    is( $h{'Content-type'}, 'text/javascript', 'as javascript' );
+    like( $h{'Cache-Control'}, qr/immutable/, 'that the browser keeps, since the href changes with the body' );
+
+    ($bcode) = render( anon( route => "/bundle/0000/$names.js", version => '0000', names => $names ), \&Trog::Routes::HTML::bundle );
+    is( $bcode, 302, 'an old version is sent to the current one' );
+    ($bcode) = render( anon( route => '/bundle/0000/nothere.js', version => '0000', names => 'nothere' ), \&Trog::Routes::HTML::bundle );
+    is( $bcode, 404, 'and a bundle of nothing is not found' );
+
+    # As a fresh worker, which has saved nothing yet.
+    local %Trog::Bundle::saved = ();
+    my $tpsgi = Test::TPSGI->new( run_callbacks => 1 );
+    render( anon( $deck_query->( tpsgi => $tpsgi ) ), \&Trog::Routes::HTML::posts );
+    is( $tpsgi->{renders}{"$srcs[0]:js"}, $bundle, 'the bundle that a page links is saved as a static' );
+
+    delete $tpsgi->{renders}{"$srcs[0]:js"};
+    render( anon( $deck_query->( tpsgi => $tpsgi ) ), \&Trog::Routes::HTML::posts );
+    ok( !exists $tpsgi->{renders}{"$srcs[0]:js"}, 'once' );
+
+    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;
+    $watch->{callback}->( $tpsgi, {} );
+    is_deeply( [ @{ $tpsgi->{invalidated} }[ -2, -1 ] ], [qw{html js}], 'and a change throws away the saved pages and bundles' );
+
+    ( $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);
+    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 @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 {
@@ -1569,7 +1643,7 @@ subtest 'saves report back through the jsalert banner' => sub {
     # form was accidentally safe here; a cookie is not.
     my $tpsgi = Test::TPSGI->new( run_callbacks => 1 );
     render( anon( route => '/specblog', cookies => $feedback, tpsgi => $tpsgi ), \&Trog::Routes::HTML::series );
-    is_deeply( [ keys %{ $tpsgi->{renders} } ], [], 'and it is never saved as a static' );
+    is_deeply( pages_saved($tpsgi), [], 'and it is never saved as a static' );
 };
 
 subtest 'a hidden id that was never filled in' => sub {

+ 0 - 1
www/templates/html/components/forms/presentation.tx

@@ -30,7 +30,6 @@
       			<: } :>
       		</div>
     	</div>
-    	<script src="/scripts/reveal.js"></script>
     	<script>
       		Reveal.initialize({
                 hash:true,

+ 0 - 1
www/templates/html/components/post_wizard.tx

@@ -1,4 +1,3 @@
-<script type="text/javascript" src="/scripts/post_wizard.js"></script>
 <script type="application/json" id="wizard-types"><: $types | mark_raw :></script>
 <div id="backoffice">
 <p class="title">

+ 0 - 4
www/templates/html/components/posts.tx

@@ -1,6 +1,4 @@
 : if ( $can_edit ) {
-    <script type="text/javascript" src="/scripts/post.js"></script>
-    <script type="text/javascript" src="/scripts/post_relations.js"></script>
     <div id="emoji_picker_modal" class="modal" style="display:none;">
     <: component('EmojiPicker') :>
     </div>
@@ -36,8 +34,6 @@
     : }
     </div>
 : }
-<script type="text/javascript" src="/scripts/highlight.min.js"></script>
-<script type="text/javascript" src="/scripts/reveal.js"></script>
 <script>
 document.addEventListener("DOMContentLoaded", function(){
     hljs.highlightAll();