root/branches/release-41/plugins/StyleCatcher/lib/StyleCatcher/CMS.pm @ 2743

Revision 2743, 22.5 kB (checked in by breese, 17 months ago)

Added support for an additional registry parameter. This template_sets->base_css registry item points to a base CSS file that should imported via a CSS @import command any time a style for this template set is applied. This change is being made to support those styles that may *not* require a base.css file to be imported. The base_css registry item should be a path relative to the mt-static directory.

I also converted style catcher from using the legacy .pl plugin format to the more standard config.yaml format.

  • Property svn:keywords set to Id Revision
Line 
1# Movable Type (r) Open Source (C) 2005-2008 Six Apart, Ltd.
2# This program is distributed under the terms of the
3# GNU General Public License, version 2.
4#
5# $Id$
6
7package StyleCatcher::CMS;
8
9use strict;
10use File::Basename qw(basename);
11
12our $DEFAULT_STYLE_LIBRARY;
13
14sub style_library {
15    return MT->registry("stylecatcher_libraries");
16}
17
18sub file_mgr {
19    my $app = MT->instance;
20    require MT::FileMgr;
21    my $filemgr = MT::FileMgr->new('Local')
22      or return $app->error( MT::FileMgr->errstr );
23    $filemgr;
24}
25
26sub listify {
27    my ($data) = @_;
28    my @list;
29    foreach my $k (keys %$data) {
30        my %entry = %{ $data->{$k} };
31        $entry{key} = $k;
32        delete $entry{plugin};
33        $entry{label} = $entry{label}->() if ref($entry{label});
34        $entry{description_label} = $entry{description_label}->() if ref($entry{description_label});
35        push @list, \%entry;
36    }
37    @list = sort { $a->{order} <=> $b->{order} } @list;
38    \@list;
39}
40
41sub view {
42    my $app     = shift;
43    my $blog_id = $app->param('blog_id');
44    $app->return_to_dashboard( redirect => 1 ) unless $blog_id;
45
46    my $blog = MT::Blog->load($blog_id);
47    return $app->errtrans("Invalid request") unless $blog;
48
49    my $static_path = $app->static_file_path;
50    if (! -d $static_path ) {
51        return $app->errtrans("Your mt-static directory could not be found. Please configure 'StaticFilePath' to continue.");
52    }
53
54    my $themeroot =
55      File::Spec->catdir( $app->static_file_path, 'support', 'themes' );
56    my $webthemeroot = $app->static_path . 'support/themes';
57    my $stylelibrary = listify(style_library());
58    my $theme_data   = make_themes();
59    my $styled_blogs = fetch_blogs();
60
61    my $config = plugin()->get_config_hash();
62
63    my @blog_loop;
64    my %current_themes;
65    my ($blog_theme, $blog_layout);
66    foreach my $blog (@$styled_blogs) {
67        my $curr_theme = $config->{"current_theme_" . $blog->id} || '';
68        my $curr_layout = $config->{"current_layout_" . $blog->id} || 'layout-wtt';
69        push @blog_loop,
70          {
71            blog_id   => $blog->id,
72            blog_name => $blog->name,
73            layout    => $curr_layout,
74            theme_id  => $curr_theme,
75            view_link => $blog->site_url,
76          };
77        if ($blog->id == $blog_id) {
78            $blog_theme = $curr_theme;
79            $blog_layout = $curr_layout;
80        }
81        if ( $theme_data->{themes} && $curr_theme ) {
82            foreach my $theme ( @{ $theme_data->{themes} } ) {
83                if ( ($theme->{prefix} || '') . ':' . $theme->{name} eq $curr_theme ) {
84                    push @{ $theme->{blogs} }, $blog->id;
85                    next if exists $current_themes{ $theme->{name} };
86                    $current_themes{ $theme->{name} } = 1;
87                    push @{ $theme->{tags} }, 'collection:current';
88                }
89            }
90        }
91    }
92
93    push @{ $theme_data->{categories} }, 'current'
94      if %current_themes;
95
96    require JSON;
97    my $url   = $app->param('url');
98    my %param = (
99        version     => plugin()->version,
100        # blog_loop   => \@blog_loop,
101        blog_id => $blog_id,
102        themes_json => JSON::objToJson(
103            $theme_data, { pretty => 1, indent => 2, delimiter => 1 }
104        ),
105        auto_fetch => $url ? 1 : 0,
106        style_library => $stylelibrary,
107        current_theme => $blog_theme || '',
108        current_layout => $blog_layout || 'layout-wtt',
109        dynamic_blog => (($blog->custom_dynamic_templates || '') eq 'all'),
110    );
111
112    if ( $blog_id && @$styled_blogs ) {
113        my $blog = $styled_blogs->[0];
114        $param{blog_name} = $blog->name;
115        $param{blog_url}  = $blog->site_url;
116    }
117
118    my $path = $app->static_path;
119    $path .= '/' unless $path =~ m!/$!;
120    $path .= plugin()->envelope . "/";
121    $path = $app->base . $path if $path =~ m!^/!;
122    $param{plugin_static_uri} = $path;
123
124    $app->build_page( 'view.tmpl', \%param );
125}
126
127# AJAX/JSON modes
128
129# returns a json structure of styles given a particular url
130sub js {
131    # ydnar's remixer uses javascript files for each collection of styles -
132    # we generate these js files from css metadata
133    # StyleCatcher will pick up any metadata in the theme css file in the
134    # format of 'key: value' in comment-space
135    # The remixer only uses name, author, description at the moment.
136    my $app = shift;
137
138    my $data = fetch_themes($app->param('url'))
139        or return $app->json_error( $app->errstr );
140    return $app->json_result( $data );
141}
142
143# does the work after user selects a particular theme to apply to a blog
144sub apply {
145    my $app = shift;
146
147    my $blog_id = $app->param('blog_id');
148    my $url     = $app->param('url');
149    my $layout  = $app->param('layout');
150    my $name    = $app->param('name');
151
152    # Load the default stylesheet for this blog
153    my $tmpl = load_style_template($blog_id);
154
155    $app->validate_magic or return $app->json_error($app->translate("Invalid request"));
156    return $app->json_error($app->translate("Invalid request"))
157      unless $blog_id && $url && $tmpl;
158
159    my $static_path = $app->static_file_path;
160    if (! -d $static_path ) {
161        return $app->json_error($app->translate("Your mt-static directory could not be found. Please configure 'StaticFilePath' to continue."));
162    }
163
164    my $themeroot =
165      File::Spec->catdir( $static_path, 'support', 'themes' );
166    my $webthemeroot = $app->static_path . 'support/themes/';
167    my $mtthemeroot  = $app->static_path . 'themes/';
168    my $mtthemebase  = $app->static_path . 'themes-base/';
169
170    # Break up the css url in to a couple useful pieces
171    my @url = split( /\//, $url );
172
173    # if this isn't a local url, then we have to grab some files from
174    # yonder...
175    my $filemgr = file_mgr()
176      or return $app->json_error( MT::FileMgr->errstr );
177
178    if ( $url !~ m/^(\Q$webthemeroot\E|\Q$mtthemeroot\E)/ ) {
179        my $new_url = '';
180
181        for (0..(scalar(@url)-2)) {
182            $new_url .= $url[$_] . '/';
183        }
184        my ( $basename, $extension ) = split( /\./, $url[-1] );
185        if ($basename eq 'screen') {
186            $basename = $url[-2];
187        }
188
189        # Pick up the stylesheet
190        my $user_agent  = $app->new_ua;
191        my $css_request = HTTP::Request->new( GET => $url );
192        my $response    = $user_agent->request($css_request);
193
194        # Pick up the thumbnail and thumbnail-large
195        my $thumbnail_request =
196          HTTP::Request->new( GET => $new_url . "thumbnail.gif" );
197        my $thumbnail_response = $user_agent->request($thumbnail_request);
198        my $thumbnail_large_request =
199          HTTP::Request->new( GET => $new_url . "thumbnail-large.gif" );
200        my $thumbnail_large_response =
201          $user_agent->request($thumbnail_large_request);
202
203        # Parse out image filenames in the css and then write out the css file
204        # and thumbnails to our theme folder
205        my $content = $response->content;
206        $content =~ s!/\*.*?\*/!!gs;    # strip all comments first
207        my @images = $content =~
208          m/\b(?:url\(\s*)([a-zA-Z0-9_.-]+\.(?:gif|jpe?g|png))(?:\s*?\))/gi;
209        $filemgr->mkpath( File::Spec->catdir( $themeroot, $basename ) )
210          or return $app->json_error(
211            $app->translate(
212"Could not create [_1] folder - Check that your 'themes' folder is webserver-writable.",
213                $basename
214            )
215          );
216        $filemgr->put_data( $response->content,
217            File::Spec->catfile( $themeroot, $basename, $basename . '.css' ) );
218        if (($thumbnail_response->code >= 200) && ($thumbnail_response->code < 400)) {
219        $filemgr->put_data( $thumbnail_response->content,
220            File::Spec->catfile( $themeroot, $basename, "thumbnail.gif" ),
221            'upload' );
222        } else {
223            return $app->json_error($app->translate("Error downloading image: [_1]", $new_url . 'thumbnail.gif'))
224        }
225        if (($thumbnail_large_response->code >= 200) && ($thumbnail_large_response->code < 400)) {
226            $filemgr->put_data(
227                $thumbnail_large_response->content,
228                File::Spec->catfile( $themeroot, $basename, "thumbnail-large.gif" ),
229                'upload'
230            );
231        } else {
232            return $app->json_error($app->translate("Error downloading image: [_1]", $new_url . 'thumbnail-large.gif'))
233        }
234
235       # Pick up the images we parsed earlier and write them to the theme folder
236        for my $image_url (@images) {
237            my $image_request =
238              HTTP::Request->new( GET => $new_url . $image_url );
239            my $image_response = $user_agent->request($image_request);
240
241            my @image_url = split( /\//, $image_url );
242            my $image_filename = $image_url[-1];
243
244            if (($response->code >= 200) && ($response->code < 400)) {
245                $filemgr->put_data( $image_response->content,
246                    File::Spec->catfile( $themeroot, $basename, $image_filename ),
247                    'upload' )
248                  or return $app->json_error( $filemgr->errstr );
249            } else {
250                return $app->json_error($app->translate("Error downloading image: [_1]", $new_url . $image_url));
251            }
252        }
253        $url = "$webthemeroot$basename/$basename.css";
254    }
255   
256    my $blog = MT->model('blog')->load($blog_id)   
257      or return $app->json_error( $app->translate('No such blog [_1]', $blog_id) );
258
259    my $r = MT->registry;
260    my $base_css = $r->{"template_sets"}{$blog->template_set}{"base_css"};
261
262    # Replacing the theme import or adding a new one at the beginning
263    my $template_text  = $tmpl->text();
264    my $replaced       = 0;
265    my $header =
266'/* This is the StyleCatcher theme addition. Do not remove this block. */';
267    my $footer = '/* end StyleCatcher imports */';
268    my $styles = $header . "\n";
269    $styles .= "\@import url(".File::Spec->catfile($app->static_path, $base_css).");\n" if $base_css;
270    $styles .= "\@import url($url);\n";
271    $styles .= $footer;
272    print STDERR "styles=$styles\n";
273
274    if ($template_text =~ s/\Q$header\E.*\Q$footer\E/$styles/s) {
275        $tmpl->text( $template_text );
276        $replaced = 1;
277    }
278    unless ($replaced) {
279
280        # we're dealing with a template that wasn't modified before now
281        # we will need to backup the existing one to make sure the new
282        # style is applied properly.
283        my @ts = MT::Util::offset_time_list( time, $blog_id );
284        my $ts = sprintf "%04d-%02d-%02d %02d:%02d:%02d", $ts[5] + 1900,
285          $ts[4] + 1, @ts[ 3, 2, 1, 0 ];
286        my $backup = $tmpl->clone;
287        delete $backup->{column_values}
288          {id};    # make sure we don't overwrite original
289        delete $backup->{changed_cols}{id};
290        $backup->name( $backup->name . ' (Backup from ' . $ts . ')' );
291        $backup->outfile('');
292        $backup->linked_file( $tmpl->linked_file );
293        $backup->rebuild_me(0);
294        $backup->build_dynamic(0);
295        $backup->identifier(undef);
296        $backup->type('backup');
297        $backup->save;
298        $tmpl->linked_file('');    # make sure this one isn't linked now
299        $tmpl->identifier('styles');
300        $tmpl->text($styles);
301    }
302
303    # Putting the stylesheet back together again
304    $tmpl->save or return $app->json_error( $tmpl->errstr );
305
306    $blog->page_layout($layout);
307    $blog->touch();
308    $blog->save();
309
310    # rebuild only the stylesheet! forcibly. with prejudice.
311    $app->rebuild_indexes(
312        BlogID   => $tmpl->blog_id,
313        Template => $tmpl,
314        Force    => 1
315    );
316
317    my $p = plugin();
318    $name =~ s/^repo_\d+:/local:/;
319    $name =~ s/\.css$//;
320    $p->set_config_value('current_theme_' . $blog_id, $name);
321    if ($layout) {
322        $p->set_config_value('current_layout_' . $blog_id, $layout);
323    } else {
324        $p->set_config_value('current_layout_' . $blog_id, undef);
325    }
326
327    return $app->json_result(
328        {
329            message =>
330              $app->translate("Successfully applied new theme selection.")
331        }
332    );
333}
334
335# Utility methods
336
337sub fetch_blogs {
338    my $app     = MT->app;
339    my $user    = $app->user;
340    my $blog_id = $app->param('blog_id');
341
342    my @blogs;
343    if ($blog_id) {
344        @blogs = MT::Blog->load($blog_id);
345    } else {
346        if ( $user->is_superuser() ) {
347            if ($blog_id) {
348                @blogs = MT::Blog->load($blog_id);
349            }
350        }
351        else {
352            my $args = { author_id => $user->id };
353            $args->{blog_id} = $blog_id if $blog_id;
354            require MT::Permission;
355            my @perms = MT::Permission->load( { author_id => $user->id } );
356            foreach my $perm (@perms) {
357                next unless $perm->can_edit_templates;
358                push @blogs, MT::Blog->load( $perm->blog_id );
359            }
360        }
361    }
362    my @styled_blogs;
363    foreach my $blog (@blogs) {
364        my $tmpl = load_style_template( $blog->id );
365        if ($tmpl) {
366            push @styled_blogs, $blog;
367        }
368    }
369    @styled_blogs = sort { $a->name cmp $b->name } @styled_blogs;
370
371    \@styled_blogs;
372}
373
374sub load_style_template {
375    my ($blog_id) = @_;
376
377    require MT::Template;
378    my $tmpl;
379
380    $tmpl = MT::Template->load(
381        {
382            blog_id    => $blog_id,
383            identifier => 'styles'
384        }
385    );
386
387    $tmpl ||= MT::Template->load(
388        {
389            blog_id => $blog_id,
390            outfile => "styles.css"
391        }
392    );
393
394    # MT 3.x era stylesheet file
395    $tmpl ||= MT::Template->load(
396        {
397            blog_id => $blog_id,
398            outfile => "styles-site.css"
399        }
400    );
401
402    unless ($tmpl) {
403
404        # Create one since we didn't find a candidate
405        $tmpl = new MT::Template;
406        $tmpl->blog_id($blog_id);
407        $tmpl->identifier('styles');
408        $tmpl->outfile("styles.css");
409        $tmpl->text(<<'EOT');
410@import url(<$MTStaticWebPath$>themes-base/blog.css);
411@import url(<$MTStaticWebPath$>themes/minimalist-red/styles.css);
412EOT
413        $tmpl->save();
414    }
415
416    $tmpl;
417}
418
419# pulls a list of themes available from a particular url
420sub fetch_themes {
421    my $app = MT->app;
422    my ($url) = @_;
423    return undef unless $url;
424
425    my $blog_id = $app->param('blog_id');
426    my $data    = {};
427
428  # If we have a url then we're specifying a specific theme (css) or repo (html)
429    # Pick up the file (html with <link>s or a css file with metadata)
430    my $user_agent = $app->new_ua;
431    my $request    = HTTP::Request->new( GET => $url );
432    my $response   = $user_agent->request($request);
433
434    # Make a repo if you've got a ton of links or an automagic entry if
435    # you're a css file
436    my $type = $response->headers->{'content-type'};
437    $type = shift @$type if ref $type eq 'ARRAY';
438    if ( $type =~ m!^text/css! ) {
439        $data->{auto}{url} = $url;
440        my $theme = fetch_theme( $url, ['collection:auto'] );
441        $data->{themes} = [$theme];
442    }
443    elsif ( $type =~ m!^text/html! ) {
444        my @repo_themes;
445        for my $link (
446            ref( $response->headers->{'link'} ) eq 'ARRAY'
447            ? @{ $response->headers->{'link'} }
448            : $response->headers->{'link'}
449          )
450        {
451            my ( $css, @parsed_link ) = split( /;/, $link );
452            $css =~ s/[<>]//g;
453            my %attr;
454            foreach (@parsed_link) {
455                my ( $name, $val ) = split /=/, $_, 2;
456                $name =~ s/^ //;
457                $val  =~ s/^['"]|['"]$//g;
458                next if $name eq '/';
459                $attr{ lc($name) } = $val;
460            }
461            next unless lc $attr{rel}  eq 'theme';
462            next unless lc $attr{type} eq 'text/x-theme';
463
464            # Fix for relative theme locations
465            if ($css !~ m!^https?://!) {
466                my $new_css = $url;
467                $new_css =~ s!/[a-z0-9_-]+\.[a-z]+?$|/$!/!;
468                $new_css .= $css;
469                $css = $new_css;
470            }
471            push @repo_themes, $css;
472        }
473
474        my $themes = [];
475        for my $repo_theme (@repo_themes) {
476            my $theme = fetch_theme( $repo_theme, [] );
477            push @$themes, $theme if $theme;
478        }
479        $data->{themes} = $themes;
480        if ( $data->{repo}{display_name} = $response->headers->{'title'} ) {
481            $data->{repo}{name} =
482              MT::Util::dirify( $data->{repo}{display_name} );
483        }
484        else {
485            $data->{repo}{display_name} = $url;
486            $data->{repo}{name}         = MT::Util::dirify($url);
487        }
488        $data->{repo}{url} = $url;
489    }
490    else {
491        return $app->error( $app->translate('Invalid URL: [_1]', $url) );
492    }
493
494    $data;
495}
496
497# sets up the object structure we return through json to populate
498# the mixer.
499sub make_themes {
500    my $app = MT->instance;
501
502    # categories
503    #   current    (for active theme)
504    #   repo       (for themes found at repo link)
505    #   my-designs (for themes that are stored locally)
506    #   mt-designs (for themes that are local and installed by default)
507    #   auto       (for link to a single css file)
508
509    # structure of "data"
510    #   categories => [ one, two, three ]  ie: 'current', 'repo'
511    #   themes => [
512    #       { theme }
513    #   ]
514    #   repo => {
515    #       display_name => 'display name',
516    #       name => 'repo name',
517    #       url => 'url of repo',
518    #   }
519
520# structure of "theme"
521#   theme => {
522#       name => 'theme_dir',
523#       imageSmall => 'link_to/thumbnail.gif',
524#       imageBig => 'link_to/thumbnail-large.gif',
525#       title => 'Theme Title',
526#       description => 'Theme description.',
527#       url_css => 'link_to/theme.css',
528#       url_zip => 'link_to/theme.zip',
529#       author => 'Author Name',
530#       author_url => 'http://author.com/'
531#       author_affiliation => 'Author Co.',
532#       layouts => "comma,delimited,layout,list"
533#       sort => 'theme_sortable_name',
534#       tags => ['association:tag']  ie, 'color:blue', 'designer:author', 'collection:repo'
535#   }
536
537    my ( $categories, $themes );
538    my $sys_root = File::Spec->catdir( $app->static_file_path, 'themes' );
539
540    # Generate our list of themes within the themeroot directory
541    my @sys_list = glob( File::Spec->catfile( $sys_root, "*" ) );
542    $categories->{'mt-designs'} = 1 if @sys_list;
543    for my $theme (@sys_list) {
544        my $theme_dir = $theme;
545        my $theme_url = $app->static_path . 'themes';
546        next unless -d $theme;
547        $theme =~ s/.*[\\\/]//;
548        $themes->{$theme} =
549          fetch_theme( $theme_dir, ['collection:mt-designs'], $theme_url,
550            $theme_dir );
551        $themes->{$theme}{name} = $themes->{$theme}{name};
552        $themes->{$theme}{prefix} = 'default';
553    }
554
555    my $themeroot =
556      File::Spec->catdir( $app->static_file_path, 'support', 'themes' );
557
558    # Generate our list of themes within the themeroot directory
559    my @themeroot_list = glob( File::Spec->catfile( $themeroot, "*" ) );
560    $categories->{'my-designs'} = 1 if @themeroot_list;
561    for my $theme (@themeroot_list) {
562        my $theme_dir = $theme;
563        next unless -d $theme;
564        $theme =~ s/.*[\\\/]//;
565        $themes->{$theme} =
566          fetch_theme( $theme_dir, ['collection:my-designs'] );
567        $themes->{$theme}{prefix} = 'local';
568    }
569
570    my $data = {
571        categories => [ keys %$categories ],
572        themes     => [ values %$themes ]
573    };
574
575    $data;
576}
577
578sub fetch_theme {
579    my $app = MT->app;
580    my ( $url, $tags, $baseurl, $basepath ) = @_;
581
582    my $theme;
583    my $stylesheet;
584    my $new_url;
585    my $themeroot;
586    if ( $url =~ m/^https?:/i ) {
587
588        # Pick up the css file
589        my $user_agent  = $app->new_ua;
590        my $css_request = HTTP::Request->new( GET => $url );
591        my $response    = $user_agent->request($css_request);
592        $stylesheet = $response->content if ($response->code >= 200) && ($response->code < 400);
593        return unless $stylesheet;
594
595# Break up the css url in to a couple useful pieces (generalize and break me out)
596        $theme = $url;
597        # discard any generic 'screen.css' filename
598        $theme =~ s!/screen.css$!!;
599        $theme =~ s/.*[\\\/]//;
600        my @url = split( /\//, $url );
601        for ( 0 .. ( scalar(@url) - 2 ) ) {
602            $new_url .= $url[$_] . '/';
603        }
604    }
605    else {
606        $themeroot = $basepath
607          || File::Spec->catdir( $app->static_file_path, 'support', 'themes' );
608        my $webthemeroot = $baseurl || $app->static_path . 'support/themes';
609
610        $theme = $url;
611        $theme =~ s/.*[\\\/]//;
612        my $file = File::Spec->catfile( $url, "$theme.css" );
613        $new_url = "$webthemeroot/$theme/";
614        if ( -e $file ) {
615            $stylesheet = file_mgr()->get_data($file);
616            $url        = $new_url . "$theme.css";
617        }
618        else {
619            $file = File::Spec->catfile( $url, "screen.css" );
620            if ( -e $file ) {
621                $stylesheet = file_mgr()->get_data($file);
622                $url        = $new_url . "screen.css";
623            }
624        }
625    }
626
627    # Pick up the metadata from the css
628    my @css_lines = split( /\r?\n/, $stylesheet || '' );
629    my $commented = 0;
630    my @comments;
631    for my $line (@css_lines) {
632        my $pos;
633        $pos = index( $line, "/*" );
634        unless ( $pos == -1 ) {
635            $line = substr( $line, $pos + 2 );
636            $commented = 1;
637        }
638        if ($commented) {
639            $pos = index( $line, "*/" );
640            unless ( $pos == -1 ) {
641                $line = substr( $line, 0, $pos );
642                $commented = 0;
643            }
644            push @comments, $line;
645        }
646    }
647
648    my $comment;
649    my %metadata;
650
651    # Trim me white space, yarr
652    for (@comments) {
653
654        # TBD: strip any "risky" content; we don't want any
655        # XSS in this content.
656        # Strip any null bytes
657        tr/\x00//d;
658        s/^\s+|\s+$//g;
659        my ( $key, $value ) = split( /:/, $_, 2 ) or next;
660        next unless defined $value;
661        $value =~ s/^\s+//;
662        $metadata{ lc $key } = $value;
663    }
664
665    my $thumbnail_link;
666    $thumbnail_link = $new_url . 'thumbnail.gif';
667    my $thumbnail_large_link;
668    $thumbnail_large_link = $new_url . 'thumbnail-large.gif';
669
670    require MT::Util;
671    my $data = {
672        name        => $theme,
673        description => $metadata{description} || '',
674        title       => $metadata{name} || '(Untitled)',
675        url         => $url,
676        imageSmall  => $thumbnail_link,
677        imageBig    => $thumbnail_large_link,
678        author      => $metadata{designer} || $metadata{author} || '',
679        author_url  => $metadata{designer_url} || $metadata{author_url} || '',
680        author_affiliation => $metadata{author_affiliation} || '',
681        layouts            => $metadata{layouts} || '',
682        'sort'             => $metadata{name} || '',
683        tags               => $tags,
684        blogs              => [],
685    };
686    $data;
687}
688
689sub plugin {
690    return MT->component('StyleCatcher');
691}
692
6931;
Note: See TracBrowser for help on using the browser.