Ver código fonte

Massive refactor to componentize the render system, fix the emoji problem

George Baugh 2 anos atrás
pai
commit
3ea81c4c6f
67 arquivos alterados com 860 adições e 407 exclusões
  1. 12 8
      lib/TCMS.pm
  2. 21 0
      lib/Theme.pm
  3. 36 0
      lib/Trog/Component/EmojiPicker.pm
  4. 2 2
      lib/Trog/FileHandler.pm
  5. 2 0
      lib/Trog/Log.pm
  6. 110 0
      lib/Trog/Renderer.pm
  7. 106 0
      lib/Trog/Renderer/Base.pm
  8. 23 0
      lib/Trog/Renderer/blob.pm
  9. 28 0
      lib/Trog/Renderer/css.pm
  10. 54 0
      lib/Trog/Renderer/html.pm
  11. 28 0
      lib/Trog/Renderer/javascript.pm
  12. 24 0
      lib/Trog/Renderer/json.pm
  13. 25 0
      lib/Trog/Renderer/text.pm
  14. 155 331
      lib/Trog/Routes/HTML.pm
  15. 67 0
      lib/Trog/Themes.pm
  16. 7 0
      lib/Trog/Utils.pm
  17. 11 4
      lib/Trog/Vars.pm
  18. 13 0
      lib/tCMS/Manual.pod
  19. 0 21
      www/scripts/emoji.js
  20. 18 1
      www/styles/screen.css
  21. 0 0
      www/templates/css/avatars.tx
  22. 0 18
      www/templates/emojis.tx
  23. 3 0
      www/templates/html/500.tx
  24. 0 0
      www/templates/html/categories.tx
  25. 0 0
      www/templates/html/components/acls.tx
  26. 1 1
      www/templates/html/components/badrequest.tx
  27. 0 2
      www/templates/html/components/config.tx
  28. 0 0
      www/templates/html/components/default.tx
  29. 0 0
      www/templates/html/components/edit_foot.tx
  30. 2 2
      www/templates/html/components/edit_head.tx
  31. 78 0
      www/templates/html/components/emojis.tx
  32. 0 0
      www/templates/html/components/footbar.tx
  33. 0 0
      www/templates/html/components/footer.tx
  34. 0 0
      www/templates/html/components/forbidden.tx
  35. 0 0
      www/templates/html/components/form_common.tx
  36. 0 0
      www/templates/html/components/forms/blog.tx
  37. 0 0
      www/templates/html/components/forms/file.tx
  38. 0 0
      www/templates/html/components/forms/microblog.tx
  39. 0 0
      www/templates/html/components/forms/profile.tx
  40. 0 0
      www/templates/html/components/forms/series.tx
  41. 0 0
      www/templates/html/components/header.tx
  42. 0 0
      www/templates/html/components/leftbar.tx
  43. 0 1
      www/templates/html/components/manual.tx
  44. 0 0
      www/templates/html/components/midtitle.tx
  45. 1 1
      www/templates/html/components/notfound.tx
  46. 0 0
      www/templates/html/components/paginator.tx
  47. 0 0
      www/templates/html/components/post_tags.tx
  48. 0 0
      www/templates/html/components/post_title.tx
  49. 22 11
      www/templates/html/components/posts.tx
  50. 0 0
      www/templates/html/components/preview.tx
  51. 0 0
      www/templates/html/components/rightbar.tx
  52. 0 0
      www/templates/html/components/sitemap.tx
  53. 0 0
      www/templates/html/components/tags.tx
  54. 0 0
      www/templates/html/components/title.tx
  55. 0 0
      www/templates/html/components/toolong.tx
  56. 0 0
      www/templates/html/components/topbar.tx
  57. 0 2
      www/templates/html/components/totp.tx
  58. 0 0
      www/templates/html/embed.tx
  59. 0 0
      www/templates/html/footers/README.md
  60. 0 0
      www/templates/html/headers/README.md
  61. 9 1
      www/templates/html/index.tx
  62. 0 0
      www/templates/html/jsalert.tx
  63. 2 0
      www/templates/html/login.tx
  64. 0 0
      www/templates/html/notconfigured.tx
  65. 0 1
      www/templates/html/sysbar.tx
  66. 0 0
      www/templates/text/robots.tx
  67. 0 0
      www/templates/xsl/rss-style.tx

+ 12 - 8
lib/TCMS.pm

@@ -73,6 +73,9 @@ sub app {
 
     return _toolong() if length( $env->{REQUEST_URI} ) > 2048;
 
+    my $requestid = eval { UUID::Tiny::create_uuid_as_string( UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS ) } // "00000000-0000-0000-0000-000000000000";
+    Trog::Log::uuid($requestid);
+
     # Check eTags.  If we don't know about it, just assume it's good and lazily fill the cache
     # XXX yes, this allows cache poisoning...but only for logged in users!
     if ( $env->{HTTP_IF_NONE_MATCH} ) {
@@ -146,15 +149,14 @@ sub app {
     $query->{user_acls} = [];
     $query->{user_acls} = Trog::Auth::acls4user($active_user) // [] if $active_user;
 
-    # Log the request.  UUID::Tiny can explode sometimes.
-    my $requestid = eval { UUID::Tiny::create_uuid_as_string( UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS ) } // "00000000-0000-0000-0000-000000000000";
-    Trog::Log::uuid($requestid);
-    INFO("$env->{REQUEST_METHOD} $path");
-
     # Filter out passed ACLs which are naughty
     my $is_admin = grep { $_ eq 'admin' } @{ $query->{user_acls} };
     @{ $query->{acls} } = grep { $_ ne 'admin' } @{ $query->{acls} } unless $is_admin;
 
+    # Ensure any short-circuit routes can log the request
+    $query->{method} = $env->{REQUEST_METHOD};
+    $query->{route}  = $path;
+
     # Disallow any paths that are naughty ( starman auto-removes .. up-traversal)
     if ( index( $path, '/templates' ) == 0 || index( $path, '/statics' ) == 0 || $path =~ m/.*(\.psgi|\.pm)$/i ) {
         return _forbidden($query);
@@ -210,8 +212,8 @@ sub app {
     $query->{deflate} = $deflate;
     $query->{user}    = $active_user;
 
-    return _forbidden($query) if $routes{$path}{auth} && !$active_user;
-    return _notfound($query)   unless exists $routes{$path};
+    return _forbidden($query)  if exists $routes{$path}{auth} && !$active_user;
+    return _notfound($query)   unless $routes{$path} && ref $routes{$path} eq 'HASH' && keys(%{$routes{$path}});
     return _badrequest($query) unless grep { $env->{REQUEST_METHOD} eq $_ } ( $routes{$path}{method} || '', 'HEAD' );
 
     @{$query}{ keys( %{ $routes{$path}{'data'} } ) } = values( %{ $routes{$path}{'data'} } ) if ref $routes{$path}{'data'} eq 'HASH' && %{ $routes{$path}{'data'} };
@@ -235,6 +237,8 @@ sub app {
         no strict 'refs';
         my $output = $routes{$path}{callback}->($query);
 
+        INFO("$env->{REQUEST_METHOD} $output->[0] $path");
+
         # Append server-timing headers
         my $tot = tv_interval($start) * 1000;
         push( @{ $output->[1] }, 'Server-Timing' => "app;dur=$tot" );
@@ -315,7 +319,7 @@ sub _static ( $path, $start, $streaming, $last_fetch = 0 ) {
 
         return [ $code, [%$headers_parsed], $fh ];
     }
-    return [ 403, [ 'Content-Type' => $Trog::Vars::content_types{plain} ], ["STAY OUT YOU RED MENACE"] ];
+    return [ 403, [ 'Content-Type' => $Trog::Vars::content_types{text} ], ["STAY OUT YOU RED MENACE"] ];
 }
 
 1;

+ 21 - 0
lib/Theme.pm

@@ -0,0 +1,21 @@
+package Theme;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures};
+
+# An example of the bare minimum your themes' routes.pm need.
+# Copy and alter things below as needed.
+
+our $default_title = 'tCMS';
+our $default_image = 'img/icon/favicon-48.png';
+our $display_name  = 'tCMS';
+our $description   = 'tCMS is a content management system written in perl.';
+our $default_tags  = 'tcms';
+
+our $twitter_account = '';
+our $fb_app_id       = '';
+
+1;

+ 36 - 0
lib/Trog/Component/EmojiPicker.pm

@@ -0,0 +1,36 @@
+package Trog::Component::EmojiPicker;
+
+use strict;
+use warnings;
+
+no warnings qw{experimental};
+use feature qw{signatures state};
+
+use Trog::Renderer;
+
+sub render () {
+    state %categorized;
+
+    if (!%categorized) {
+        my $file = 'www/scripts/list.min.json';
+        die "Run make prereq-frontend first" unless -f $file;
+
+        my $raw = File::Slurper::read_binary($file);
+        my $emojis = Cpanel::JSON::XS::decode_json($raw);
+        foreach my $emoji (@{$emojis->{emojis}}) {
+            $categorized{$emoji->{category}} //= [];
+            push(@{$categorized{$emoji->{category}}}, $emoji->{emoji});
+        }
+    }
+
+    return Trog::Renderer->render(
+        contenttype => 'text/html',
+        component => 1,
+        template  => 'emojis.tx',
+        data      => {
+            categories => \%categorized,
+        },
+    );
+}
+
+1;

+ 2 - 2
lib/Trog/FileHandler.pm

@@ -34,7 +34,7 @@ sub serve ( $path, $start, $streaming, $ranges, $last_fetch = 0, $deflate = 0 )
         $ft = Plack::MIME->mime_type($ext) if $ext;
         $ft ||= $extra_types{$ext}         if exists $extra_types{$ext};
     }
-    $ft ||= $Trog::Vars::content_types{plain};
+    $ft ||= $Trog::Vars::content_types{text};
 
     my $ct      = 'Content-type';
     my @headers = ( $ct => $ft );
@@ -94,7 +94,7 @@ sub serve ( $path, $start, $streaming, $ranges, $last_fetch = 0, $deflate = 0 )
         return [ $code, \@headers, [$dfh] ];
     }
 
-    return [ 403, [ $ct => $Trog::Vars::content_types{plain} ], ["STAY OUT YOU RED MENACE"] ];
+    return [ 403, [ $ct => $Trog::Vars::content_types{text} ], ["STAY OUT YOU RED MENACE"] ];
 }
 
 sub _range ( $fh, $ranges, $sz, %headers ) {

+ 2 - 0
lib/Trog/Log.pm

@@ -64,6 +64,8 @@ BEGIN {
 sub _log {
     my ( $msg, $level ) = @_;
 
+    $msg //= "No message passed.  This is almost certainly a bug. ";
+
     my $tstamp = strftime "%a %b %d %T %Y", localtime;
     my $uuid   = uuid();
 

+ 110 - 0
lib/Trog/Renderer.pm

@@ -0,0 +1,110 @@
+package Trog::Renderer;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use Carp::Always;
+
+use Trog::Vars;
+use Trog::Log qw{:all};
+
+use Trog::Renderer::text;
+use Trog::Renderer::html;
+use Trog::Renderer::json;
+use Trog::Renderer::blob;
+use Trog::Renderer::css;
+
+=head1 Trog::Renderer
+
+Idea here is to have a renderer per known/supported content-type we need to output that is also theme-aware.
+
+We have an abstraction here, render() which you feed everything to.
+
+=cut;
+
+our %renderers = (
+    text  => \&Trog::Renderer::text::render,
+    html  => \&Trog::Renderer::html::render,
+    json  => \&Trog::Renderer::json::render,
+    blob  => \&Trog::Renderer::blob::render,
+    xsl   => \&Trog::Renderer::text::render,
+    xml   => \&Trog::Renderer::text::render,
+    css   => \&Trog::Renderer::css::render,
+);
+
+=head2 Trog::Renderer->render(%options)
+
+Returns either the 3-arg arrayref suitable to emit at the end of a PSGI session or a response body if the component field of options is truthy.
+The idea is that components will be concatenated to other rendered templates until we finish having everything ready.
+
+=cut
+
+sub render ($class, %options) {
+    local $@;
+    my $renderer;
+    return _yeet($renderer, "Renderer requires a valid content type to be passed", %options) unless $options{contenttype};
+    my $rendertype = $Trog::Vars::byct{$options{contenttype}};
+    return _yeet($renderer, "Renderer requires a known content type (used $options{contenttype}) to be passed", %options) unless $rendertype;
+    $renderer = $renderers{$rendertype};
+    return _yeet($renderer, "Renderer for $rendertype is not defined!", %options) unless $renderer;
+    return _yeet($renderer, "Status code not provided", %options) if !$options{code} && !$options{component};
+    return _yeet($renderer, "Template data not provided", %options) unless $options{data};
+    return _yeet($renderer, "Template not provided", %options) unless $options{template};
+
+    my $skip_save = !$options{data}{route} || $options{data}{has_query} || $options{data}{user} || ($options{code} // 0) != 200 || Trog::Log::is_debug();
+
+    my $ret;
+    local $@;
+    eval {
+        $ret = $renderer->(%options);
+        save_render( $options{data}, $ret->[2], $ret->[1]) unless $skip_save;
+        1;
+    } or do {
+        return _yeet($renderer, $@, %options);
+    };
+    return $ret;
+}
+
+sub _yeet ($renderer, $error, %options) {
+    WARN($error);
+
+    # All-else fails error page
+    my $ret;
+    local $@;
+    eval {
+        $ret = $renderer->(
+            code => 500,
+            template => '500.tx',
+            contenttype => 'text/html',
+            data => { %options, content => "<h1>500 Internal Server Error</h1>$error" },
+        );
+        1;
+    } or do {
+        my $msg = $error;
+        $msg .= " and subsequently during render of error template, $@" if $renderer;
+        INFO("$options{data}{method} 500 $options{data}{route}");
+        FATAL($msg);
+    };
+    return $ret;
+}
+
+sub save_render ( $vars, $body, %headers ) {
+    Path::Tiny::path( "www/statics/" . dirname( $vars->{route} ) )->mkpath;
+    my $file = "www/statics/$vars->{route}";
+
+    my $verb = -f $file ? 'Overwrite' : 'Write';
+    DEBUG("$verb static for $vars->{route}");
+    open( my $fh, '>', $file ) or die "Could not open $file for writing";
+    print $fh "HTTP/1.1 $vars->{code} OK\n";
+    foreach my $h ( keys(%headers) ) {
+        print $fh "$h:$headers{$h}\n" if $headers{$h};
+    }
+    print $fh "\n";
+    print $fh $body;
+    close $fh;
+}
+
+1;

+ 106 - 0
lib/Trog/Renderer/Base.pm

@@ -0,0 +1,106 @@
+package Trog::Renderer::Base;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use Encode qw{encode_utf8};
+use IO::Compress::Gzip;
+
+use Text::Xslate;
+use Trog::Themes;
+use Trog::Config;
+
+=head1 Trog::Renderer::Base
+
+Basic rendering structure, subclass me.
+
+Sets up the methods which must be present for all templates, e.g. render_it for rendering dynamic template strings coming from a post.
+
+=cut
+
+our %renderers;
+
+sub render (%options) {
+    die "Templated renders require a template to be passed" unless $options{template};
+
+    my $template_dir = Trog::Themes::template_dir($options{template}, $options{contenttype}, $options{component});
+    die "Templated renders require an existing template to be passed, got $template_dir/$options{template}" unless -f "$template_dir/$options{template}";
+
+    #TODO make this work with posts all the time
+    $options{child_processor} //= Text::Xslate->new( path => $template_dir );
+    my $child_processor = $options{child_processor};
+    $options{child_renderer} //= sub {
+        my ( $template_string, $options ) = @_;
+
+        # If it fails to render, it must be something else
+        my $out = eval { $child_processor->render_string( $template_string, $options ) };
+        return $out ? $out : $template_string;
+    };
+
+    $renderers{$template_dir} //= Text::Xslate->new(
+        path     => $template_dir,
+        function => {
+            render_it => $options{child_renderer},
+        },
+    );
+
+    my $code = $options{code};
+    my $body = encode_utf8($renderers{$template_dir}->render($options{template}, $options{data}));
+
+    # Users can supply a post_processor to futz with the output (such as with minifiers) if they wish.
+    $body = $options{post_processor}->($body) if $options{post_processor} && ref $options{post_processor} eq 'CODE';
+
+    # Users can supply custom headers as part of the data in options.
+    my %headers = headers(\%options, $body);
+
+    return $body if $options{component};
+    return [$code, [%headers], [$body]] unless $options{deflate};
+
+    $headers{"Content-Encoding"} = "gzip";
+    my $dfh;
+    IO::Compress::Gzip::gzip( \$body => \$dfh );
+    print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
+    $headers{"Content-Length"} = length($dfh);
+
+    return [ $code, [%headers], [$dfh] ];
+}
+
+sub headers ($query,$body) {
+    my $uh = ref $query->{headers} eq 'HASH' ? $query->{headers} : {};
+    my $ct = $query->{contenttype} eq 'text/html' ? "text/html; charset=UTF-8" : "$query->{contenttype};";
+    my %headers = (
+        'Content-Type'   => $ct,
+        'Content-Length' => length($body),
+        'Cache-Control'  => $query->{cachecontrol} // $Trog::Vars::cache_control{revalidate},
+        'X-Content-Type-Options' => 'nosniff',
+        'Vary'           => 'Accept-Encoding',
+        %$uh,
+    );
+
+    #Disallow framing UNLESS we are in embed mode
+    $headers{"Content-Security-Policy"} = qq{frame-ancestors 'none'} unless $query->{embed};
+
+    $headers{'X-Frame-Options'} = 'DENY' unless $query->{embed};
+    $headers{'Referrer-Policy'} = 'no-referrer-when-downgrade';
+
+    #CSP. Yet another layer of 'no mixed content' plus whitelisted execution of remote resources.
+    my $scheme = $query->{scheme} ? "$query->{scheme}:" : '';
+
+    my $conf = Trog::Config::get();
+    my $sites = $conf->param('security.allow_embeds_from') // '';
+    $headers{'Content-Security-Policy'} .= ";default-src $scheme 'self' 'unsafe-eval' 'unsafe-inline' $sites";
+    $headers{'Content-Security-Policy'} .= ";object-src 'none'";
+
+    # Force https if we are https
+    $headers{'Strict-Transport-Security'} = 'max-age=63072000' if ($query->{scheme} // '') eq 'https';
+
+    # We only set etags when users are logged in, cause we don't use statics
+    $headers{'ETag'} = $query->{etag} if $query->{etag} && $query->{user};
+
+    return %headers;
+}
+
+1;

+ 23 - 0
lib/Trog/Renderer/blob.pm

@@ -0,0 +1,23 @@
+package Trog::Renderer::blob;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+=head1 Trog::Renderer::blob
+
+Render blobs, such as files stored in a DB.
+
+=cut
+
+# TODO use the streaming code from Trog::FileHandler, etc.
+sub render (%options) {
+    my $code    = delete $options{code};
+    my $headers = delete $options{headers};
+    my $body    = $options{body};
+    return [$code, [$headers], [$body]];
+}
+
+1;

+ 28 - 0
lib/Trog/Renderer/css.pm

@@ -0,0 +1,28 @@
+package Trog::Renderer::css;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use parent qw{Trog::Renderer::Base};
+
+use CSS::Minifier::XS;
+
+=head1 Trog::Renderer::css
+
+Render CSS, and minify the output.
+
+=cut
+
+sub render (%options) {
+    $options{post_processor} = \&_minify;
+    Trog::Renderer::Base::render(%options);
+}
+
+sub _minify {
+    return CSS::Minifier::XS::minify(shift);
+}
+
+1;

+ 54 - 0
lib/Trog/Renderer/html.pm

@@ -0,0 +1,54 @@
+package Trog::Renderer::html;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use parent qw{Trog::Renderer::Base};
+
+use Text::Xslate;
+
+=head1 Trog::Renderer::html
+
+Render HTML. TODO: support inlining everything like you would want when emailing a post.
+
+=cut
+
+sub render (%options) {
+    state $child_processor = Text::Xslate->new(
+
+        # Prevent a recursive descent.  If the renderer is hit again, just do nothing
+        # XXX unfortunately if the post tries to include itself, it will die.
+        function => {
+            embed => sub {
+                my ( $this_id, $style ) = @_;
+                $style //= 'embed';
+
+                # If instead the style is 'content', then we will only show the content w/ no formatting, and no title.
+                return Text::Xslate::mark_raw(
+                    Trog::Routes::HTML::posts(
+                        { route => "/post/$this_id", style => $style },
+                        sub { },
+                        1
+                    )
+                );
+            },
+        }
+    );
+    state $child_renderer = sub {
+        my ( $template_string, $options ) = @_;
+
+        # If it fails to render, it must be something else
+        my $out = eval { $child_processor->render_string( $template_string, $options ) };
+        return $out ? $out : $template_string;
+    };
+
+    $options{child_processor} = $child_processor;
+    $options{child_renderer}  = $child_renderer;
+
+    return Trog::Renderer::Base::render(%options);
+}
+
+1;

+ 28 - 0
lib/Trog/Renderer/javascript.pm

@@ -0,0 +1,28 @@
+package Trog::Renderer::javascript;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use parent qw{Trog::Renderer::Base};
+
+use JavaScript::Minifier::XS;
+
+=head1 Trog::Renderer::javascript
+
+Render JS, and minify the output.
+
+=cut
+
+sub render (%options) {
+    $options{post_processor} = \&_minify;
+    Trog::Renderer::Base::render(%options);
+}
+
+sub _minify {
+    return JavaScript::Minifier::XS::minify(shift);
+}
+
+1;

+ 24 - 0
lib/Trog/Renderer/json.pm

@@ -0,0 +1,24 @@
+package Trog::Renderer::json;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use JSON::MaybeXS;
+
+=head1 Trog::Renderer::json
+
+Render JSON.  Rather than be templated, we just run the input thru the encoder.
+
+=cut
+
+sub render (%options) {
+    my $code    = delete $options{code};
+    my $headers = delete $options{headers};
+    my $body    = encode_json(\%options);
+    return [$code, [$headers], [$body]];
+}
+
+1;

+ 25 - 0
lib/Trog/Renderer/text.pm

@@ -0,0 +1,25 @@
+package Trog::Renderer::text;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use parent qw{Trog::Renderer::Base};
+
+use Text::Xslate;
+
+use Trog::Themes;
+
+=head1 Trog::Renderer::text
+
+Render plain text.  Can be used for email as well.
+
+=cut
+
+sub render (%options) {
+    Trog::Renderer::Base::render(%options);
+}
+
+1;

+ 155 - 331
lib/Trog/Routes/HTML.pm

@@ -3,7 +3,7 @@ package Trog::Routes::HTML;
 use strict;
 use warnings;
 
-no warnings 'experimental';
+no warnings qw{experimental once};
 use feature qw{signatures state};
 
 use Errno qw{ENOENT};
@@ -15,25 +15,24 @@ use HTML::SocialMeta;
 
 use Encode qw{encode_utf8};
 use IO::Compress::Gzip;
-use CSS::Minifier::XS;
 use Path::Tiny();
 use File::Basename qw{dirname};
 use URI();
 
+use FindBin::libs;
+
 use Trog::Log qw{:all};
 use Trog::Utils;
 use Trog::Config;
 use Trog::Auth;
 use Trog::Data;
 use Trog::FileHandler;
+use Trog::Themes;
+use Trog::Renderer;
 
-my $conf         = Trog::Config::get();
-my $template_dir = 'www/templates';
-my $theme_dir    = '';
-$theme_dir = "themes/" . $conf->param('general.theme') if $conf->param('general.theme') && -d "www/themes/" . $conf->param('general.theme');
-my $td = $theme_dir ? "/$theme_dir" : '';
+use Trog::Component::EmojiPicker;
 
-use lib 'www';
+my $conf = Trog::Config::get();
 
 our $landing_page = 'default.tx';
 our $htmltitle    = 'title.tx';
@@ -191,21 +190,19 @@ our %routes = (
         callback => \&Trog::Routes::HTML::posts,
         data     => { format => 'rss' },
     },
-    '/emoji' => {
-        method => 'GET',
-        callback => \&Trog::Routes::HTML::emojis,
-        auth => 1,
-    },
 );
 
 # Grab theme routes
 my $themed = 0;
-if ($theme_dir) {
-    my $theme_mod = "$theme_dir/routes.pm";
+if ($Trog::Themes::theme_dir) {
+    my $theme_mod = "$Trog::Themes::theme_dir/routes.pm";
     if ( -f "www/$theme_mod" ) {
         require $theme_mod;
         @routes{ keys(%Theme::routes) } = values(%Theme::routes);
         $themed = 1;
+    } else {
+        # Use the special "default" theme
+        require Theme;
     }
 }
 
@@ -221,18 +218,19 @@ Most subsequent functions simply pass content to this function.
 =cut
 
 sub index ( $query, $content = '', $i_styles = [] ) {
-    $query->{theme_dir} = $td;
+    $query->{theme_dir} = $Trog::Themes::td;
 
-    $content ||= themed_render( $landing_page, $query );
+    my $to_render = $query->{template} // $landing_page;
+    $content ||= Trog::Renderer->render( template => $to_render, data => $query, component => 1, contenttype => 'text/html' );
 
-    my @styles = ('/styles/avatars.css');
-    if ($theme_dir) {
+    my @styles = ('avatars.css');
+    if ($Trog::Themes::theme_dir) {
         if ( $query->{embed} ) {
-            unshift( @styles, _themed_style("embed.css") ) if -f 'www/' . _themed_style("embed.css");
+            unshift( @styles, Trog::Themes::themed_style("embed.css") ) if -f 'www/' . Trog::Themes::themed_style("embed.css");
         }
-        unshift( @styles, _themed_style("screen.css") )    if -f 'www/' . _themed_style("screen.css");
-        unshift( @styles, _themed_style("print.css") )     if -f 'www/' . _themed_style("print.css");
-        unshift( @styles, _themed_style("structure.css") ) if -f 'www/' . _themed_style("structure.css");
+        unshift( @styles, Trog::Themes::themed_style("screen.css") )    if -f 'www/' . Trog::Themes::themed_style("screen.css");
+        unshift( @styles, Trog::Themes::themed_style("print.css") )     if -f 'www/' . Trog::Themes::themed_style("print.css");
+        unshift( @styles, Trog::Themes::themed_style("structure.css") ) if -f 'www/' . Trog::Themes::themed_style("structure.css");
     }
     push( @styles, @$i_styles );
 
@@ -252,15 +250,15 @@ sub index ( $query, $content = '', $i_styles = [] ) {
             %$query,
             search_lang  => $data->lang(),
             search_help  => $data->help(),
-            theme_dir    => $td,
+            theme_dir    => $Trog::Themes::td,
             content      => $content,
             title        => $title,
-            htmltitle    => themed_render( $htmltitle, $query ),
-            midtitle     => themed_render( $midtitle,  $query ),
-            rightbar     => themed_render( $rightbar,  $query ),
-            leftbar      => themed_render( $leftbar,   $query ),
-            topbar       => themed_render( $topbar,    $query ),
-            footbar      => themed_render( $footbar,   $query ),
+            htmltitle    => Trog::Renderer->render( template => $htmltitle, data => $query, component => 1, contenttype => 'text/html' ),
+            midtitle     => Trog::Renderer->render( template => $midtitle,  data => $query, component => 1, contenttype => 'text/html' ),
+            rightbar     => Trog::Renderer->render( template => $rightbar,  data => $query, component => 1, contenttype => 'text/html' ),
+            leftbar      => Trog::Renderer->render( template => $leftbar,   data => $query, component => 1, contenttype => 'text/html' ),
+            topbar       => Trog::Renderer->render( template => $topbar,    data => $query, component => 1, contenttype => 'text/html' ),
+            footbar      => Trog::Renderer->render( template => $footbar,   data => $query, component => 1, contenttype => 'text/html' ),
             categories   => \@series,
             stylesheets  => \@styles,
             show_madeby  => $Theme::show_madeby ? 1 : 0,
@@ -287,7 +285,7 @@ sub _build_social_meta ( $query, $title ) {
     $card_type = 'featured_image' if $query->{primary_post} && $query->{primary_post}{is_image};
     $card_type = 'player'         if $query->{primary_post} && $query->{primary_post}{is_video};
 
-    my $image = $Theme::default_image ? "https://$query->{domain}/$td/$Theme::default_image" : '';
+    my $image = $Theme::default_image ? "https://$query->{domain}/$Trog::Themes::td/$Theme::default_image" : '';
     $image = "https://$query->{domain}/$query->{primary_post}{preview}" if $query->{primary_post} && $query->{primary_post}{preview};
     $image = "https://$query->{domain}/$query->{primary_post}{href}"    if $query->{primary_post} && $query->{primary_post}{is_image};
 
@@ -323,7 +321,7 @@ sub _build_social_meta ( $query, $title ) {
     $meta_tags =~ s/content="video"/content="video:other"/mg if $meta_tags;
     $meta_tags .= $extra_tags                                if $extra_tags;
 
-    print STDERR "WARNING: Theme misconfigured, social media tags will not be included\n$@\n" if $theme_dir && !$meta_tags;
+    print STDERR "WARNING: Theme misconfigured, social media tags will not be included\n$@\n" if $Trog::Themes::theme_dir && !$meta_tags;
     return ( $default_tags, $meta_desc, $meta_tags );
 }
 
@@ -341,18 +339,9 @@ sub _generic_route ( $rname, $code, $title, $query ) {
     $query->{code} = $code;
     $query->{route} //= $rname;
     $query->{title} = $title;
-    my $styles  = _build_themed_styles("$rname.css");
-    my $content = themed_render(
-        "$rname.tx",
-        {
-            title   => $title,
-            route   => $query->{route},
-            user    => $query->{user},
-            styles  => $styles,
-            deflate => $query->{deflate},
-        }
-    );
-    return Trog::Routes::HTML::index( $query, $content, $styles );
+    $query->{template} = "$rname.tx";
+    INFO("$query->{method} $code $query->{route}");
+    return Trog::Routes::HTML::index( $query );
 }
 
 sub notfound (@args) {
@@ -378,14 +367,17 @@ Redirects to the provided page.
 =cut
 
 sub redirect ($to) {
+    INFO("redirect: $to");
     return [ 302, [ "Location" => $to ], [''] ];
 }
 
 sub redirect_permanent ($to) {
+    INFO("permanent redirect: $to");
     return [ 301, [ "Location" => $to ], [''] ];
 }
 
 sub see_also ($to) {
+    INFO("see also: $to");
     return [ 303, [ "Location" => $to ], [''] ];
 }
 
@@ -401,7 +393,15 @@ Return an appropriate robots.txt
 
 sub robots ($query) {
     state $etag = "robots-" . time();
-    return finish_render( undef, { etag => $etag, contenttype => 'text/plain', body => encode_utf8( themed_render( 'robots.tx', { domain => $query->{domain} } ) ) } );
+    return Trog::Renderer->render(
+        contenttype => 'text/plain',
+        template => 'robots.tx',
+        data => {
+            etag   => $etag,
+            %$query,
+        },
+        code => 200,
+    );
 }
 
 =head2 setup
@@ -416,7 +416,7 @@ sub setup ($query) {
         'notconfigured.tx',
         {
             title       => 'tCMS Requires Setup to Continue...',
-            stylesheets => _build_themed_styles('notconfigured.css'),
+            stylesheets => ['notconfigured.css'],
             scheme      => $query->{scheme},
         }
     );
@@ -432,20 +432,23 @@ Returns a page with a QR code & TOTP uri for pasting into your authenticator app
 sub totp ($query) {
     my $active_user = $query->{user};
     my $domain      = $query->{domain};
+    $query->{failure} //= -1;
     my ( $uri, $qr, $failure, $message ) = Trog::Auth::totp( $active_user, $domain );
 
-    return finish_render(
-        'totp.tx',
+    return Trog::Routes::HTML::index(
         {
             title       => 'Enable TOTP 2-Factor Auth',
-            theme_dir   => $td,
+            theme_dir   => $Trog::Themes::td,
             uri         => $uri,
             qr          => $qr,
             failure     => $failure,
             message     => $message,
-            stylesheets => _build_themed_styles('post.css'),
-            scheme      => $query->{scheme},
-        }
+            template    => 'totp.tx',
+            is_admin    => 1,
+            %$query,
+        },
+        undef,
+        [qw{post.css}],
     );
 }
 
@@ -463,6 +466,7 @@ sub login ($query) {
     $query->{to} //= $query->{route};
     $query->{to} = '/config' if $query->{to} eq '/login';
     if ( $query->{user} ) {
+        DEBUG("Login by $query->{user}, redirecting to $query->{to}");
         return see_also( $query->{to} );
     }
 
@@ -470,7 +474,7 @@ sub login ($query) {
     my $hasusers = -f "config/has_users";
     my $btnmsg   = $hasusers ? "Log In" : "Register";
 
-    my @headers;
+    my $headers;
     my $has_totp = 0;
     if ( $query->{username} && $query->{password} ) {
         if ( !$hasusers ) {
@@ -493,27 +497,29 @@ sub login ($query) {
             # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
             my $secure = '';
             $secure  = '; Secure' if $query->{scheme} eq 'https';
-            @headers = (
+            $headers = {
                 "Set-Cookie" => "tcmslogin=$cookie; HttpOnly; SameSite=Strict$secure",
-            );
+            };
             $query->{failed} = 0;
         }
     }
 
     $query->{failed} //= -1;
-    return finish_render(
-        'login.tx',
-        {
+    return Trog::Renderer->render(
+        template => 'login.tx',
+        data => {
             title       => 'tCMS 2 ~ Login',
             to          => $query->{to},
             failure     => int( $query->{failed} ),
             message     => int( $query->{failed} ) < 1 ? "Login Successful, Redirecting..." : "Login Failed.",
             btnmsg      => $btnmsg,
-            stylesheets => _build_themed_styles('login.css'),
-            theme_dir   => $td,
-            scheme      => $query->{scheme},
+            stylesheets => _build_themed_styles([qw{login.css}]),
+            theme_dir   => $Trog::Themes::td,
+            %$query,
         },
-        @headers
+        headers     => $headers,
+        contenttype => 'text/html',
+        code => 200,
     );
 }
 
@@ -609,18 +615,14 @@ sub config ($query) {
     return see_also('/login')                    unless $query->{user};
     return Trog::Routes::HTML::forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
 
-    my $css = _build_themed_styles('config.css');
-    my $js  = _build_themed_scripts('post.js');
-
     $query->{failure} //= -1;
 
-    return finish_render(
-        'config.tx',
+    return Trog::Routes::HTML::index(
         {
             title              => 'Configure tCMS',
-            theme_dir          => $td,
-            stylesheets        => $css,
-            scripts            => $js,
+            theme_dir          => $Trog::Themes::td,
+            stylesheets        => [qw{config.css}],
+            scripts            => [qw{post.js}],
             themes             => _get_themes() || [],
             data_models        => _get_data_models(),
             current_theme      => $conf->param('general.theme')      // '',
@@ -631,7 +633,12 @@ sub config ($query) {
             to                 => '/config',
             scheme             => $query->{scheme},
             embeds             => $conf->param('security.allow_embeds_from') // '',
-        }
+            is_admin           => 1,
+            template           => 'config.tx',
+            %$query,
+        },
+        undef,
+        [qw{config.css}],
     );
 }
 
@@ -734,7 +741,7 @@ sub post_save ($query) {
     #Copy this down since it will be deleted later
     my $acls = $query->{acls};
 
-    $query->{tags} = _coerce_array( $query->{tags} );
+    $query->{tags} = Trog::Utils::coerce_array( $query->{tags} );
 
     # Filter bits and bobs
     delete $query->{primary_post};
@@ -832,29 +839,23 @@ Returns the avatars.css.
 
 sub avatars ($query) {
     push( @{ $query->{user_acls} }, 'public' );
-    my $tags = _coerce_array( $query->{tag} );
+    my $tags = Trog::Utils::coerce_array( $query->{tag} );
 
     my @posts = _post_helper( $query, $tags, $query->{user_acls} );
-
-    $query->{body} = encode_utf8(
-        CSS::Minifier::XS::minify(
-            themed_render(
-                'avatars.tx',
-                {
-                    users => \@posts,
-                }
-            )
-        )
-    );
-
-    $query->{contenttype} = "text/css";
     if (@posts) {
-
         # Set the eTag so that we don't get a re-fetch
         $query->{etag} = "$posts[0]{id}-$posts[0]{version}";
     }
 
-    return finish_render( undef, $query );
+    return Trog::Renderer->render(
+        template => 'avatars.tx',
+        data => {
+            users => \@posts,
+            %$query,
+        },
+        code        => 200,
+        contenttype => 'text/css',
+    );
 }
 
 =head2 users
@@ -902,7 +903,7 @@ sub posts ( $query, $direct = 0 ) {
     $query->{route} //= $query->{to};
     my ( undef, undef, $id ) = split( /\//, $query->{route} );
 
-    my $tags = _coerce_array( $query->{tag} );
+    my $tags = Trog::Utils::coerce_array( $query->{tag} );
     $query->{id} = $id if $id && !$query->{in_series};
 
     my $is_admin = grep { $_ eq 'admin' } @{ $query->{user_acls} };
@@ -957,15 +958,17 @@ 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 = themed_render( 'headers/' . $query->{primary_post}{header}, { theme_dir => $td } ) if $query->{primary_post}{header};
-    $footer = themed_render( 'footers/' . $query->{primary_post}{footer}, { theme_dir => $td } ) if $query->{primary_post}{footer};
+    $header = Trog::Renderer->render( 'headers/' . $query->{primary_post}{header}, { theme_dir => $Trog::Themes::td } ) if $query->{primary_post}{header};
+    $footer = Trog::Renderer->render( 'footers/' . $query->{primary_post}{footer}, { theme_dir => $Trog::Themes::td } ) if $query->{primary_post}{footer};
 
     # List the available headers/footers
-    my $headers = _templates_in_dir( -d $theme_dir ? "www/$theme_dir/templates/headers" : "www/templates/headers" );
-    my $footers = _templates_in_dir( -d $theme_dir ? "www/$theme_dir/templates/footers" : "www/templates/footers" );
+    my $headers = Trog::Themes::templates_in_dir( "headers", 'text/html', 1 );
+    my $footers = Trog::Themes::templates_in_dir( "footers", 'text/html', 1 );
 
-    my $styles = _build_themed_styles('posts.css');
+    #XXX used to be post.css, but probably not good anymore?
+    my $styles = [];
 
     # Build page title if it wasn't set by a wrapping sub
     $query->{title} = "$query->{domain} : $query->{title}" if $query->{title} && $query->{domain};
@@ -1006,7 +1009,7 @@ sub posts ( $query, $direct = 0 ) {
         $_
     } _post_helper( {}, ['series'], $query->{user_acls} );
 
-    my $forms = _templates_in_dir("$template_dir/forms");
+    my $forms = Trog::Themes::templates_in_dir("forms", 'text/html', 1);
 
     my $edittype = $query->{primary_post} ? $query->{primary_post}->{child_form}          : $query->{form};
     my $tiled    = $query->{primary_post} ? !$is_admin && $query->{primary_post}->{tiled} : 0;
@@ -1034,9 +1037,14 @@ sub posts ( $query, $direct = 0 ) {
 
     $query->{author} = $query->{primary_post}{user} // $posts[0]{user};
 
-    my $content = themed_render(
-        'posts.tx',
-        {
+    my $picker = Trog::Component::EmojiPicker::render();
+    return $picker if ref $picker eq 'ARRAY';
+
+    #XXX the only reason this is needed is due to direct=1
+    #XXX is this even used?
+    my $content = Trog::Renderer->render(
+        template => 'posts.tx',
+        data => {
             acls              => \@acls,
             can_edit          => $is_admin,
             forms             => $forms,
@@ -1068,23 +1076,18 @@ sub posts ( $query, $direct = 0 ) {
             footers           => $footers,
             years             => [ reverse( $oldest_year .. $now_year ) ],
             months            => [ 0 .. 11 ],
-        }
+            emoji_picker      => $picker,
+        },
+        contenttype => 'text/html',
+        component   => 1,
     );
+    # Something exploded
+    return $content if ref $content eq "ARRAY";
+
     return $content if $direct;
     return Trog::Routes::HTML::index( $query, $content, $styles );
 }
 
-sub _templates_in_dir ($path) {
-    my $forms = [];
-    return $forms unless -d $path;
-    opendir( my $dh, $path );
-    while ( my $form = readdir($dh) ) {
-        push( @$forms, $form ) if -f "$path/$form" && $form =~ m/.*\.tx$/;
-    }
-    close($dh);
-    return $forms;
-}
-
 sub _themed_title ($path) {
     return $path unless %Theme::paths;
     return $Theme::paths{$path} ? $Theme::paths{$path} : $path;
@@ -1160,6 +1163,7 @@ sub sitemap ($query) {
     }
 
     if ( $query->{xml} ) {
+        DEBUG("RENDER SITEMAP XML");
         my $sm;
         my $xml_date = time();
         my $fmt      = "xml";
@@ -1245,22 +1249,16 @@ sub sitemap ($query) {
     }
 
     @to_map = sort @to_map unless $is_index;
-    my $styles = _build_themed_styles('sitemap.css');
+    my $styles = ['sitemap.css'];
 
     $query->{title} = "$query->{domain} : Sitemap";
-    my $content = themed_render(
-        'sitemap.tx',
-        {
-            title      => "Site Map",
-            to_map     => \@to_map,
-            is_index   => $is_index,
-            route_type => $route_type,
-            route      => $query->{route},
-        }
-    );
+    $query->{template} = 'sitemap.tx',
+    $query->{to_map} = \@to_map,
+    $query->{is_index} = $is_index,
+    $query->{route_type} = $route_type,
     $query->{etag} = $etag;
 
-    return Trog::Routes::HTML::index( $query, $content, $styles );
+    return Trog::Routes::HTML::index( $query, undef, $styles );
 }
 
 sub _rss ( $query, $subtitle, $posts ) {
@@ -1337,123 +1335,53 @@ sub manual ($query) {
 
     #Fix links from Pod::HTML
     $query->{module} =~ s/\.html$//g if $query->{module};
+    $query->{failure} //= -1;
 
     my $infile = $query->{module} ? "$query->{module}.pm" : 'tCMS/Manual.pod';
     return notfound($query) unless -f "lib/$infile";
     my $content = capture { Pod::Html::pod2html( qw{--podpath=lib --podroot=.}, "--infile=lib/$infile" ) };
 
-    return finish_render(
-        'manual.tx',
+    return Trog::Routes::HTML::index(
         {
             title       => 'tCMS Manual',
-            theme_dir   => $td,
+            theme_dir   => $Trog::Themes::td,
             content     => $content,
-            stylesheets => _build_themed_styles('post.css'),
-            scheme      => $query->{scheme},
-        }
-    );
-}
-
-# Deal with Params which may or may not be arrays
-sub _coerce_array ($param) {
-    my $p = $param || [];
-    $p = [$param] if $param && ( ref $param ne 'ARRAY' );
-    return $p;
-}
-
-sub _build_themed_styles ($style) {
-    my @styles;
-    @styles = ("/styles/$style") if -f "www/styles/$style";
-    my $ts = _themed_style($style);
-    push( @styles, $ts ) if $theme_dir && -f "www/$ts";
-    return \@styles;
-}
-
-sub _build_themed_scripts ($script) {
-    my @scripts = ("/scripts/$script");
-    my $ts      = _themed_script($script);
-    push( @scripts, $ts ) if $theme_dir && -f "www/$ts";
-    return \@scripts;
-}
-
-sub _build_themed_templates ($template) {
-    my @templates = ("/templates/$template");
-    my $ts        = _themed_template($template);
-    push( @templates, $ts ) if $theme_dir && -f "www/$ts";
-    return \@templates;
-}
-
-sub themed_render ( $template, $data ) {
-    state $child_processor = Text::Xslate->new(
-
-        # Prevent a recursive descent.  If the renderer is hit again, just do nothing
-        # XXX unfortunately if the post tries to include itself, it will die.
-        function => {
-            embed => sub {
-                my ( $this_id, $style ) = @_;
-                $style //= 'embed';
-
-                # If instead the style is 'content', then we will only show the content w/ no formatting, and no title.
-                return Text::Xslate::mark_raw(
-                    Trog::Routes::HTML::posts(
-                        { route => "/post/$this_id", style => $style },
-                        sub { },
-                        1
-                    )
-                );
-            },
-        }
-    );
-    state $child_renderer = sub {
-        my ( $template_string, $options ) = @_;
-
-        # If it fails to render, it must be something else
-        my $out = eval { $child_processor->render_string( $template_string, $options ) };
-        return $out ? $out : $template_string;
-    };
-
-    state $processor = Text::Xslate->new(
-        path     => $template_dir,
-        function => {
-            render_it => $child_renderer,
+            template    => 'manual.tx',
+            is_admin    => 1,
+            %$query,
         },
+        undef,
+        ['post.css'],
     );
-
-    state $t_processor = $theme_dir
-      ? Text::Xslate->new(
-        path     => "www/$theme_dir/templates",
-        function => {
-            render_it => $child_renderer,
-        },
-      )
-      : undef;
-
-    return _pick_processor( "templates/$template", $processor, $t_processor )->render( $template, $data );
 }
 
-sub _pick_processor ( $file, $normal, $themed ) {
-    return _template_dir($file) eq $template_dir ? $normal : $themed;
-}
-
-sub _template_dir ($template) {
-    return $theme_dir && -f "www/$theme_dir/$template" ? $theme_dir : $template_dir;
+# basically a file rewrite rule for themes
+sub icon ($query) {
+    my $path = $query->{route};
+    return Trog::FileHandler::serve("$Trog::Themes::td/img/icon/$path");
 }
 
-# Pick appropriate dir based on whether theme override exists
-sub _dir_for_resource ($resource) {
-    return $theme_dir && -f "www/$theme_dir/$resource" ? $theme_dir : '';
-}
+# TODO make statics, abstract gzipped outputting & header handling
+sub rss_style ($query) {
+    $query->{port} = ":$query->{port}" if $query->{port};
+    $query->{rss_css} = Trog::Themes::themed_style("rss.css");
 
-sub _themed_style ($resource) {
-    return _dir_for_resource("styles/$resource") . "/styles/$resource";
+    return Trog::Renderer->render(
+        template    => 'rss-style.tx',
+        contenttype => 'text/xsl',
+        data        => $query,
+        code        => 200,
+    );
 }
 
-sub _themed_script ($resource) {
-    return _dir_for_resource("scripts/$resource") . "/scripts/$resource";
+sub _build_themed_styles ($styles) {
+    my @styles = map { Trog::Themes::themed_style("$_") } @{Trog::Utils::coerce_array($styles)};
+    return \@styles;
 }
 
-sub _themed_template ($resource) {
-    return _dir_for_resource("templates/$resource") . "/templates/$resource";
+sub _build_themed_scripts ($scripts) {
+    my @scripts = map { Trog::Themes::themed_script("$_") } @{Trog::Utils::coerce_array($scripts)};
+    return \@scripts;
 }
 
 sub finish_render ( $template, $vars, %headers ) {
@@ -1464,6 +1392,10 @@ sub finish_render ( $template, $vars, %headers ) {
     $vars->{stylesheets} //= [];
     $vars->{scripts}     //= [];
 
+    # Theme-ize the paths
+    $vars->{stylesheets} = _build_themed_styles($vars->{stylesheets});
+    $vars->{scripts}     = _build_themed_scripts($vars->{scripts});
+
     # Absolute-ize the paths for scripts & stylesheets
     @{ $vars->{stylesheets} } = map { CORE::index( $_, '/' ) == 0 ? $_ : "/$_" } @{ $vars->{stylesheets} };
     @{ $vars->{scripts} }     = map { CORE::index( $_, '/' ) == 0 ? $_ : "/$_" } @{ $vars->{scripts} };
@@ -1474,118 +1406,10 @@ sub finish_render ( $template, $vars, %headers ) {
     $vars->{cachecontrol} //= $Trog::Vars::cache_control{revalidate};
 
     $vars->{code} ||= 200;
+    $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 );
 
-    my $body = exists $vars->{body} ? $vars->{body} : '';
-    if ($template) {
-        $body = themed_render( 'header.tx', $vars );
-        $body .= themed_render( $template,   $vars );
-        $body .= themed_render( 'footer.tx', $vars );
-        $body = encode_utf8($body);
-    }
-
-    #Disallow framing UNLESS we are in embed mode
-    $headers{"Content-Security-Policy"} = qq{frame-ancestors 'none'} unless $vars->{embed};
-
-    my $ct = 'Content-type';
-    my $cc = 'Cache-control';
-    $headers{$ct}              = $vars->{contenttype} // "text/html";
-    $headers{$cc}              = $vars->{cachecontrol} if $vars->{cachecontrol};
-    $headers{'Vary'}           = 'Accept-Encoding';
-    $headers{"Content-Length"} = length($body);
-    $headers{'X-Content-Type-Options'} = 'nosniff';
-    $headers{'X-Frame-Options'} = 'DENY' unless $vars->{embed};
-    $headers{'Referrer-Policy'} = 'no-referrer-when-downgrade';
-
-    #CSP. Yet another layer of 'no mixed content' plus whitelisted execution of remote resources.
-    my $scheme = $vars->{scheme} ? "$vars->{scheme}:" : '';
-    my $sites = $conf->param('security.allow_embeds_from') // '';
-    $headers{'Content-Security-Policy'} .= ";default-src $scheme 'self' 'unsafe-eval' 'unsafe-inline' $sites";
-    $headers{'Content-Security-Policy'} .= ";object-src 'none'";
-
-    # Force https if we are https
-    $headers{'Strict-Transport-Security'} = 'max-age=63072000';
-
-    # We only set etags when users are logged in, cause we don't use statics
-    $headers{'ETag'} = $vars->{etag} if $vars->{etag} && $vars->{user};
-
-    my $skip_render = !$vars->{route} || $vars->{has_query};
-
-    # Time to stash (and cache!) the bodies for public routes, everything else should be fine
-    save_render( $vars, $body, %headers ) unless $vars->{user} || $skip_render;
-
-    #Return data in the event the caller does not support deflate
-    return [ $vars->{code}, [%headers], [$body] ] unless $vars->{deflate};
-
-    #Compress
-    $headers{"Content-Encoding"} = "gzip";
-    my $dfh;
-    IO::Compress::Gzip::gzip( \$body => \$dfh );
-    print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
-    $headers{"Content-Length"} = length($dfh);
-
-    save_render( { route => "$vars->{route}.z", code => $vars->{code} }, $dfh, %headers ) unless $vars->{user} || $skip_render;
-
-    return [ $vars->{code}, [%headers], [$dfh] ];
-}
-
-sub save_render ( $vars, $body, %headers ) {
-    Path::Tiny::path( "www/statics/" . dirname( $vars->{route} ) )->mkpath;
-    my $file = "www/statics/$vars->{route}";
-
-    my $verb = -f $file ? 'Overwrite' : 'Write';
-    DEBUG("$verb static for $vars->{route}");
-    open( my $fh, '>', $file ) or die "Could not open $file for writing";
-    print $fh "HTTP/1.1 $vars->{code} OK\n";
-    foreach my $h ( keys(%headers) ) {
-        print $fh "$h:$headers{$h}\n" if $headers{$h};
-    }
-    print $fh "\n";
-    print $fh $body;
-    close $fh;
-}
-
-# basically a file rewrite rule for themes
-sub icon ($query) {
-    my $path = $query->{route};
-    return Trog::FileHandler::serve("$td/img/icon/$path");
-}
-
-# TODO make statics, abstract gzipped outputting & header handling
-sub rss_style ($query) {
-    $query->{port} = ":$query->{port}" if $query->{port};
-    $query->{rss_css} = _themed_style("rss.css");
-    my $body = encode_utf8(themed_render('rss-style.tx', $query));
-    my %headers = (
-        'Content-Type'   => 'text/xsl',
-        'Content-Length' => length($body),
-        'Cache-Control'  => $Trog::Vars::cache_control{revalidate},
-        'X-Content-Type-Options' => 'nosniff',
-        'Vary'           => 'Accept-Encoding',
-    );
-    return [ 200, [%headers], [$body]] unless $query->{deflate};
-
-    $headers{"Content-Encoding"} = "gzip";
-    my $dfh;
-    IO::Compress::Gzip::gzip( \$body => \$dfh );
-    print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
-    $headers{"Content-Length"} = length($dfh);
-
-    return [ 200, [%headers], [$dfh] ];
-}
-
-sub emojis ($query) {
-    my $file = 'www/scripts/list.min.json';
-    die "Run make prereq-frontend first" unless -f $file;
-
-    my $raw = File::Slurper::read_binary($file);
-    my $emojis = Cpanel::JSON::XS::decode_json($raw);
-    my %categorized;
-    foreach my $emoji (@{$emojis->{emojis}}) {
-        $categorized{$emoji->{category}} //= [];
-        push(@{$categorized{$emoji->{category}}}, $emoji->{emoji});
-    }
-
-    return finish_render('emojis.tx', { %$query, categories => \%categorized, scripts => _build_themed_scripts('emoji.js') });
+    return Trog::Renderer->render( template => $template, data => $vars, contenttype => 'text/html', code => $vars->{code} );
 }
 
 1;

+ 67 - 0
lib/Trog/Themes.pm

@@ -0,0 +1,67 @@
+package Trog::Themes;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use Trog::Vars;
+use Trog::Config;
+
+=head1 Trog::Themes
+
+Utility functions for getting themed paths.
+
+=cut
+
+my $conf         = Trog::Config::get();
+our $template_dir = 'www/templates';
+our $theme_dir    = '';
+$theme_dir = "themes/" . $conf->param('general.theme') if $conf->param('general.theme') && -d "www/themes/" . $conf->param('general.theme');
+our $td = $theme_dir ? "/$theme_dir" : '';
+
+sub template_dir ($template, $content_type, $is_component=0, $is_dir=0) {
+    my $ct = $Trog::Vars::byct{$content_type};
+    my ($mtd, $mtemp) = ("$theme_dir/$ct", "$template_dir/$ct");
+    if ($is_component) {
+        $mtd   .= "/components";
+        $mtemp .= "/components";
+    }
+    if ($is_dir) {
+        return $mtd && -d "www/$mtd/$template" ? $mtd : $mtemp;
+    }
+    return $mtd && -f "www/$mtd/$template" ? $mtd : $mtemp;
+}
+
+# Pick appropriate dir based on whether theme override exists
+sub _dir_for_resource ($resource) {
+    return $theme_dir && -f "www/$theme_dir/$resource" ? $theme_dir : '';
+}
+
+sub themed_style ($resource) {
+    return _dir_for_resource("styles/$resource") . "/styles/$resource";
+}
+
+sub themed_script ($resource) {
+    return _dir_for_resource("scripts/$resource") . "/scripts/$resource";
+}
+
+sub themed_template ($resource) {
+    return _dir_for_resource("templates/$resource") . "/templates/$resource";
+}
+
+sub templates_in_dir ($path, $ct, $is_component=0) {
+    $path = template_dir($path, $ct, $is_component, 1)."/$path";
+    my $forms = [];
+    return $forms unless -d $path;
+    opendir( my $dh, $path );
+    while ( my $form = readdir($dh) ) {
+        push( @$forms, $form ) if -f "$path/$form" && $form =~ m/.*\.tx$/;
+    }
+    close($dh);
+    return $forms;
+}
+
+
+1;

+ 7 - 0
lib/Trog/Utils.pm

@@ -6,6 +6,13 @@ use warnings;
 no warnings 'experimental';
 use feature qw{signatures};
 
+# Deal with Params which may or may not be arrays
+sub coerce_array ($param) {
+    my $p = $param || [];
+    $p = [$param] if $param && ( ref $param ne 'ARRAY' );
+    return $p;
+}
+
 sub strip_and_trunc ($s) {
     return unless $s;
     $s =~ s/<[^>]*>//g;

+ 11 - 4
lib/Trog/Vars.pm

@@ -8,14 +8,21 @@ our $CHUNK_SEP  = 'tCMSep666YOLO42069';
 our $CHUNK_SIZE = 1024000;
 
 our %content_types = (
-    plain => "text/plain;",
-    html  => "text/html; charset=UTF-8",
-    json  => "application/json;",
-    blob  => "application/octet-stream;",
+    text  => "text/plain",
+    html  => "text/html",
+    json  => "application/json",
+    blob  => "application/octet-stream",
+    xml   => "text/xml",
+    xsl   => "text/xsl",
+    css   => "text/css",
 );
 
+our %byct = reverse %Trog::Vars::content_types;
+
 our %cache_control = (
     revalidate => "no-cache, max-age=0",
     nocache    => "no-store",
     static     => "public, max-age=604800, immutable",
 );
+
+1;

+ 13 - 0
lib/tCMS/Manual.pod

@@ -64,3 +64,16 @@ Stylesheets are included after the mainline ones, so that your styles will overr
 
 You will want your theme icon to be in the img/icon directory, make sure it's an SVG named 'favicon.svg'.
 From there, run bin/favicon_mongler.pl $PATH_TO_YOUR_FAVICON_SVG
+
+=head2 Renderers
+
+Each routing module calls out to the appropriate rendering modules (based on content-type) to build output based on the templates either in www/templates or in your theme's templates/ dir.
+This templates dir is further subdivided by content-type.
+
+=head3 Components, Forms, Footers and Headers
+
+Within that we have a components subdirectory for UI components intended to be included within other templates.
+The idea is that these are not dynamic, but can be statically compiled to strings for faster template builds.
+Things like emoji pickers, modals and other stuff you might populate later with an XHR are good candidates for being a component.
+Each component will have it's own module encapsulating it in the Trog::Component namespace.
+They must have a render() method which returns a string processed by Trog::Renderer->render().

+ 0 - 21
www/scripts/emoji.js

@@ -1,21 +0,0 @@
-function switchEmojiDropDown (e) {
-    var panes = document.querySelectorAll('.mojipane');
-    for (pane of panes) {
-        pane.style.display="none";
-    }
-
-    var theId = e.target.value;
-    var el = document.getElementById(theId);
-    if ( el === null ) {
-        console.log('no such element '+el);
-        return;
-    }
-    el.style.display = 'inline-block';
-}
-
-window.onload = function () {
-    var cat = document.getElementById('emoji-category');
-    cat.addEventListener("change", switchEmojiDropDown);
-    const ev = new Event("change");
-    cat.dispatchEvent(ev);
-}

+ 18 - 1
www/styles/screen.css

@@ -99,7 +99,6 @@ audio, video {
  display: table;
 }
 #submissions {
- width: 20%;
  display: table-cell;
 }
 #stories {
@@ -487,4 +486,22 @@ a.tag {
     width: 30vw;
     text-align: center;
     padding: .25rem;
+    line-height: 125%;
+}
+.emoji {
+    cursor: pointer;
+    font-size: 100%;
+    padding-left:.25rem;
+}
+.emoji:hover {
+    font-size: 120%;
+    text-shadow: 0 0 .3rem gold;
+}
+.posteditor {
+    width: 100%;
+}
+#emoji_picker_modal {
+    position: fixed;
+    top: 3rem;
+    right: 3rem;
 }

+ 0 - 0
www/templates/avatars.tx → www/templates/css/avatars.tx


+ 0 - 18
www/templates/emojis.tx

@@ -1,18 +0,0 @@
-<div id="emoji-container">
-    <select id="emoji-category" class="mojitab" >
-    : for $categories.keys() -> $category {
-        <option value="<: $category :>"><: $category :></option>
-    : }
-    </select>
-    <div id="emojis">
-    : for $categories.keys() -> $category {
-        <span id="<: $category :>" class="mojipane" style="display:none">
-    :   for $categories[$category] -> $emoji {
-            <span>
-            <: $emoji :>
-            </span>
-    :   }
-        </span>
-    : }
-    </div>
-</div>

+ 3 - 0
www/templates/html/500.tx

@@ -0,0 +1,3 @@
+: include "components/header.tx";
+: include "index.tx";
+: include "components/footer.tx"

+ 0 - 0
www/templates/categories.tx → www/templates/html/categories.tx


+ 0 - 0
www/templates/acls.tx → www/templates/html/components/acls.tx


+ 1 - 1
www/templates/badrequest.tx → www/templates/html/components/badrequest.tx

@@ -1,3 +1,3 @@
-400 Bad Request
+<h1>400 Bad Request</h1>
 <br /><br />
 See the SiteMap <a href="/sitemap">here</a> as to the valid ways to communicate with this website.

+ 0 - 2
www/templates/config.tx → www/templates/html/components/config.tx

@@ -1,5 +1,3 @@
-: include "sysbar.tx";
-: include "jsalert.tx";
 <div id="backoffice">
 <p class="title">
  General settings:

+ 0 - 0
www/templates/default.tx → www/templates/html/components/default.tx


+ 0 - 0
www/templates/edit_foot.tx → www/templates/html/components/edit_foot.tx


+ 2 - 2
www/templates/edit_head.tx → www/templates/html/components/edit_head.tx

@@ -1,8 +1,8 @@
 <br />
 : if ( !!$post.addpost ) {
     <a style="cursor:pointer" onclick="switchMenu('submissions')">[Add Post]</a><hr />
-    <div id="submissions" style="display:none;">
+    <div id="submissions" style="display:none;" class="posteditor">
 : } else {
     <a style="display: inline-block;cursor:pointer;" onclick="switchMenu('<: $post.id :>-<: $post.version :>');">[Edit]</a>
-    <div id="<: $post.id :>-<: $post.version :>" style="display:none;">
+    <div id="<: $post.id :>-<: $post.version :>" style="display:none;" class="posteditor">
 :}

+ 78 - 0
www/templates/html/components/emojis.tx

@@ -0,0 +1,78 @@
+<div id="emoji-container">
+    <select id="emoji-category" class="mojitab" >
+    : for $categories.keys() -> $category {
+        <option value="<: $category :>"><: $category :></option>
+    : }
+    </select>
+    <div id="emojis">
+    : for $categories.keys() -> $category {
+        <span id="<: $category :>" class="mojipane" style="display:none">
+    :   for $categories[$category] -> $emoji {
+            <span class="emoji"><: $emoji :></span>
+    :   }
+        </span>
+    : }
+    </div>
+</div>
+<script>
+class TcmsEmojiPicker {
+    constructor () {
+        this.clearBinds();
+    }
+    binds = [];
+
+    switchEmojiDropDown (e) {
+        var panes = document.querySelectorAll('.mojipane');
+        for (var pane of panes) {
+            pane.style.display="none";
+        }
+
+        var theId = e.target.value;
+        var el = document.getElementById(theId);
+        if ( el === null ) {
+            console.log('no such element '+el);
+            return;
+        }
+        el.style.display = 'inline-block';
+    }
+
+    emitEmoji (e) {
+        var emoji = e.target.innerText;
+        // Emit the emoji to all the bound components.
+        for (var bind of this.binds) {
+            if (!bind) {
+                continue;
+            }
+            bind.value = bind.value+emoji;
+        }
+    }
+
+    addBinds (elements) {
+        for (var element of elements) {
+            this.binds.push(element);
+        }
+    }
+
+    clearBinds () {
+        this.binds = [];
+    }
+};
+
+addEventListener("load", function () {
+    window.emojiPicker = new TcmsEmojiPicker();
+
+    var cat = document.getElementById('emoji-category');
+    cat.addEventListener("change", function (e) { window.emojiPicker.switchEmojiDropDown(e) });
+    const ev = new Event("change");
+    cat.dispatchEvent(ev);
+
+    // Setup the listeners on the emojis themselves
+    var mojis = document.querySelectorAll('.emoji');
+    for (emoji of mojis) {
+        emoji.addEventListener("click", function (e) { window.emojiPicker.emitEmoji(e) } );
+    }
+
+    const emojiEvent = new Event("emojiComponentReady");
+    window.dispatchEvent(emojiEvent);
+});
+</script>

+ 0 - 0
www/templates/footbar.tx → www/templates/html/components/footbar.tx


+ 0 - 0
www/templates/footer.tx → www/templates/html/components/footer.tx


+ 0 - 0
www/templates/forbidden.tx → www/templates/html/components/forbidden.tx


+ 0 - 0
www/templates/form_common.tx → www/templates/html/components/form_common.tx


+ 0 - 0
www/templates/forms/blog.tx → www/templates/html/components/forms/blog.tx


+ 0 - 0
www/templates/forms/file.tx → www/templates/html/components/forms/file.tx


+ 0 - 0
www/templates/forms/microblog.tx → www/templates/html/components/forms/microblog.tx


+ 0 - 0
www/templates/forms/profile.tx → www/templates/html/components/forms/profile.tx


+ 0 - 0
www/templates/forms/series.tx → www/templates/html/components/forms/series.tx


+ 0 - 0
www/templates/header.tx → www/templates/html/components/header.tx


+ 0 - 0
www/templates/leftbar.tx → www/templates/html/components/leftbar.tx


+ 0 - 1
www/templates/manual.tx → www/templates/html/components/manual.tx

@@ -1,4 +1,3 @@
-: include "sysbar.tx";
 <div id="backoffice">
 <: $content | mark_raw :>
 </div>

+ 0 - 0
www/templates/midtitle.tx → www/templates/html/components/midtitle.tx


+ 1 - 1
www/templates/notfound.tx → www/templates/html/components/notfound.tx

@@ -1,3 +1,3 @@
-404 not found
+<h1>404 not found</h1>
 <br /><br />
 No such resource <: $path :>

+ 0 - 0
www/templates/paginator.tx → www/templates/html/components/paginator.tx


+ 0 - 0
www/templates/post_tags.tx → www/templates/html/components/post_tags.tx


+ 0 - 0
www/templates/post_title.tx → www/templates/html/components/post_title.tx


+ 22 - 11
www/templates/posts.tx → www/templates/html/components/posts.tx

@@ -1,21 +1,32 @@
 : if ( $can_edit ) {
     <script type="text/javascript" src="/scripts/post.js"></script>
+    <div id="emoji_picker_modal" class="modal" style="display:none;">
+    <: $emoji_picker | mark_raw :>
+    </div>
     : if (!$direct) {
         : if ($to) {
             : include "jsalert.tx";
         : }
-        <script type="text/javascript" src="/scripts/fgEmojiPicker.js"></script>
         <script type="text/javascript">
-        new FgEmojiPicker({
-            trigger: ['button.emojiPicker'],
-            position: ['bottom'],
-            dir: `/scripts/`,
-            preFetch: true,
-            emit(obj, triggerElement) {
-                const emoji = obj.emoji;
-                document.querySelector('textarea').value += emoji;
-            }
-        });
+            addEventListener("load", function () {
+                var buttons = document.querySelectorAll('.emojiPicker');
+                for (button of buttons) {
+                    // Make the emoji picker appear.
+                    button.addEventListener('click', function (e) {
+                        switchMenu('emoji_picker_modal');
+                    });
+                }
+                var mojis = document.querySelectorAll('.emoji');
+                for (emoji of mojis) {
+                    emoji.addEventListener('click', function (e) {
+                        switchMenu('emoji_picker_modal');
+                    });
+                }
+
+            });
+            addEventListener("emojiComponentReady", function () {
+                emojiPicker.addBinds(document.querySelectorAll('textarea.cooltext'));
+            });
         </script>
     : }
     <div class="postedit">

+ 0 - 0
www/templates/preview.tx → www/templates/html/components/preview.tx


+ 0 - 0
www/templates/rightbar.tx → www/templates/html/components/rightbar.tx


+ 0 - 0
www/templates/sitemap.tx → www/templates/html/components/sitemap.tx


+ 0 - 0
www/templates/tags.tx → www/templates/html/components/tags.tx


+ 0 - 0
www/templates/title.tx → www/templates/html/components/title.tx


+ 0 - 0
www/templates/toolong.tx → www/templates/html/components/toolong.tx


+ 0 - 0
www/templates/topbar.tx → www/templates/html/components/topbar.tx


+ 0 - 2
www/templates/totp.tx → www/templates/html/components/totp.tx

@@ -1,5 +1,3 @@
-: include "sysbar.tx";
-: include "jsalert.tx";
 <div id="backoffice">
     Use the following URI, or scan the below QR code into your authenticator Application of choice:<br />
     <: $uri :>

+ 0 - 0
www/templates/embed.tx → www/templates/html/embed.tx


+ 0 - 0
www/templates/footers/README.md → www/templates/html/footers/README.md


+ 0 - 0
www/templates/headers/README.md → www/templates/html/headers/README.md


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

@@ -1,4 +1,9 @@
-<div id="topkek">
+<: $header | mark_raw :>
+: 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 :>
     </div>
@@ -22,6 +27,7 @@
     <a href="/login?to=<: $route :>" title="Login" class="topbar usericon" style="font-size: 1.5rem;">🔑</a>
     : }
 </div>
+: }
 <div id="littlemenu">
 </div>
 <div id="kontainer">
@@ -35,6 +41,7 @@
         <: $rightbar | mark_raw :>
     </div>
 </div>
+
 <div id="footbar">
     <: $footbar | mark_raw :>
 </div>
@@ -43,3 +50,4 @@
 <img src="/img/icon/favicon.svg" style="height:2rem;" />
 </a>
 : }
+<: $footer | mark_raw :>

+ 0 - 0
www/templates/jsalert.tx → www/templates/html/jsalert.tx


+ 2 - 0
www/templates/login.tx → www/templates/html/login.tx

@@ -1,3 +1,4 @@
+: include "components/header.tx";
 <div id="login">
     : include "jsalert.tx";
     <div>
@@ -28,3 +29,4 @@
       <input type="submit" id="maximumGo" value="<: $btnmsg :>"></input>
     </form>
 </div>
+: include "components/footer.tx";

+ 0 - 0
www/templates/notconfigured.tx → www/templates/html/notconfigured.tx


+ 0 - 1
www/templates/sysbar.tx → www/templates/html/sysbar.tx

@@ -8,4 +8,3 @@
         <a href="/logout"      title="Logout"        class="topbar">🚪</a>
     </span>
 </div>
-<div style="height:3rem;">hidon</div>

+ 0 - 0
www/templates/robots.tx → www/templates/text/robots.tx


+ 0 - 0
www/templates/rss-style.tx → www/templates/xsl/rss-style.tx