Prechádzať zdrojové kódy

Call components from templates rather than wiring them up in routes

Trog::Component::EmojiPicker was the only member of a namespace the manual
already describes as a framework, and it was wired up by hand in four places:
a use statement, an unconditional render, a PSGI-arrayref sniff to detect
failure, and a template var threaded through posts().

Add Trog::Component, a dispatcher injected into Xslate as component() next to
render_it(), so a template asks for what it wants:

    <: component('EmojiPicker') :>
    <: component('Gallery', { album => $post.id, cols => 3 }) :>

The name is one flat level under Trog::Component, word characters only.  It
becomes a path handed to require(), so that check is what stops a template
naming its way out of Trog/Component/.  Output comes back marked raw, and a
component that fails dies rather than returning a triplet, which is what saves
every call site from the 'return $it if ref $it eq ARRAY' dance.

Dying is necessary but not sufficient.  Xslate catches an exception thrown out
of a template function, hands it to its warn handler, and renders on with an
empty string where the call was, so a broken component would vanish from the
page with only a line on stderr to say so.  The failure is therefore also
recorded in $Trog::Component::error, which Trog::Renderer::Base localises per
render and checks, turning it into the 500 it deserves.

The picker now renders only for editors, since the call sits inside posts.tx's
can_edit block.  It used to be rendered on every posts() call and thrown away
for anonymous readers, who also ate an outright die when the emoji list was
missing.

Also give EmojiPicker the File::Slurper it has always called and never loaded.
It worked only because Trog::Routes::HTML drags in Trog::DataModule, which does;
loaded on its own it died with an undefined subroutine rather than the intended
"run make prereq-frontend" message.

Readme.md carries unrelated edits that were already in the working tree before
this change -- the content type list and the data sources section.

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
1dc3a86964

+ 33 - 9
Readme.md

@@ -48,17 +48,20 @@ Content templates are modular.
 Add in a template to www/templates/html/components/forms which describe the content *and* how to edit it.
 Our post data storage being JSON allows us the flexibility to have any kind of meta associated with posts, so go hog wild.
 
+You describe the type of data provided in a sidecar JSON file alongside the form's template.
+There's a wizard you can use to build these!
+
+From there you make Series of the content type you want; tag the series with 'topbar' if you want it to show up in the top links.
+
 Currently supported:
 * Microblogs
 * Blogs
 * Files (Video/Audio/Images/Other)
 * About Pages
-* Post Series
 * Presentations
-
-Planned development:
-* LaTeX
-* Test Plans / Issues (crossover with App::Prove::Elasticsearch)
+Virtualization Data:
+* Hypervisors
+* Guests
 
 Embedding Posts within other Posts
 ==================================
@@ -81,12 +84,33 @@ These will be added as classes to the embedded post, so you can theme this appro
 
 Data Models
 ===========
+Posts are normally stored as a file somewhere.
+
 * DUMMY - A JSON blob.  Used for testing mostly, but could be handy for very small sites.
-* Flat File - Pretty much the tCMS1 data model; a migration script is forthcoming
+* Flat File - Pretty much the tCMS1 data model, but with an SQLite index bolted on.
+
+Data Sources
+============
+Sometimes you want to consider something else authoritative that isn't a datamodel under our control.
+
+* Virt - Talk to a libvirt HV to list guests.
+
+Components
+==========
+Sometimes you re-use a template a lot. Sometimes it also needs special handling.
+This is when you write a 'component'.  Example:
+
+Trog::Component::EmojiPicker -> renders www/templates/html/components/emojis.tx
+
+A component is a module in the Trog::Component namespace with a render(%args)
+method returning a string.  Templates call it by name:
+
+    <: component('EmojiPicker') :>
+    <: component('Gallery', { album => $post.id, cols => 3 }) :>
 
-Planned Development:
-* Elasticsearch - Documents are ideally indexed in a search engine, should be nice and fast too.
-* Git - More for the APE crossover
+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.
 
 Ideas to come:
 =============

+ 132 - 0
lib/Trog/Component.pm

@@ -0,0 +1,132 @@
+package Trog::Component;
+
+use v5.36;
+use re '/aa';
+
+use Text::Xslate ();
+
+=head1 Trog::Component
+
+Dispatcher for the UI components living under the Trog::Component namespace.
+
+Injected into the template processor as component() by Trog::Renderer::Base, so
+templates ask for what they want rather than having a route thread a rendered
+string into their data hash:
+
+    <: component('EmojiPicker') :>
+    <: component('Gallery', { album => $post.id, cols => 3 }) :>
+
+Deliberately depends on nothing but Text::Xslate.  Trog::Renderer::Base uses
+this module, and components use Trog::Renderer, so anything heavier here would
+close a compile-time load cycle.  Components are loaded lazily, at call time,
+for the same reason.
+
+=head1 Termination Conditions
+
+Dies if the component can't be named, found, loaded or rendered.  There is no
+sensible half-rendered page.
+
+Dying is necessary but not sufficient: Xslate catches exceptions thrown out of a
+function call, hands them to its warn handler, and carries on with an empty
+string where the call was -- so a broken component would otherwise vanish from
+the page with nothing but a line on stderr to say so.  The failure is therefore
+also recorded in $Trog::Component::error, which Trog::Renderer::Base::render()
+localises and checks, turning it into the 500 it deserves.
+
+=head1 FUNCTIONS
+
+=cut
+
+# A component whose template asks for itself would otherwise recurse until the
+# stack gives out, which says nothing about which component did it.
+our $MAX_DEPTH = 10;
+our $depth     = 0;
+
+# The first component failure of the current render.  See above -- dying alone
+# does not stop an Xslate render.  Always read through a local() in the caller.
+our $error;
+
+=head2 component($name, $args) = STRING
+
+Render the named component, marked raw so that callers need no C<| mark_raw>.
+
+$name is one flat namespace level under Trog::Component -- word characters only.
+It becomes a path handed to require(), so this is what stops a template naming
+its way out of Trog/Component/.
+
+$args is an optional HASHREF, flattened into the component's render().  Nullary
+components simply ignore it.
+
+=cut
+
+sub component ( $name, $args = {} ) {
+    my ( $out, $err );
+    {
+        local $@;
+        $out = eval { _component( $name, $args ) };
+        $err = $@;
+    }
+    return $out if defined $out;
+
+    # Record as well as rethrow: the die on its own gets swallowed by Xslate.
+    # First failure wins, as the ones after it tend to be consequences of it.
+    # The throw uses our own error rather than $error, so a direct caller is
+    # told what *it* did even when something earlier left a record behind.
+    $err ||= "Component '$name' failed for no stated reason\n";
+    $error //= $err;
+    die $err;
+}
+
+sub _component ( $name, $args ) {
+    my $module = _load($name);
+
+    die "Component '$name' recursed more than $MAX_DEPTH deep\n" if $depth >= $MAX_DEPTH;
+    local $depth = $depth + 1;
+
+    $args = {} unless ref $args eq 'HASH';
+
+    my $out;
+    local $@;
+    eval {
+        $out = $module->can('render')->(%$args);
+        1;
+    } or do {
+        die "Component '$name' failed to render: $@";
+    };
+
+    # Trog::Renderer::render() answers failure with a PSGI triplet from _yeet(),
+    # which is meaningless halfway through a template.  Catching it here is what
+    # saves every call site from the 'return $it if ref $it eq ARRAY' dance.
+    die "Component '$name' returned a " . ref($out) . " rather than a string\n" if ref $out;
+    die "Component '$name' rendered nothing\n"                                  if !defined $out;
+
+    return Text::Xslate::mark_raw($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.
+sub _load ($name) {
+    state %loaded;
+
+    die "Component name must be given\n"   unless defined $name && length $name;
+    die "Invalid component name '$name'\n" unless $name =~ m/^[A-Za-z][A-Za-z0-9_]*$/;
+
+    my $module = "Trog::Component::$name";
+    return $module if $loaded{$module};
+
+    local $@;
+    eval {
+        require "Trog/Component/$name.pm";    ## no critic (RequireBarewordIncludes) -- $name is validated above
+        1;
+    } or do {
+        die "Could not load component '$name': $@";
+    };
+
+    die "Component '$name' has no render()\n" unless $module->can('render');
+
+    $loaded{$module} = 1;
+    return $module;
+}
+
+1;

+ 6 - 3
lib/Trog/Component/EmojiPicker.pm

@@ -3,6 +3,7 @@ package Trog::Component::EmojiPicker;
 use v5.36;
 use re '/aa';
 
+use File::Slurper ();
 use JSON::MaybeXS;
 use Trog::Renderer;
 
@@ -18,9 +19,11 @@ puts it there.
 
 =head1 FUNCTIONS
 
-=head2 render() = STRING
+=head2 render(%args) = STRING
 
-Render the picker as an HTML component.
+Render the picker as an HTML component.  Reached from templates as
+C<< <: component('EmojiPicker') :> >>; takes no arguments, and ignores any it
+is handed.
 
 The emoji list ships as a flat array, which is no use to a picker with tabs, so
 it gets bucketed by category on the first call and memoized thereafter -- the
@@ -31,7 +34,7 @@ to be composed into a page by their caller.
 
 =cut
 
-sub render () {
+sub render (%args) {
     state %categorized;
 
     if ( !%categorized ) {

+ 19 - 2
lib/Trog/Renderer/Base.pm

@@ -7,6 +7,7 @@ use Encode qw{encode_utf8};
 use IO::Compress::Gzip;
 
 use Text::Xslate;
+use Trog::Component ();
 use Trog::Themes;
 use Trog::Config;
 use Time::HiRes qw{tv_interval};
@@ -15,7 +16,7 @@ use Time::HiRes qw{tv_interval};
 
 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.
+Sets up the functions which must be present for all templates: render_it, for rendering dynamic template strings coming from a post, and component, for pulling in a Trog::Component by name.
 
 =cut
 
@@ -72,6 +73,11 @@ sub render (%options) {
         path     => \@template_dirs,
         function => {
             render_it => $options{child_renderer},
+
+            # A plain package sub rather than a closure, so the \%renderers
+            # memoization here cannot bake in whatever the first caller for this
+            # path happened to pass -- which it can, and does, for render_it.
+            component => \&Trog::Component::component,
         },
     );
 
@@ -85,7 +91,18 @@ sub render (%options) {
     # a good theme template existed.  And the condition itself, -f $t || -s $t,
     # short-circuited on -f, so an empty file passed, while a *directory* passed
     # on -s returning its size.
-    my $body = eval { encode_utf8( $renderers{$renderer_key}->render( $options{template}, $options{data} ) ) };
+    #
+    # $Trog::Component::error is how a failed component() call reaches us at all:
+    # Xslate catches an exception thrown out of a template function, warns, and
+    # renders on with an empty string in its place, so the render below succeeds
+    # with a hole in it.  Localised here so that a nested render (a component
+    # rendering its own template) doesn't answer for its parent's failure.
+    my $body = eval {
+        local $Trog::Component::error;
+        my $out = encode_utf8( $renderers{$renderer_key}->render( $options{template}, $options{data} ) );
+        die $Trog::Component::error if $Trog::Component::error;
+        $out;
+    };
     die "Could not render template '$options{template}' (searched @template_dirs): $@" unless defined $body;
 
     # Users can supply a post_processor to futz with the output (such as with minifiers) if they wish.

+ 0 - 6
lib/Trog/Routes/HTML.pm

@@ -38,8 +38,6 @@ use Trog::Themes;
 use Trog::Renderer;
 use Trog::Email;
 
-use Trog::Component::EmojiPicker;
-
 our $landing_page = 'default.tx';
 our $htmltitle    = 'title.tx';
 our $midtitle     = 'midtitle.tx';
@@ -1540,9 +1538,6 @@ sub posts ( $query, $direct = 0 ) {
 
     $query->{author} = $query->{primary_post}{user} // $posts[0]{user};
 
-    my $picker = Trog::Component::EmojiPicker::render();
-    return $picker if ref $picker eq 'ARRAY';
-
     # Resolve each post's declared relations.  One cache for the whole page, so
     # 25 invoices don't mean 25 scans of every entities post.
     my %relation_cache;
@@ -1595,7 +1590,6 @@ sub posts ( $query, $direct = 0 ) {
             footers           => $footers,
             years             => [ reverse( $oldest_year .. $now_year ) ],
             months            => [ 0 .. 11 ],
-            emoji_picker      => $picker,
             embed             => $query->{embed},
             nochrome          => $query->{nochrome},
         },

+ 10 - 1
lib/tCMS/Manual.pod

@@ -75,4 +75,13 @@ Within that we have a components subdirectory for UI components intended to be i
 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().
+They must have a render(%args) method which returns a string processed by Trog::Renderer->render().
+On failure they die rather than returning a PSGI triplet, as there is nowhere to put one halfway through a page.
+
+Templates pull them in by name with the component() function, which Trog::Renderer::Base injects into the template processor alongside render_it():
+
+    <: component('EmojiPicker') :>
+    <: component('Gallery', { album => $post.id, cols => 3 }) :>
+
+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.

+ 217 - 0
t/Trog-Component.t

@@ -0,0 +1,217 @@
+use strict;
+use warnings;
+
+use Test::More;
+use Test::Fatal qw{exception};
+use FindBin;
+
+use lib "$FindBin::Bin/../lib";
+
+use Text::Xslate ();
+
+require_ok('Trog::Component') or BAIL_OUT("Can't find SUT");
+
+# Fixtures, registered in %INC so that _load()'s require() is satisfied without
+# anything landing on disk in the real lib tree.
+BEGIN {
+    $INC{'Trog/Component/TestFixture.pm'}  = 1;
+    $INC{'Trog/Component/TestSilent.pm'}   = 1;
+    $INC{'Trog/Component/TestTriplet.pm'}  = 1;
+    $INC{'Trog/Component/TestBoom.pm'}     = 1;
+    $INC{'Trog/Component/TestRecurse.pm'}  = 1;
+    $INC{'Trog/Component/TestNoRender.pm'} = 1;
+}
+
+our @ARGS_SEEN;
+
+{
+
+    package Trog::Component::TestFixture;
+    sub render { @ARGS_SEEN = @_; return '<b>fixture</b>' }
+
+    package Trog::Component::TestSilent;
+    sub render { return undef }
+
+    package Trog::Component::TestTriplet;
+    sub render { return [ 500, [], ['boom'] ] }
+
+    package Trog::Component::TestBoom;
+    sub render { die "kaboom\n" }
+
+    package Trog::Component::TestRecurse;
+    sub render { return Trog::Component::component('TestRecurse') }
+
+    package Trog::Component::TestNoRender;
+    sub nope { 1 }
+}
+
+subtest 'name validation' => sub {
+
+    # Every one of these reaches require() as a path if it gets past the check.
+    my %bad = (
+        'traversal'     => '../../etc/passwd',
+        'separator'     => 'Foo/Bar',
+        'package sep'   => 'Foo::Bar',
+        'leading digit' => '9Lives',
+        'empty'         => '',
+        'space'         => 'Emoji Picker',
+        'dot'           => 'Foo.pm',
+        'nul'           => "Foo\0Bar",
+    );
+
+    foreach my $case ( sort keys %bad ) {
+        like(
+            exception { Trog::Component::component( $bad{$case} ) },
+            qr/component name/i,
+            "$case rejected before anything is loaded"
+        );
+    }
+
+    like( exception { Trog::Component::component(undef) }, qr/component name/i, "undef rejected" );
+
+    # Nothing above should have made it as far as %INC.
+    is( scalar( grep { m{^Trog/Component/} && !m{^Trog/Component/Test} } keys %INC ), 0, "no stray component was loaded" );
+};
+
+subtest 'loading' => sub {
+    like(
+        exception { Trog::Component::component('NoSuchComponentHere') },
+        qr/NoSuchComponentHere/,
+        "unknown component dies naming the component"
+    );
+
+    like(
+        exception { Trog::Component::component('TestNoRender') },
+        qr/no render/i,
+        "a module without render() is not a component"
+    );
+};
+
+subtest 'render' => sub {
+    local @ARGS_SEEN = ();
+
+    my $out = Trog::Component::component('TestFixture');
+    is( "$out", '<b>fixture</b>', "component output comes back" );
+    isa_ok( $out, 'Text::Xslate::Type::Raw', "output is marked raw so callers need no mark_raw" );
+    is_deeply( \@ARGS_SEEN, [], "no args passed means render() sees none" );
+
+    Trog::Component::component( 'TestFixture', { album => 'holiday', cols => 3 } );
+    is_deeply( {@ARGS_SEEN}, { album => 'holiday', cols => 3 }, "args hash is flattened into render()" );
+
+    Trog::Component::component( 'TestFixture', 'not a hashref' );
+    is_deeply( \@ARGS_SEEN, [], "a non-hashref second arg is ignored rather than fatal" );
+};
+
+subtest 'failure modes' => sub {
+
+    # Trog::Renderer::render() answers failure with a PSGI triplet, which is
+    # meaningless halfway through a template.
+    like(
+        exception { Trog::Component::component('TestTriplet') },
+        qr/TestTriplet.*ARRAY/,
+        "a PSGI triplet is caught rather than leaking into the page"
+    );
+
+    like(
+        exception { Trog::Component::component('TestSilent') },
+        qr/rendered nothing/,
+        "an undef render is a failure, not an empty component"
+    );
+
+    like(
+        exception { Trog::Component::component('TestBoom') },
+        qr/TestBoom.*kaboom/s,
+        "a component's own die is re-thrown naming the component"
+    );
+};
+
+subtest 'recursion guard' => sub {
+    no warnings qw{once};
+    local $Trog::Component::MAX_DEPTH = 5;
+
+    my $err = exception { Trog::Component::component('TestRecurse') };
+    like( $err, qr/recursed more than 5 deep/, "self-referential component dies bounded" );
+
+    is( $Trog::Component::depth, 0, "depth unwinds after the die" );
+};
+
+subtest 'as an xslate function' => sub {
+
+    # Same shape as the injection in Trog::Renderer::Base::render().
+    my $tx = Text::Xslate->new( function => { component => \&Trog::Component::component } );
+
+    is(
+        $tx->render_string( q{<: component('TestFixture') :>}, {} ),
+        '<b>fixture</b>',
+        "markup survives unescaped without | mark_raw"
+    );
+
+    is(
+        $tx->render_string( q{<: component('TestFixture', { cols => 3 }) :>}, {} ),
+        '<b>fixture</b>',
+        "the args form parses in a template"
+    );
+};
+
+subtest 'a failure inside a template is not swallowed' => sub {
+    no warnings qw{once};
+
+    my $tx = Text::Xslate->new( function => { component => \&Trog::Component::component } );
+
+    # Xslate catches the die, warns, and renders on with a hole where the call
+    # was -- which is exactly why $error exists.  Muffle the warning so the test
+    # output stays readable.
+    my $render = sub {
+        my ($template) = @_;
+        local $Trog::Component::error;
+        local $SIG{__WARN__} = sub { };
+        my $out = $tx->render_string( $template, {} );
+        return ( $out, $Trog::Component::error );
+    };
+
+    my ( $out, $err ) = $render->(q{before<: component('../../etc/passwd') :>after});
+    is( $out, 'beforeafter', "Xslate really does render on past a dead component" );
+    like( $err, qr/component name/i, "...and the failure is recorded for the renderer to find" );
+
+    ( $out, $err ) = $render->(q{<: component('TestBoom') :>});
+    like( $err, qr/TestBoom/, "a component's own death is recorded" );
+
+    ( $out, $err ) = $render->(q{<: component('TestFixture') :>});
+    is( $err, undef, "a good render leaves no error behind" );
+
+    # Trog::Renderer::Base localises $error per render, so a nested render
+    # cannot inherit a stale one.
+    ( $out, $err ) = $render->(q{<: component('TestBoom') :><: component('TestFixture') :>});
+    like( $err, qr/TestBoom/, "the first failure wins, not the last" );
+};
+
+subtest 'through the real renderer' => sub {
+    require Trog::Renderer::Base;
+
+    # posts.tx is the live call site; render it as an editor with no posts and
+    # the EmojiPicker component has to come back through the whole stack.
+    my $body = eval {
+        Trog::Renderer::Base::render(
+            template    => 'posts.tx',
+            contenttype => 'text/html',
+            component   => 1,
+            data        => {
+                can_edit =>  1,
+                direct   =>  1,
+                failure  => -1,
+                posts    => [],
+                forms    => [],
+            },
+        );
+    };
+    my $err = $@;
+
+  SKIP: {
+        skip( "no emoji list on disk -- run make prereq-frontend", 2 )
+          if $err && $err =~ m/list\.min\.json|prereq-frontend/;
+        is( $err, '', "posts.tx renders" ) or diag($err);
+        like( $body, qr/id="emoji-container"/, "component('EmojiPicker') reached the page" );
+    }
+};
+
+done_testing();

+ 2 - 2
t/Trog-Routes-HTML-series-lifecycle.t

@@ -102,8 +102,8 @@ BEGIN {
     # and a symlink would land the generated post type in the real repo.
     _copy_tree( "$REPO/www/templates", "$ROOT/www/templates" );
 
-    # posts() renders the emoji picker unconditionally, and EmojiPicker dies
-    # outright if this file is missing.  Only category and emoji are read.
+    # posts.tx pulls in the EmojiPicker component for an editor, and EmojiPicker
+    # dies outright if this file is missing.  Only category and emoji are read.
     open( my $fh, '>', "$ROOT/www/scripts/list.min.json" ) or die $!;
     print {$fh} '{"emojis":[{"category":"Smileys","emoji":"X"}]}';
     close $fh;

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

@@ -2,7 +2,7 @@
     <script type="text/javascript" src="/scripts/post.js"></script>
     <script type="text/javascript" src="/scripts/post_relations.js"></script>
     <div id="emoji_picker_modal" class="modal" style="display:none;">
-    <: $emoji_picker | mark_raw :>
+    <: component('EmojiPicker') :>
     </div>
     : if (!$direct) {
         : if ($failure != -1) {