Prechádzať zdrojové kódy

Make the page furniture components rather than route-side pre-renders

Fifteen more places had the shape EmojiPicker used to: render a template in a
route, then pass the string into a later render as a variable.  index() did it
for seven bars, posts() for the header and footer a series names, rss_style()
and finish_render() for the page header and footer.

Each carried a 'return $x if ref $x eq ARRAY' check to catch the PSGI triplet
Trog::Renderer answers failure with -- or, in rss_style() and finish_render(),
carried no check at all and would have stuffed an arrayref into a template var.
One of the checks was wrong: posts() read

    return $header if ref $footer eq 'ARRAY';

so a failed footer returned the header's triplet, or a bare undef when the post
had no header.  All of it goes away; component() already refuses a ref.

The bars also relied on 'my $topbar = ... template => $topbar ...' resolving the
package var, since my() doesn't bring the lexical into scope until the RHS is
evaluated.  It worked, but reordering any of those lines would have silently
rendered undef.  Each component now owns the name of its own template, so the
eight package vars go too.  Nothing outside index() ever read them, and the
comment claiming themes needed this dance is stale -- theme override by search
path landed in 3ea81c4, the same commit that introduced component().

Add Trog::Component::render_template() so the twelve modules aren't twelve
copies of the same Trog::Renderer call, and require Trog::Renderer inside it
rather than at the top of the file, for the same cycle reason components are
loaded lazily.

PostHeader/PostFooter take their filename from post data, which previously went
straight into a path with nothing checking it.  They now validate against
themed_templates_in_dir(), the same list the series edit form is built from, so
anything the form can pick renders and nothing else does.

Two behaviour notes.  The RSS stylesheet's header now says lang="en-US" rather
than lang="", because Header applies the default every other page already got
from finish_render.  And a series whose header template is empty now emits the
surrounding whitespace, since the template asks whether a header is *named*
rather than whether it *rendered to something*.  index.tx, embed.tx and
posts.tx are otherwise byte-identical to what they produced before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George Baugh 1 mesiac pred
rodič
commit
313444850b

+ 4 - 0
Readme.md

@@ -112,6 +112,10 @@ The optional hash is flattened into the component's render(); nullary components
 ignore it.  Output comes back marked raw, so no | mark_raw is needed.  Routes
 don't have to know a component exists -- the template asks for what it wants.
 
+The page furniture is built this way: Header, Footer, HtmlTitle, MidTitle,
+TopBar, LeftBar, RightBar, FootBar and CategoryBar are what index.tx is made of,
+and PostHeader/PostFooter are the per-series header and footer posts.tx pulls in.
+
 Ideas to come:
 =============
 

+ 28 - 0
lib/Trog/Component.pm

@@ -103,6 +103,34 @@ sub _component ( $name, $args ) {
     return Text::Xslate::mark_raw($out);
 }
 
+=head2 render_template($template, $data) = STRING
+
+Render a component template and hand back the body, for the many components
+whose whole job is exactly that.
+
+Dies naming the template if the render fails, rather than answering with the
+PSGI triplet Trog::Renderer::render() produces on failure.
+
+=cut
+
+sub render_template ( $template, $data = {} ) {
+
+    # Required rather than used, for the same reason components are loaded
+    # lazily: Trog::Renderer::Base uses this module, so a compile-time use here
+    # would close the cycle.  By the time anyone calls this it is already loaded.
+    require Trog::Renderer;
+
+    my $out = Trog::Renderer->render(
+        contenttype => 'text/html',
+        component   => 1,
+        template    => $template,
+        data        => $data,
+    );
+
+    die "Could not render component template '$template'\n" if ref $out;
+    return $out;
+}
+
 # Resolved at call time rather than compile time, so that components are free to
 # use Trog::Renderer without cycling back through us.  require() memoizes in
 # %INC; %loaded only saves us the repeat eval and string munging.

+ 28 - 0
lib/Trog/Component/CategoryBar.pm

@@ -0,0 +1,28 @@
+package Trog::Component::CategoryBar;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::CategoryBar
+
+The list of series links in the top bar.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render categories.tx.
+
+Wants C<categories>, the series list the page was built with, and C<user>, which
+decides whether the links point at /secure.
+
+=cut
+
+sub render (%args) {
+    $args{categories} //= [];
+    return Trog::Component::render_template( 'categories.tx', \%args );
+}
+
+1;

+ 23 - 0
lib/Trog/Component/FootBar.pm

@@ -0,0 +1,23 @@
+package Trog::Component::FootBar;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::FootBar
+
+The footer bar, below the content.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render footbar.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'footbar.tx', \%args ) }
+
+1;

+ 23 - 0
lib/Trog/Component/Footer.pm

@@ -0,0 +1,23 @@
+package Trog::Component::Footer;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::Footer
+
+The tail of an HTML page -- the closing body and html tags.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render footer.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'footer.tx', \%args ) }
+
+1;

+ 54 - 0
lib/Trog/Component/Header.pm

@@ -0,0 +1,54 @@
+package Trog::Component::Header;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+use Trog::Themes;
+
+=head1 Trog::Component::Header
+
+Everything from the doctype down to the opening body tag: the title, the social
+and icon metadata, and the stylesheet and script links.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render header.tx.
+
+Recognized arguments, all optional:
+
+    title / route / lang / author         the page itself
+    meta_desc / meta_tags / default_tags  social metadata, built by the route
+    stylesheets / print_styles / scripts  resolved paths, built by finish_render
+    embed                                 embedded view, which adds a base target
+    no_doctype                            leave the doctype off, for the RSS
+                                          stylesheet, which is XSL rather than HTML
+
+The list arguments default to empty rather than to the stock sheets, so that a
+caller which has none -- rss_style(), notably -- gets a header with none, as it
+did when the route rendered this itself.
+
+theme_dir is worked out here rather than passed, as it is the same for every
+page and the template only wants it to build asset paths.
+
+=cut
+
+sub render (%args) {
+    $args{lang}         //= 'en-US';
+    $args{title}        //= 'tCMS';
+    $args{stylesheets}  //= [];
+    $args{print_styles} //= [];
+    $args{scripts}      //= [];
+
+    # td() answers a rooted URL path, which for a themed site starts /www/ --
+    # the template builds hrefs with it, and /www is not in the URL space.
+    my $theme_dir = Trog::Themes::td();
+    $theme_dir =~ s|^/www/||;
+    $args{theme_dir} //= $theme_dir;
+
+    return Trog::Component::render_template( 'header.tx', \%args );
+}
+
+1;

+ 23 - 0
lib/Trog/Component/HtmlTitle.pm

@@ -0,0 +1,23 @@
+package Trog::Component::HtmlTitle;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::HtmlTitle
+
+The site title, shown at the left of the top bar.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render title.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'title.tx', \%args ) }
+
+1;

+ 23 - 0
lib/Trog/Component/LeftBar.pm

@@ -0,0 +1,23 @@
+package Trog::Component::LeftBar;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::LeftBar
+
+The left sidebar.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render leftbar.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'leftbar.tx', \%args ) }
+
+1;

+ 23 - 0
lib/Trog/Component/MidTitle.pm

@@ -0,0 +1,23 @@
+package Trog::Component::MidTitle;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::MidTitle
+
+The middle slot of the top bar.  Empty by default.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render midtitle.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'midtitle.tx', \%args ) }
+
+1;

+ 53 - 0
lib/Trog/Component/PostFooter.pm

@@ -0,0 +1,53 @@
+package Trog::Component::PostFooter;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+use Trog::Themes;
+
+=head1 Trog::Component::PostFooter
+
+The per-post footer a series can name in its metadata, drawn from the footers/
+directory of the stock templates or the theme's.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render footers/$args{name}.
+
+The name comes out of post data, so it is checked against the footers/ listing
+before it becomes a path -- see available().  Returns the empty string when no
+name is given, so that the caller can ask unconditionally.
+
+Beyond the name, the template gets the post it belongs to and the route it is
+being shown at, plus theme_dir for building asset paths.  That is narrower than
+the whole request hash these used to be rendered with; a theme whose footer
+wants something else should be handed it at the call site in posts.tx.
+
+=cut
+
+sub render (%args) {
+    my $name = delete $args{name};
+    return '' unless defined $name && length $name;
+
+    die "No such footer '$name'\n" unless grep { $_ eq $name } @{ available() };
+
+    $args{theme_dir} //= Trog::Themes::td();
+    return Trog::Component::render_template( "footers/$name", \%args );
+}
+
+=head2 available() = ARRAYREF
+
+The footers a post may name: every .tx in footers/, the theme's and the stock ones
+both.  This is the same list the series edit form offers, so anything the form
+can pick renders, and nothing else does.
+
+=cut
+
+sub available () {
+    return Trog::Themes::themed_templates_in_dir( 'footers', 'text/html', 1 );
+}
+
+1;

+ 53 - 0
lib/Trog/Component/PostHeader.pm

@@ -0,0 +1,53 @@
+package Trog::Component::PostHeader;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+use Trog::Themes;
+
+=head1 Trog::Component::PostHeader
+
+The per-post header a series can name in its metadata, drawn from the headers/
+directory of the stock templates or the theme's.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render headers/$args{name}.
+
+The name comes out of post data, so it is checked against the headers/ listing
+before it becomes a path -- see available().  Returns the empty string when no
+name is given, so that the caller can ask unconditionally.
+
+Beyond the name, the template gets the post it belongs to and the route it is
+being shown at, plus theme_dir for building asset paths.  That is narrower than
+the whole request hash these used to be rendered with; a theme whose header
+wants something else should be handed it at the call site in posts.tx.
+
+=cut
+
+sub render (%args) {
+    my $name = delete $args{name};
+    return '' unless defined $name && length $name;
+
+    die "No such header '$name'\n" unless grep { $_ eq $name } @{ available() };
+
+    $args{theme_dir} //= Trog::Themes::td();
+    return Trog::Component::render_template( "headers/$name", \%args );
+}
+
+=head2 available() = ARRAYREF
+
+The headers a post may name: every .tx in headers/, the theme's and the stock ones
+both.  This is the same list the series edit form offers, so anything the form
+can pick renders, and nothing else does.
+
+=cut
+
+sub available () {
+    return Trog::Themes::themed_templates_in_dir( 'headers', 'text/html', 1 );
+}
+
+1;

+ 23 - 0
lib/Trog/Component/RightBar.pm

@@ -0,0 +1,23 @@
+package Trog::Component::RightBar;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::RightBar
+
+The right sidebar.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render rightbar.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'rightbar.tx', \%args ) }
+
+1;

+ 23 - 0
lib/Trog/Component/TopBar.pm

@@ -0,0 +1,23 @@
+package Trog::Component::TopBar;
+
+use v5.36;
+use re '/aa';
+
+use Trog::Component ();
+
+=head1 Trog::Component::TopBar
+
+The top bar's theme slot, beside the search box.  Empty by default.
+
+=head1 FUNCTIONS
+
+=head2 render(%args) = STRING
+
+Render topbar.tx.  Stock reads no variables; anything passed is handed to the
+template, so a theme's override is free to want some.
+
+=cut
+
+sub render (%args) { return Trog::Component::render_template( 'topbar.tx', \%args ) }
+
+1;

+ 11 - 66
lib/Trog/Routes/HTML.pm

@@ -39,13 +39,6 @@ use Trog::Renderer;
 use Trog::Email;
 
 our $landing_page = 'default.tx';
-our $htmltitle    = 'title.tx';
-our $midtitle     = 'midtitle.tx';
-our $rightbar     = 'rightbar.tx';
-our $leftbar      = 'leftbar.tx';
-our $topbar       = 'topbar.tx';
-our $footbar      = 'footbar.tx';
-our $categorybar  = 'categories.tx';
 
 # Note to maintainers: never ever remove backends from this list.
 # the auth => 1 is a crucial protection.
@@ -371,22 +364,6 @@ sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
     my $tmpl = $query->{embed} ? 'embed.tx' : 'index.tx';
     $query->{theme_dir} =~ s/^\/www\///;
 
-    # TO support theming we have to do things like this rather than with an include directive in the templates.
-    my $htmltitle = Trog::Renderer->render( template => $htmltitle, data => $query, component => 1, contenttype => 'text/html' );
-    return $htmltitle if ref $htmltitle eq 'ARRAY';
-    my $midtitle = Trog::Renderer->render( template => $midtitle, data => $query, component => 1, contenttype => 'text/html' );
-    return $midtitle if ref $midtitle eq 'ARRAY';
-    my $rightbar = Trog::Renderer->render( template => $rightbar, data => $query, component => 1, contenttype => 'text/html' );
-    return $rightbar if ref $rightbar eq 'ARRAY';
-    my $leftbar = Trog::Renderer->render( template => $leftbar, data => $query, component => 1, contenttype => 'text/html' );
-    return $leftbar if ref $leftbar eq 'ARRAY';
-    my $topbar = Trog::Renderer->render( template => $topbar, data => $query, component => 1, contenttype => 'text/html' );
-    return $topbar if ref $topbar eq 'ARRAY';
-    my $footbar = Trog::Renderer->render( template => $footbar, data => $query, component => 1, contenttype => 'text/html' );
-    return $footbar if ref $footbar eq 'ARRAY';
-    my $categorybar = Trog::Renderer->render( template => $categorybar, data => { %$query, categories => \@series }, component => 1, contenttype => 'text/html' );
-    return $categorybar if ref $categorybar eq 'ARRAY';
-
     # Grab the avatar class for the logged in user
     if ( $query->{user} ) {
         $query->{user_class} = Trog::Auth::username2classname( $query->{user} );
@@ -404,13 +381,6 @@ sub index ( $query, $content = '', $i_styles = [], $i_scripts = [] ) {
             theme_dir    => Trog::Themes::td(),
             content      => $content,
             title        => $title,
-            htmltitle    => $htmltitle,
-            midtitle     => $midtitle,
-            rightbar     => $rightbar,
-            leftbar      => $leftbar,
-            topbar       => $topbar,
-            footbar      => $footbar,
-            categorybar  => $categorybar,
             categories   => \@series,
             stylesheets  => \@styles,
             print_styles => \@p_styles,
@@ -1443,23 +1413,6 @@ sub posts ( $query, $direct = 0 ) {
     #XXX Is used by the sitemap, maybe just fix there?
     my @post_aliases = map { $_->{local_href} } _get_series();
 
-    # Allow themes to put in custom headers/footers on posts
-    my ( $header, $footer );
-    $header = Trog::Renderer->render(
-        template    => 'headers/' . $query->{primary_post}{header},
-        data        => { theme_dir => Trog::Themes::td(), %$query },
-        component   => 1,
-        contenttype => 'text/html',
-    ) if $query->{primary_post}{header};
-    return $header if ref $header eq 'ARRAY';
-    $footer = Trog::Renderer->render(
-        template    => 'footers/' . $query->{primary_post}{footer},
-        data        => { theme_dir => Trog::Themes::td(), %$query },
-        component   => 1,
-        contenttype => 'text/html',
-    ) if $query->{primary_post}{footer};
-    return $header if ref $footer eq 'ARRAY';
-
     # List the available headers/footers
     my $headers = Trog::Themes::themed_templates_in_dir( "headers", 'text/html', 1 );
     my $footers = Trog::Themes::themed_templates_in_dir( "footers", 'text/html', 1 );
@@ -1477,12 +1430,6 @@ sub posts ( $query, $direct = 0 ) {
     my $now_year    = ( localtime(time) )[5] + 1900;
     my $oldest_year = $now_year - 20;                  #XXX actually find oldest post year
 
-    # Handle post style.
-    if ( $query->{style} ) {
-        undef $header;
-        undef $footer;
-    }
-
     my $older = !@posts ? 0 : $posts[-1]->{created};
     $query->{failure} //= -1;
     $query->{id}      //= '';
@@ -1584,8 +1531,6 @@ sub posts ( $query, $direct = 0 ) {
             tiled             => $tiled,
             category          => $ph,
             subhead           => $query->{subhead},
-            header            => $header,
-            footer            => $footer,
             headers           => $headers,
             footers           => $footers,
             years             => [ reverse( $oldest_year .. $now_year ) ],
@@ -2641,9 +2586,12 @@ sub totp_qr ($query) {
 
 Implements /styles/rss-style.xsl.
 
-The XSL stylesheet which makes the RSS feed legible in a browser.  The header
-and footer are rendered here and passed in as strings because the output is
-XSL rather than HTML, so an include directive isn't available.
+The XSL stylesheet which makes the RSS feed legible in a browser.
+
+rss-style.tx pulls the header and footer in with component(), which works here
+where an include directive would not: the output is XSL rather than HTML, so
+the two are rendered separately and composed, and the header is asked for
+without a doctype.
 
 =cut
 
@@ -2652,10 +2600,6 @@ sub rss_style ($query) {
     $query->{title}      = qq{<xsl:value-of select="rss/channel/title"/>};
     $query->{no_doctype} = 1;
 
-    # Due to this being html rather than XML, we can't use an include directive.
-    $query->{header} = Trog::Renderer->render( template => 'header.tx', data => $query, contenttype => 'text/html', component => 1 );
-    $query->{footer} = Trog::Renderer->render( template => 'footer.tx', data => $query, contenttype => 'text/html', component => 1 );
-
     return Trog::Renderer->render(
         template    => 'rss-style.tx',
         contenttype => 'text/xsl',
@@ -2680,8 +2624,11 @@ Render a full page, as opposed to a component.
 
 Fills in the defaults every page needs (lang, title, status code, content type,
 cache control), resolves stylesheet and script names through the theme, makes
-their paths absolute, renders the header and footer, and hands the lot to
-Trog::Renderer.
+their paths absolute, and hands the lot to Trog::Renderer.
+
+The page's own template asks for the header and footer with component(), so the
+resolved stylesheet and script lists are passed on to Trog::Component::Header
+rather than being rendered into a string here.
 
 Just about everything in this module ends up here; index() is the usual way in.
 
@@ -2716,8 +2663,6 @@ sub finish_render ( $template, $vars, %headers ) {
 
     $vars->{code} ||= 200;
     $vars->{theme_dir} =~ s/^\/www\/// if $vars->{theme_dir};
-    $vars->{header} = Trog::Renderer->render( template => 'header.tx', data => $vars, contenttype => 'text/html', component => 1 );
-    $vars->{footer} = Trog::Renderer->render( template => 'footer.tx', data => $vars, contenttype => 'text/html', component => 1 );
 
     return Trog::Renderer->render( template => $template, data => $vars, contenttype => 'text/html', code => $vars->{code} );
 }

+ 3 - 0
lib/tCMS/Manual.pod

@@ -85,3 +85,6 @@ Templates pull them in by name with the component() function, which Trog::Render
 
 The optional hash is flattened into the component's render(), and the output comes back marked raw.
 Only the name is accepted, one flat level under Trog::Component, so a template cannot name its way out of that namespace.
+
+The page furniture works this way: index.tx asks for its own Header, Footer, HtmlTitle, MidTitle, TopBar, LeftBar, RightBar, FootBar and CategoryBar, and posts.tx asks for the PostHeader and PostFooter a series names in its metadata.
+Each of those modules owns the name of the template it renders, so adding a piece of furniture no longer means editing a route.

+ 153 - 0
t/Trog-Component-page.t

@@ -0,0 +1,153 @@
+use strict;
+use warnings;
+
+use Test::More;
+use Test::MockModule qw{strict};
+use Test::Fatal      qw{exception};
+use FindBin;
+
+use lib "$FindBin::Bin/../lib";
+
+# The components render real templates through the real renderer, which resolves
+# them relative to the tCMS root.  Xslate also computes its cache dir from $HOME
+# at load time, so point that somewhere disposable before it is loaded.
+my $ROOT;
+
+BEGIN {
+    require File::Temp;
+    $ROOT = File::Temp::tempdir( 'tcms-component-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
+    $ENV{HOME} = $ROOT;
+    chdir("$FindBin::Bin/..") or die "could not chdir to the tCMS root: $!";
+}
+
+require_ok('Trog::Component')             or BAIL_OUT("Can't find SUT");
+require_ok('Trog::Component::Header')     or BAIL_OUT("Can't find SUT");
+require_ok('Trog::Component::PostHeader') or BAIL_OUT("Can't find SUT");
+
+subtest 'the furniture with no arguments' => sub {
+
+    # These six are empty or a stock placeholder, and read no variables at all.
+    # The test is that each resolves its own template and comes back with a
+    # string rather than dying -- which is the whole of what index.tx asks of it.
+    foreach my $name (qw{HtmlTitle MidTitle TopBar LeftBar RightBar FootBar Footer}) {
+        my $out = Trog::Component::component($name);
+        is( exception { Trog::Component::component($name) }, undef, "$name renders" );
+        isa_ok( $out, 'Text::Xslate::Type::Raw', "$name comes back marked raw" );
+    }
+
+    like( Trog::Component::component('HtmlTitle'), qr/tCMS/,     "HtmlTitle is the site title" );
+    like( Trog::Component::component('Footer'),    qr{</html>}i, "Footer closes the document" );
+};
+
+subtest 'Header' => sub {
+    my $out = Trog::Component::component(
+        'Header',
+        {
+            title       => 'A Page',
+            route       => '/somewhere',
+            stylesheets => ['/styles/screen.css'],
+            scripts     => ['/scripts/post.js'],
+        }
+    );
+
+    like( $out, qr/<!doctype html>/i,              "the doctype is emitted by default" );
+    like( $out, qr{<title>A Page</title>},         "the title lands" );
+    like( $out, qr{href="/styles/screen\.css"},    "stylesheets land" );
+    like( $out, qr{src="/scripts/post\.js"},       "scripts land" );
+    like( $out, qr{lang="en-US"},                  "lang defaults rather than rendering empty" );
+    like( $out, qr{href="/somewhere\?format=rss"}, "the rss link uses the route" );
+
+    # rss_style() renders into an XSL document, so the doctype has to go.
+    my $xsl = Trog::Component::component( 'Header', { title => 'RSS', no_doctype => 1 } );
+    unlike( $xsl, qr/<!doctype/i, "no_doctype suppresses it" );
+
+    # It also has no stylesheets or scripts to give, and must not invent any.
+    unlike( $xsl, qr/rel="stylesheet" type="text\/css" href="\/styles\/screen/, "no stylesheets are conjured up" );
+
+    # header.tx marks meta_tags raw, so social markup has to survive as markup.
+    my $social = Trog::Component::component( 'Header', { meta_tags => '<meta name="twitter:card" />' } );
+    like( $social, qr{<meta name="twitter:card" />}, "meta_tags stay markup" );
+};
+
+subtest 'CategoryBar' => sub {
+    my $categories = [ { local_href => '/series/blog', title => 'Blog' } ];
+
+    my $out = Trog::Component::component( 'CategoryBar', { categories => $categories } );
+    like( $out, qr{href="/series/blog"}, "an anonymous reader gets the plain link" );
+
+    my $secure = Trog::Component::component( 'CategoryBar', { categories => $categories, user => 'someone' } );
+    like( $secure, qr{href="/secure/series/blog"}, "a logged in reader gets the secure link" );
+
+    is( exception { Trog::Component::component('CategoryBar') }, undef, "no categories is not fatal" );
+};
+
+subtest 'PostHeader and PostFooter validate the name' => sub {
+
+    # The name comes out of post data, and before this it went straight into a
+    # path with nothing checking it.
+    foreach my $bad ( '../../etc/passwd', 'headers/../../../etc/passwd', 'nope.tx', 'about_header' ) {
+        like(
+            exception { Trog::Component::component( 'PostHeader', { name => $bad } ) },
+            qr/No such header/,
+            "'$bad' is refused"
+        );
+    }
+
+    # An empty name is how a post with no header asks, so it must not be fatal.
+    is( Trog::Component::component( 'PostHeader', { name => '' } ),    '', "no name renders nothing" );
+    is( Trog::Component::component('PostHeader'),                      '', "a missing name renders nothing" );
+    is( Trog::Component::component( 'PostFooter', { name => undef } ), '', "an undef name renders nothing" );
+
+    # Whatever the edit form offers has to actually render.
+    my $available = Trog::Component::PostHeader::available();
+    ok( scalar(@$available), "there is at least one stock header to try" ) or return;
+    foreach my $name (@$available) {
+        is( exception { Trog::Component::component( 'PostHeader', { name => $name } ) }, undef, "$name renders" );
+    }
+
+    # available() is the same list the series edit form is built from.
+    is_deeply(
+        $available,
+        Trog::Themes::themed_templates_in_dir( 'headers', 'text/html', 1 ),
+        "available() is the list the edit form offers"
+    );
+};
+
+subtest 'the whole page still composes' => sub {
+    require Trog::Renderer::Base;
+
+    # index.tx is a page, not a component, so it resolves in www/templates/html
+    # rather than the components dir and comes back as a PSGI triplet.
+    my $psgi = eval {
+        Trog::Renderer::Base::render(
+            template    => 'index.tx',
+            contenttype => 'text/html',
+            code        => 200,
+            data        => {
+                title        => 'Composed',
+                route        => '/',
+                content      => '<p>the body</p>',
+                categories   => [ { local_href => '/series/blog', title => 'Blog' } ],
+                stylesheets  => ['/styles/screen.css'],
+                print_styles => [],
+                scripts      => [],
+            },
+        );
+    };
+    my $err = $@;
+    is( $err, '', "index.tx renders" ) or diag($err);
+    my $body = ref $psgi eq 'ARRAY' ? $psgi->[2][0] : undef;
+
+    like( $body, qr/<!doctype html>/i,        "Header came through" );
+    like( $body, qr{<title>Composed</title>}, "the title came through" );
+    like( $body, qr{<p>the body</p>},         "the content is still the content" );
+    like( $body, qr{href="/series/blog"},     "CategoryBar came through" );
+    like( $body, qr{</html>},                 "Footer came through" );
+
+    # The furniture divs are the bars' sockets; they must still be there even
+    # though the stock bars render placeholders.
+    like( $body, qr{id="leftbar"}, "the left bar socket is intact" );
+    like( $body, qr{id="footbar"}, "the footer bar socket is intact" );
+};
+
+done_testing();

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

@@ -56,8 +56,8 @@ document.addEventListener("DOMContentLoaded", function(){
          </div>
     : }
 
-    : if ( $header ) {
-        <: $header | mark_raw :>
+    : if ( !$style && $primary_post.header ) {
+        <: component('PostHeader', { name => $primary_post.header, post => $primary_post, route => $route }) :>
     : }
     : if (!$style) {
     <hr class="divider" />
@@ -103,8 +103,8 @@ document.addEventListener("DOMContentLoaded", function(){
 : include "paginator.tx";
 : }
 
-: if ( $footer ) {
-    <: $footer | mark_raw :>
+: if ( !$style && $primary_post.footer ) {
+    <: component('PostFooter', { name => $primary_post.footer, post => $primary_post, route => $route }) :>
 : }
 
 <script>

+ 14 - 2
www/templates/html/embed.tx

@@ -1,4 +1,16 @@
-<: $header | mark_raw :>
+<: component('Header', {
+    title        => $title,
+    route        => $route,
+    lang         => $lang,
+    author       => $author,
+    meta_desc    => $meta_desc,
+    meta_tags    => $meta_tags,
+    default_tags => $default_tags,
+    embed        => $embed,
+    stylesheets  => $stylesheets,
+    print_styles => $print_styles,
+    scripts      => $scripts,
+}) :>
 :if ($embed_video || $nochrome) {
 <style>
 .divider, .blogtitles, #postData, .post-tags {
@@ -18,5 +30,5 @@ video {
 : if (!$embed_video && !$nochrome) {
 </div>
 : }
-<: $footer | mark_raw :>
+<: component('Footer') :>
 

+ 21 - 9
www/templates/html/index.tx

@@ -1,21 +1,33 @@
-<: $header | mark_raw :>
+<: component('Header', {
+    title        => $title,
+    route        => $route,
+    lang         => $lang,
+    author       => $author,
+    meta_desc    => $meta_desc,
+    meta_tags    => $meta_tags,
+    default_tags => $default_tags,
+    embed        => $embed,
+    stylesheets  => $stylesheets,
+    print_styles => $print_styles,
+    scripts      => $scripts,
+}) :>
 : if ($is_admin) {
 : include "sysbar.tx";
 : include "jsalert.tx";
 : } else {
 <div id="topkek" <: $is_admin ? "style='display:none'" : "" :>>
     <div id="lefttitle" class="toplel">
-        <: $htmltitle | mark_raw :>
+        <: component('HtmlTitle') :>
     </div>
     <div id="midtitle" class="toplel">
-        <: $midtitle | mark_raw :>
+        <: component('MidTitle') :>
     </div>
     <span title="Menu" id="clickme" onclick="this.focus()">🌐</span>
     <div id="righttitle" class="toplel">
-        <: $categorybar | mark_raw :>
+        <: component('CategoryBar', { categories => $categories, user => $user }) :>
         <span id="common-search">
         <a href="/sitemap" title="Sitemap" class="topbar">🗺️</a>
-        <: $topbar | mark_raw :>
+        <: component('TopBar') :>
         <form action="<: $route :>" method="GET" class="ib">
             <input required type="search" class="coolbutton topform" name="like" autofocus></input>
             <input class="coolbutton topform" type="submit" value="🔎"></input>
@@ -34,22 +46,22 @@
 </div>
 <div id="kontainer">
     <div id="leftbar" class="kontained">
-      <: $leftbar | mark_raw :>
+      <: component('LeftBar') :>
     </div>
     <div id="kontent" class="kontained">
         <: $content | mark_raw :>
     </div>
     <div id="rightbar" class="kontained">
-        <: $rightbar | mark_raw :>
+        <: component('RightBar') :>
     </div>
 </div>
 
 <div id="footbar">
-    <: $footbar | mark_raw :>
+    <: component('FootBar') :>
 </div>
 : if ( $show_madeby ) {
 <a target="_blank" rel="noopener" class="trogged" href="https://tcms.troglodyne.net">
 <img src="/img/icon/favicon.svg" style="height:2rem;" />
 </a>
 : }
-<: $footer | mark_raw :>
+<: component('Footer') :>

+ 7 - 2
www/templates/xsl/rss-style.tx

@@ -2,7 +2,12 @@
 <xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
   <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
   <xsl:template match="/">
-    <: $header | mark_raw :>
+    <: component('Header', {
+      title      => $title,
+      route      => $route,
+      lang       => $lang,
+      no_doctype => 1,
+    }) :>
         <a>
           <xsl:attribute name="href">
             <xsl:value-of select="rss/channel/link"/>
@@ -31,6 +36,6 @@
             </div>
           </div>
         </xsl:for-each>
-    <: $footer | mark_raw :>
+    <: component('Footer') :>
   </xsl:template>
 </xsl:stylesheet>