Group
Extension

Mojolicious-Plugin-Wordpress/lib/Mojolicious/Plugin/Wordpress.pm

package Mojolicious::Plugin::Wordpress;
use Mojo::Base 'Mojolicious::Plugin';

use Mojo::DOM;
use Mojo::UserAgent;
use Mojo::Util 'trim';

use constant DEBUG => $ENV{MOJO_WORDPRESS_DEBUG} || 0;

our $VERSION = '0.03';

has base_url       => 'http://localhost/wp-json';                       # Will become a Mojo::URL object
has meta_replacer  => undef;
has post_processor => undef;
has ua             => sub { Mojo::UserAgent->new->max_redirects(3) };
has yoast_meta_key => 'yoast';

sub register {
  my ($self, $app, $config) = @_;
  my $prefix = $config->{prefix} || 'wp';

  $self->{meta}   = $self->_default_meta($app, $config->{meta} || {});
  $self->{prefix} = $prefix;

  $self->$_($config->{$_}) for grep { $config->{$_} } qw(base_url post_processor yoast_meta_key ua);
  $self->meta_replacer($config->{meta_replacer} || sub { $self->_default_meta_replacer(@_) });
  $self->base_url(Mojo::URL->new($self->base_url)) unless ref $self->base_url;

  $app->helper("$prefix.meta_from"       => sub { $self->_helper_meta_from(@_) });
  $app->helper("$prefix.rewrite_content" => sub { $self->_helper_rewrite_content(@_) });

  my $default_post_types = [qw(pages posts)];
  for my $type (@{$config->{post_types} || $default_post_types}) {
    (my $singular = $type) =~ s!s$!!;
    $app->helper("$prefix.get_${singular}_p" => sub { $self->_helper_get_post_p($type => @_) });
    $app->helper("$prefix.get_${type}_p" => sub { $self->_helper_get_posts_p($type => @_) });
  }

  $self->_add_wp_assets_route($app, $config) if $config->{base_assets_url};
}

sub _add_wp_assets_route {
  my ($self, $app, $config) = @_;
  my $base_url = Mojo::URL->new($config->{base_assets_url});

  $app->routes->get($config->{base_assets_route} || '/uploads/*proxy_path')->to(
    base_assets_url => $base_url,
    cb              => sub {
      my $c   = shift;
      my $url = $base_url->clone;

      push @{$url->path}, split '/', $c->stash('proxy_path');
      $c->app->log->debug("[Wordpress] GET $url");

      return $self->ua->get_p($url)->then(sub {
        my $proxy_tx = shift;
        my $proxy_h  = $proxy_tx->res->headers;
        my $res_h    = $c->res->headers;

        $res_h->$_($proxy_h->$_) for qw(content_length content_type);
        $res_h->cache_control($proxy_h->cache_control || 'max-age=86400');
        $res_h->etag($proxy_h->etag || Mojo::Util::md5_sum($proxy_tx->res->body));
        $res_h->last_modified($proxy_h->last_modified) if $proxy_h->last_modified;
        $c->render(data => $proxy_tx->res->body);
      });
    }
  )->name("$self->{prefix}.assets");
}

sub _arr { ref $_[0] eq 'ARRAY' ? $_[0] : [] }

sub _default_meta {
  my ($self, $app, $meta) = @_;

  # "powered_by" is just a dummy value to illustrate that custom %%variables%%
  # are also supported.
  return {powered_by => 'Mojolicious', sep => '-', sitename => $app->moniker, %$meta};
}

sub _default_meta_replacer {
  my ($self, $c, $str) = @_;
  my ($meta, $prefix) = @$self{qw(meta prefix)};
  my $stash = $c->stash;

  # %%title%% %%primary_category%% %%sep%% %%sitename%% %%whatever%%"
  $str =~ s!%%(\w+)%%!{$stash->{"${prefix}_${1}"} || $meta->{$1} || "%%$1%%"}!ge;
  $str;
}

sub _description {
  my $dom  = Mojo::DOM->new(shift->{content}{rendered} || '');
  my $text = trim($dom->all_text);
  return 297 < length $text ? sprintf '%s...', substr $text, 0, 297 : $text;
}

sub _helper_meta_from {
  my ($self, $c, $post) = @_;
  return undef unless ref $post eq 'HASH';

  my ($yoast_key, %meta) = ($self->yoast_meta_key);
  for my $key (keys %{$post->{x_metadata} || {}}, keys %{$post->{$yoast_key} || {}}) {
    next unless my $val = $post->{x_metadata}{$key} || $post->{$yoast_key}{$key};
    my $meta_key = $key;
    next unless $meta_key =~ s!^_?yoast_wpseo_!! or $post->{$yoast_key}{$key};
    $meta_key =~ s!-!_!g;
    $meta{$meta_key} ||= $val;
  }

  $meta{description}
    ||= delete $meta{metadesc} || $meta{opengraph_description} || $meta{twitter_description} || _description($post);
  $meta{title} ||= $meta{opengraph_title} || $meta{twitter_title} || '';
  $meta{"opengraph_$_"}      ||= $meta{"twitter_$_"} || $meta{$_} for qw(description title);
  $meta{twitter_description} ||= $meta{opengraph_description};
  $meta{twitter_title} ||= $meta{opengraph_title} || $meta{title};

  if (my ($base_url, $url_for) = $self->_rewrite_asset_url_info($c)) {
    for my $k (keys %meta) {
      $meta{$k} =~ s!\b$base_url/?(\S+)!{$url_for->($1)->to_abs}!ge;
    }
  }

  for my $key (keys %meta) {
    my $prefixed = "$self->{prefix}_$key";
    $meta{$prefixed} = $self->meta_replacer->($c, delete $meta{$key} // '');
    delete $meta{$prefixed} unless length $meta{$prefixed};
  }

  return \%meta;
}

sub _helper_get_post_p {
  my ($self, $type, $c, $params) = @_;
  $params = {slug => $params} unless ref $params;

  my %query = %$params;
  delete $params->{post_processor};

  my $processor = $params->{post_processor} || $self->post_processor;
  return $self->_raw(get_p => "wp/v2/$type", \%query)->then(sub {
    my $wp_res = shift->res;
    my $post   = _arr($wp_res->json)->[0];
    return $post && $processor ? $c->$processor($post) : $post;
  });
}

sub _helper_get_posts_p {
  my ($self, $type, $c, $params) = @_;

  my %query = %{$params || {}};
  delete $query{$_} for qw(all post_processor);
  $query{page}     = 1   if $params->{all};
  $query{per_page} = 100 if $params->{all} and !$query{per_page};

  my $processor = $params->{post_processor} || $self->post_processor;
  my ($gather, @posts);
  $gather = sub {
    my $wp_res = shift->res;

    for my $post (@{_arr($wp_res->json)}) {
      push @posts, $processor ? $c->$processor($post) : $post;
    }

    # Done getting all posts
    my $n_pages = $wp_res->headers->header('x-wp-totalpages') || 1;
    return \@posts if !$params->{all} or $n_pages <= $query{page};

    # Fetch more
    $query{page}++;
    $self->_raw(get_p => 'wp/v2/posts', \%query)->then($gather);
  };

  return $self->_raw(get_p => "wp/v2/$type", \%query)->then($gather);
}

sub _helper_rewrite_content {
  my ($self, $c) = @_;
  my $content = Mojo::DOM->new($_[2] // '');

  if (my ($base_url, $url_for) = $self->_rewrite_asset_url_info($c)) {
    $content->find(qq([src^="$base_url"], [srcset*="$base_url"]))->each(sub {
      for my $k (qw(src srcset)) {
        $_[0]->{$k} =~ s!\b$base_url/?(\S+)!{$url_for->($1)}!ge if $_[0]->{$k};
      }
    });
  }

  $content->find('img')->each(sub {
    delete $_[0]->{$_} for qw(height width);
  });

  return $content;
}

sub _raw {
  my ($self, $method, $path, $query, @data) = @_;
  my $url = $self->base_url->clone;

  # Want the query params sorted to improve caching
  $url->query(ref $query eq 'ARRAY' ? $query : [map { ($_ => $query->{$_}) } sort keys %$query]);
  push @{$url->path}, split '/', $path;

  warn "[Wordpress] $method $url\n" if DEBUG;
  return $self->ua->$method($url, @data);
}

sub _rewrite_asset_url_info {
  my ($self, $c) = @_;
  my $assets_route_name = "$self->{prefix}.assets";
  my $assets_route      = $c->app->routes->lookup($assets_route_name) or return;

  return ($assets_route->to->{base_assets_url}->to_string,
    sub { $c->url_for($assets_route_name, {proxy_path => $_[0]}) });
}

1;

=encoding utf8

=head1 NAME

Mojolicious::Plugin::Wordpress - Use Wordpress as a headless CMS

=head1 SYNOPSIS

  use Mojolicious::Lite;
  plugin wordpress => {base_url => "https://wordpress.example.com/wp-json"};

  get "/page/:slug" => sub {
    my $c = shift->render_later;
    $c->wp->get_page_p($c->stash("slug"))->then(sub {
      my $page = shift;
      $c->render(json => $page);
    });
  };

=head1 DESCRIPTION

L<Mojolicious::Plugin::Wordpress> is a plugin for getting data using the
Wordpress JSON API.

This plugin is currently EXPERIMENTAL. Let me know if you have any feedback at
L<https://github.com/jhthorsen/mojolicious-plugin-wordpress/issues>.

=head1 HELPERS

=head2 get_post_p

  my $promise = $c->wp->get_post_p;
  my $promise = $c->wp->get_post_p($slug);
  my $promise = $c->wp->get_post_p(\%query);

This helper will be available, dependent on what you set L</post_types> to. It
will return a L<Mojo::Promise> that will get a C<$post> hash-ref or C<undef> in
the fullfillment callback. The C<$post> hash-ref will be exactly what was
returned through the API from Wordpress, or whatever the L</post_processor> has
changed it to.

=head2 get_posts_p

  my $promise = $c->wp->get_posts_p;
  my $promise = $c->wp->get_posts_p(\%query);
  my $promise = $c->wp->get_posts_p({all => 1, post_processor => sub { ... }});

This helper will be available, dependent on what you set L</post_types> to. It
will return a L<Mojo::Promise> that will get an array-ref of C<$post> hash refs
in the fullfillment callback. A C<$post> hash-ref will be exactly what was
returned through the API from Wordpress, or whatever the L</post_processor> has
changed it to.

=head2 meta_from

  my $meta = $c->wp->meta_from(\%post);

This helper will extract meta information from the Wordpress post and return a
C<%hash> that looks something like this:

  {
    wp_canonical             => "",
    wp_title                 => "",
    wp_metadesc              => "",
    wp_description           => "",
    wp_opengraph_title       => "",
    wp_opengraph_description => "",
    wp_twitter_title         => "",
    wp_twitter_description   => "",
    ...
  }

Note that some keys might be missing or some keys might be added, depending on
how the Wordpress server has been set up.

Suggested Wordpress plugins: L<https://wordpress.org/plugins/wordpress-seo/>
and L<https://github.com/jhthorsen/wp-api-yoast-meta>.

=head2 rewrite_content

  $dom = $c->wp->rewrite_content($post->{content}{rendered});

This helper will rewrite a piece of HTML from Wordpress with the following
rules:

=over 2

=item * Asset URL

Will replace "src" and "srcset" on images with an URL to the local application,
if L</base_assets_url> is set in config or a "wp.assets" route could be found.

=item * Images

All "height" and "width" attributes will be removed from "img" tags.

=back

Note that more rules might be added in the future.

=head1 ATTRIBUTES

=head2 base_url

  my $url = $wp->base_url;
  my $wp  = $wp->base_url("https://wordpress.example.com/wp-json");

Holds the base URL to the Wordpress server API, including "/wp-json".

=head2 meta_replacer

  my $cb = $wp->meta_replacer;
  my $wp = $wp->meta_replacer(sub { my ($c, $str) = @_ });

A callback used to search and replace meta data when calling L</meta_from>.
The default callback will search and replace all occurances of "%%some_key%%"
with C<wp_$some_key> from L<Mojolicious::Controller/stash>.

=head2 post_processor

  my $cb = $wp->post_processor;
  my $wp = $wp->post_processor(sub { my ($c, $post) = @_ });

A code block that can be used to post process the JSON response from Wordpress.

=head2 ua

  my $ua = $wp->ua;
  my $wp = $wp->ua(Mojo::UserAgent->new);

Holds a L<Mojo::UserAgent> object that is used to get data from Wordpress.

=head2 yoast_meta_key

  my $str = $wp->yaost_meta_key;
  my $wp  = $wp->yaost_meta_key("yoast");

The key in the post JSON response that holds
L<Yoast|https://wordpress.org/plugins/wordpress-seo/> meta information.

This information is not enabled by default. To enable it through the API, you
can add this plugin: L<https://github.com/jhthorsen/wp-api-yoast-meta>.

=head1 METHODS

=head2 register

  $wp->register($app, \%config);
  $app->plugin(wordpress => \%config);

Used to register this plugin. C<%config> can have:

=over 2

=item * base_assets_url

If C<base_assets_url> is set, then a new route will be added to your C<$app>,
that will proxy GET requests to your Wordpress backend. This can be useful,
if you want to mask/hide Wordpress URLs and rather let everything go through
your L<Mojolicious> application.

Example value:

  {base_assets_url => 'https://wordpress.com/wp-content/uploads'}

The path added will either be defined by the C<base_assets_route> config
variable or default to "/uploads/*proxy_path", and the route will be named
"wp.assets".

=item * base_url

See L</base_url>.

=item * post_processor

See L</post_processor>.

=item * post_types

A list of post types available in the CMS. Defaults to:

  ["pages", "posts"]

This list will generate helpers to fetch data from Wordpress. Example default
helpers are:

  my $p = $c->wp->get_page_p(...);
  my $p = $c->wp->get_pages_p(...);
  my $p = $c->wp->get_post_p(...);
  my $p = $c->wp->get_posts_p(...);

See L</get_post_p> and L</get_posts_p> for more information.

Suggested Wordpress plugin:
L<https://wordpress.org/plugins/custom-post-type-maker/>

=item * prefix

The prefix for the helpers. Defaults to "wp".

=item * ua

See L</ua>.

=item * yoast_meta_key

See L</yoast_meta_key>.

=back

=head1 AUTHOR

Jan Henning Thorsen

=head1 COPYRIGHT AND LICENSE

Copyright (C), Jan Henning Thorsen.

This program is free software, you can redistribute it and/or modify it under
the terms of the Artistic License version 2.0.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.