package Trog::Themes; use 5.040; use re '/aasx'; use warnings FATAL => 'all'; use FindBin::libs; use Path::Tiny(); use Trog::Vars; use Trog::Config; =head1 Trog::Themes Utility functions for getting themed paths. =cut our $template_dir = 'www/templates'; =head2 routes() = %routes The active theme's own routing table, from its routes.pm. Themes can add routes of their own by shipping a routes.pm which populates %Theme::routes. Returns an empty list when there's no theme, or when the theme hasn't got one. Dies if there is a routes.pm and it won't load, as silently serving a site with half its routes missing is worse than not starting. =cut sub routes { my $rdir = get_dir(); return () unless -f "$rdir/routes.pm"; ## no critic (ProhibitFiletest_f) -- theme ships routes, or it does not # get_dir() is relative to the tCMS root, which require only searches from '.'. local @INC = ( '.', @INC ); local $@; eval { require "$rdir/routes.pm"; 1; } or do { ## no critic (Modules::RequireBarewordIncludes) -- a theme ships a file, not a module die "Could not load Theme routing package: $@"; }; no warnings 'once'; return %Theme::routes; } =head2 get_dir() = STRING $dir The active theme's directory, relative to the tCMS root, e.g. C. Empty string when no theme is configured, or when the configured one doesn't exist on disk -- so it's safe to interpolate either way, and truthiness is the test for "is there a theme". Memoized for the life of the process: changing the theme goes through /config/save, which signals the workers to restart. =cut sub get_dir { state $tdir = ''; return $tdir if $tdir; my $conf = Trog::Config::get(); my $theme = $conf->param('general.theme') || ''; if ($theme) { my $themedir = "www/themes/$theme"; $tdir = $themedir if -d $themedir; } return $tdir; } =head2 td() = STRING $dir get_dir() as a rooted URL path rather than a filesystem one, for templates to build asset links with. Empty string when there's no theme. =cut sub td { my $dir = get_dir(); return $dir ? "/$dir" : ''; } # The theme's template dir and the stock one, in that order of preference. # The theme entry is empty when no theme is configured, so callers can just # grep for truth rather than repeating the "is there a theme" dance. my sub _template_dirs ( $content_type, $is_component = 0 ) { my $ct = $Trog::Vars::byct{$content_type}; my $theme = get_dir(); my ( $themed, $stock ) = ( $theme ? "$theme/templates/$ct" : '', "$template_dir/$ct" ); if ($is_component) { $themed .= "/components" if $themed; $stock .= "/components"; } return ( $themed, $stock ); } =head2 template_dirs($content_type, $is_component) Every directory which could satisfy a template, most specific first. Hand this to Text::Xslate as its C and a theme gets to override individual templates without having to fork every last one of them. =cut sub template_dirs ( $content_type, $is_component = 0 ) { return grep { $_ && -d $_ } _template_dirs( $content_type, $is_component ); } =head2 template_dir($template, $content_type, $is_component, $is_dir) The single directory which owns $template: the theme's if it has its own copy, the stock one otherwise. With $is_dir, $template names a subdirectory (forms, headers, footers) rather than a file. This answers "who owns this one thing", which is what you want when *writing*. For reading a whole directory you almost always want themed_templates_in_dir() instead, which spans both rather than making you pick. =cut sub template_dir ( $template, $content_type, $is_component = 0, $is_dir = 0 ) { my ( $mtd, $mtemp ) = _template_dirs( $content_type, $is_component ); if ($is_dir) { return $mtd && -d "$mtd/$template" ? $mtd : $mtemp; } return $mtd && -f "$mtd/$template" ? $mtd : $mtemp; ## no critic (ProhibitFiletest_f) -- which dir owns the template } # Pick appropriate dir based on whether theme override exists my sub dir_for_resource ($resource) { my $theme_dir = get_dir(); return $theme_dir && -f "$theme_dir/$resource" ? $theme_dir : ''; ## no critic (ProhibitFiletest_f) -- theme override, else stock } =head2 themed($resource) Path to a www/-relative resource, preferring the theme's copy. =cut sub themed ($resource) { return dir_for_resource("$resource") . "/$resource"; } =head2 themed_style($resource) = @hrefs Stylesheets are the exception to the "one or the other" rule: you get the stock sheet *and* the theme's, in that order, so a theme can override a handful of rules without restating the whole thing. =cut # For style we want to load *both* style files and have the override come later. sub themed_style ($resource) { my @styles = ("/styles/$resource"); my $styled = dir_for_resource("styles/$resource"); $styled =~ s/^www\///; push( @styles, "/$styled/styles/$resource" ) if $styled; return @styles; } =head2 themed_script($resource) Path to a script under www/scripts, preferring the theme's copy. =cut sub themed_script ($resource) { return dir_for_resource("scripts/$resource") . "/scripts/$resource"; } =head2 dir_for($path, $content_type, $is_component) The single directory which owns a given component subdir: the theme's if it has one, the stock one otherwise. This is the right answer for I -- see forms_dir() and the post type wizard. For reading, you almost certainly want themed_templates_in_dir() instead, which spans both. =cut sub dir_for ( $path, $ct, $is_component = 0 ) { return template_dir( $path, $ct, $is_component, 1 ) . "/$path"; } =head2 forms_dir() dir_for() the post type forms directory, which is where the post type wizard writes new types and their schema sidecars. =cut sub forms_dir { return dir_for( 'forms', 'text/html', 1 ); } =head2 themed_templates_in_dir($path, $content_type, $is_component) The template names in an abstract template dir, from the theme and the stock tree both: the theme's templates where the theme has them, plus the stock ones it hasn't overridden. dir_for() is winner-take-all, so a theme with a forms/ dir of its own would otherwise hide every core post type. Returns bare filenames, deduped, theme first. Which of the two files a given name resolves to is then up to whoever renders it -- see template_dirs(). =cut sub themed_templates_in_dir ( $path, $ct, $is_component = 0 ) { my @dirs = grep { $_ } map { "$_/$path" } _template_dirs( $ct, $is_component ); my ( @templates, %seen ); foreach my $dir (@dirs) { next unless -d $dir; my @files = eval { grep { -f } Path::Tiny::path($dir)->children(qr/[.]tx\z/); ## no critic (ProhibitFiletest_f) -- listing names, nothing is opened }; foreach my $template ( map { $_->basename } @files ) { # First one wins, and the theme's dir comes first. push( @templates, $template ) unless $seen{$template}++; } } return \@templates; } =head2 themed_file_in_dir($path, $file, $content_type, $is_component) The path to a single named file in an abstract template dir, preferring the theme's copy. Returns undef when neither has it. =cut sub themed_file_in_dir ( $path, $file, $ct, $is_component = 0 ) { foreach my $dir ( grep { $_ } _template_dirs( $ct, $is_component ) ) { return "$dir/$path/$file" if -f "$dir/$path/$file"; ## no critic (ProhibitFiletest_f) -- search path, first hit wins } return undef; } =head2 themed_component($file, $content_type) The path to a file sitting directly in the components dir rather than in one of its subdirectories, preferring the theme's copy. Returns undef when neither has it. This is themed_file_in_dir() for the components which are not post types: the canned editor blocks every form splices in, and the JSON sidecars beside them which say what each one collects -- see Trog::DataModule::include_schema_for(). =cut sub themed_component ( $file, $ct = 'text/html' ) { foreach my $dir ( template_dirs( $ct, 1 ) ) { return "$dir/$file" if -f "$dir/$file"; ## no critic (ProhibitFiletest_f) -- search path, first hit wins } return undef; } 1;