A hosting service for Jekyll Blogs
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

557 lines
13 KiB

package MJB::Backend::Jekyll;
use Moo;
use IPC::Run3 qw( run3 );
use Cwd qw( getcwd );
use File::Path qw( make_path );
use File::Find;
use Storable qw( dclone );
use Mojo::File;
use MJB::Backend::Jekyll::MarkdownFile;
use MJB::Backend::Jekyll::ConfigFile;
# The root path for the repositories
has root => (
is => 'ro',
required => 1,
trigger => sub {
my ( $self, $value ) = @_;
make_path( $value );
},
);
# The domain name for this jekyll blog
has domain => (
is => 'ro',
required => 1,
);
# The full path to the git repo this is backed by.
has repo => (
is => 'ro',
required => 1,
);
has config => (
is => 'lazy',
);
sub _build_config {
my ( $self ) = @_;
return MJB::Backend::Jekyll::ConfigFile->new(
path => $self->repo_path . "/_config.yml",
)->read;
}
has repo_path => (
is => 'lazy',
);
sub _build_repo_path {
my ( $self ) = @_;
return $self->root . "/" . $self->domain;
}
# The full path to the git repo to clone when using
# init on a new repository.
has init_from => (
is => 'ro',
required => 1,
);
sub init {
my ( $self ) = @_;
# Refuse to overwrite an already-existing site.
die "Error: Cannot init when the target directory already exists."
if -d $self->repo_path;
# Clone the template repo
$self->system_command( [ qw( git clone ), $self->init_from, $self->repo_path ] );
# Update the origin that is set
$self->system_command( [ qw( git remote set-url origin ), $self->repo ], {
chdir => $self->repo_path,
});
# Confirm the origin updated
my $return = $self->system_command( [ qw( git remote get-url origin ) ], {
chdir => $self->repo_path,
});
if ( $return->{stdout} ne $self->repo . "\n" ) {
die "Error: Unable to initialize and set repo.";
}
# Push the repo to the store
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return $self;
}
sub list_media {
my ( $self ) = @_;
$self->_ensure_repository_is_latest;
my $media = Mojo::File->new( $self->repo_path . "/assets/media" );
my @return;
# TODO: Sort by date for the listing on the front end.
foreach my $file ( $media->list->each ) {
push @return, {
path => $file->to_string,
filename => $file->basename,
url => 'https://' . $self->domain . '/assets/media/' . $file->basename,
markdown => '![Title for image](https://' . $self->domain . '/assets/media/' . $file->basename . ')',
};
}
return [ @return ];
}
sub list_posts {
my ( $self ) = @_;
$self->_ensure_repository_is_latest;
my $posts = Mojo::File->new( $self->repo_path . "/_posts" );
my @files;
# TODO: Sort by date for the listing on the front end.
foreach my $file ( $posts->list->each ) {
push @files, MJB::Backend::Jekyll::MarkdownFile->new(
root => $self->repo_path,
path => $file->to_string,
);
}
return [ @files ];
}
sub remove_markdown_file {
my ( $self, $file ) = @_;
# Update the origin that is set
$self->system_command( [ qw( git rm ), $file->path ], {
chdir => $self->repo_path,
});
# Commit The Changes
$self->system_command( [ qw( git commit -m ), 'Removed ' . $file->filename ], {
chdir => $self->repo_path,
});
# Push the changes
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return $self;
}
sub list_pages {
my ( $self ) = @_;
$self->_ensure_repository_is_latest;
my @files;
find( sub {
return unless $_ =~ /\.(?:markdown|md)$/; # Only markdown files
return if substr((split m|/|, $File::Find::dir)[-1], 0, 1) eq '_'; # Skip directories that start with _
push @files, MJB::Backend::Jekyll::MarkdownFile->new(
root => $self->repo_path,
path => $File::Find::name,
);
}, $self->repo_path );
return [ @files ];
}
sub get_post {
my ( $self, $filename ) = @_;
return undef
if $filename =~ m|\.\./|;
return undef
unless -f $self->repo_path . "/_posts/" . $filename;
return MJB::Backend::Jekyll::MarkdownFile->new(
root => $self->repo_path,
path => $self->repo_path . "/_posts/" . $filename,
)->read;
}
sub new_post {
my ( $self, $filename ) = @_;
return undef
if $filename =~ m|\.\./|;
return MJB::Backend::Jekyll::MarkdownFile->new(
root => $self->repo_path,
path => $self->repo_path . "/_posts/" . $filename,
);
}
sub new_page {
my ( $self, $filename ) = @_;
return undef
if $filename =~ m|\.\./|;
return MJB::Backend::Jekyll::MarkdownFile->new(
root => $self->repo_path,
path => $self->repo_path . $filename,
);
}
sub get_title_of_post {
my ( $self, $file ) = @_;
open my $lf, "<", $file
or die "Failed to open $file for reading: $!";
while ( defined( my $line = <$lf> ) ) {
if ( $line =~ /^title: (.+)$/ ) {
close $lf;
return $1;
}
}
close $lf;
return undef;
}
# Think about this....
# probably want 'slug: ' as an override for the file path
sub _post_path {
my ( $self, $headers ) = @_;
my $title = $headers->{title};
$title = lc($title);
$title =~ s/[^a-zA-Z0-9-_]+/_/g;
$title =~ s/[_]+/_/g;
$title =~ s/_$//g;
$title =~ s/^_//g;
return $self->repo_path . "/_posts/" . $title . ".markdown";
}
sub write_config {
my ( $self, $config ) = @_;
$config ||= $self->config;
$config->write;
# Add the file to git
$self->system_command( [ qw( git add ), $config->path ], {
chdir => $self->repo_path,
});
# Commit the file
$self->system_command( [ qw( git commit -m ), "Updated Site Config" ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub write_post {
my ( $self, $md_file ) = @_;
# Check that the repo exists and is latest.
$self->_ensure_repository_is_latest;
# Write the file
$md_file->write;
# Add the file to git
$self->system_command( [ qw( git add ), $md_file->path ], {
chdir => $self->repo_path,
});
# Commit the file
$self->system_command( [ qw( git commit -m ), "Created " . $md_file->headers->{title} ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub commit_file {
my ( $self, $file, $comment ) = @_;
# Check that the repo exists and is latest.
$self->_ensure_repository_is_latest;
# Add the file to git
$self->system_command( [ qw( git add ), $file ], {
chdir => $self->repo_path,
});
# Commit the file
$self->system_command( [ qw( git commit -m ), $comment ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub remove_file {
my ( $self, $file, $comment ) = @_;
# Check that the repo exists and is latest.
$self->_ensure_repository_is_latest;
# Add the file to git
$self->system_command( [ qw( git rm ), $file ], {
chdir => $self->repo_path,
});
# Commit the file
$self->system_command( [ qw( git commit -m ), $comment ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub revert_commit {
my ( $self, $commit ) = @_;
# Check that the repo exists and is latest.
$self->_ensure_repository_is_latest;
# Add the file to git
$self->system_command( [ qw( git revert --no-edit ), $commit ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub restore_commit {
my ( $self, $commit ) = @_;
# Check that the repo exists and is latest.
$self->_ensure_repository_is_latest;
# Restore the commit.
$self->system_command( [ qw( git restore --source ), $commit, qw( -W -S --theirs :/ ) ], {
chdir => $self->repo_path,
});
# Restore the commit.
$self->system_command( [ qw( git commit -m ), "Restored from $commit" ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub delete_post {
my ( $self, $title, $file ) = @_;
# Check if the repo exists and update the repo if needed
$self->_ensure_repository_is_latest;
# Ensure the post exists - irony
die "Error: Cannot delete post that doesn't exists at " . $file
if ! -f $file;
# git rm the file
$self->system_command( [ qw( git rm ), $file ], {
chdir => $self->repo_path,
});
# git commit the file
$self->system_command( [ qw( git commit -m ), "Deleted post $title" ], {
chdir => $self->repo_path,
});
# Push the repo to the store server
$self->system_command( [ qw( git push origin master ) ], {
chdir => $self->repo_path,
});
}
sub history {
my ( $self ) = @_;
# Check if the repo exists and update the repo if needed
$self->_ensure_repository_is_latest;
# Do a git history
my $result = $self->system_command( [ qw(git log --date=relative), q|--pretty=%H %ad %s| ], {
chdir => $self->repo_path,
});
my @return;
# Format the results into a data structure
foreach my $line ( split( /\n/, $result->{stdout} ) ) {
if ( $line =~ /^([0-9a-f]{40}) (.+ ago) (.+)$/ ) {
push @return, {
commit => $1,
dateref => $2,
message => $3,
};
}
}
# Return the data structure
return [ @return ];
}
# Helper function to ensure the repo exists and has the latest
# changes.
sub _ensure_repository_is_latest {
my ( $self ) = @_;
# Check for the repo -- if it doesn't exist, clone it.
if ( ! -d $self->repo_path ) {
$self->system_command( [ qw( git clone ), $self->repo, $self->repo_path ] );
return 1;
}
# Run a git pull with fast forward
$self->system_command( [ qw( git pull --ff-only origin master ) ], {
chdir => $self->repo_path,
});
return 1;
}
sub system_command {
my ( $self, $cmd, $settings ) = @_;
$settings ||= {};
# Change the directory, if requested.
if ( $settings->{chdir} ) {
# Throw an error if that directory doesn't exist.
die "Error: directory " . $settings->{chdir} . "doesn't exist."
unless -d $settings->{chdir};
# Change to that directory, or die with error.
chdir $settings->{chdir}
or die "Failed to chdir to " . $settings->{chdir} . ": $!";
$settings->{return_chdir} = getcwd();
}
# Mask values we don't want exposed in the logs.
my $masked_cmd = dclone($cmd);
if ( ref $settings->{mask} eq 'HASH' ) {
foreach my $key ( keys %{$settings->{mask}} ) {
my $value = $settings->{mask}{$key};
$masked_cmd = [ map { s/\Q$key\E/$value/g; $_ } @{$masked_cmd} ];
}
}
# Log the lines
my ( $out, $err );
my $ret = run3( $cmd, \undef, sub {
chomp $_;
# Mask values we don't want exposed in the logs.
if ( ref $settings->{mask} eq 'HASH' ) {
foreach my $key ( keys %{$settings->{mask}} ) {
my $value = $settings->{mask}{$key};
s/\Q$key\E/$value/g;
}
}
$out .= "$_\n";
}, sub {
chomp $_;
# Mask values we don't want exposed in the logs.
if ( ref $settings->{mask} eq 'HASH' ) {
foreach my $key ( keys %{$settings->{mask}} ) {
my $value = $settings->{mask}{$key};
s/\Q$key\E/$value/g;
}
}
$err .= "$_\n";
});
# Check stderr for errors to fail on.
if ( $settings->{fail_on_stderr} ) {
my @tests = @{$settings->{fail_on_stderr}};
while ( my $regex = shift @tests ) {
my $reason = shift @tests;
if ( $err =~ /$regex/ ) {
die $reason;
}
}
}
# Return to the directory we started in if we chdir'ed.
if ( $settings->{return_chdir} ) {
chdir $settings->{return_chdir}
or die "Failed to chdir to " . $settings->{chdir} . ": $!";
}
if ( $ENV{MJB_DEBUG} ) {
require Data::Dumper;
print Data::Dumper::Dumper({
stdout => $out,
stderr => $err,
exitno => $ret,
});
}
return {
stdout => $out,
stderr => $err,
exitno => $ret,
};
}
1;