Add Markdent GFM extensions

- Per-image classes, to restrict what images a CSS selector acts on
  (in-post vs out-of-post)
- Fenced aside blocks (`!!!`-enclosed, like code blocks)
- Footnotes are handled by the Markdown parser, instead of manually
This commit is contained in:
snow flurry 2024-10-05 17:24:16 -07:00
parent b848bc58c9
commit fb0d94a2c0
10 changed files with 511 additions and 70 deletions

View file

@ -9,7 +9,10 @@ use File::Slurp;
use POSIX qw(strftime);
use Text::FrontMatter::YAML;
use Text::Template;
use Markdent::Simple::Fragment;
use FindBin;
use lib "$FindBin::RealBin/lib";
use Classy::Simple;
use Classy::Dialect;
use strict;
use utf8;
@ -31,70 +34,18 @@ my $post_tmpl = Text::Template->new(SOURCE => "$tmpl_dir/post.html.tmpl");
my $assets_path = $cwd . ASSETS_PATH;
my $out_path = $cwd . OUT_PATH;
my $postout_path = $out_path . POSTS_PATH;
my $return_link = qq{<img src="/assets/img/return.gif" />};
my $return_link = qq{<img class="ret-link" src="/assets/img/return.gif" />};
# used across a couple functions
my $parser = Markdent::Simple::Fragment->new;
my $parser = Classy::Simple->new;
sub make_fragment {
my $body = shift;
$parser->markdown_to_html(
dialect => 'GitHub',
markdown => $body,
dialects => [ 'Classy::Dialect' ],
markdown => shift,
);
}
sub strip_id {
my $id = shift;
$id =~ s/[ \t]/-/g;
$id =~ s/[^A-Za-z0-9_-]//g;
return $id;
}
sub do_footnotes {
my $text = shift;
my %footnotes;
my @used;
my $footnote_counter = 0;
# First grab the definitions
while ($text =~ s{
\n\[\^([^\n]+?)\]\:[ \t]*
\n?
(.*?)(?:\n{1,2} # end at new paragraph
(?=\n[ ]{0,4}\S)|\Z) # Lookahead for non-space at line-start, or end of doc
}{\n}sx) {
my $id = strip_id($1);
$footnotes{$1} = make_fragment(qq{$2 [$return_link](#fnref:$id)});
}
# then replace the inline footnotes
$text =~ s{
\[\^(.*?)\]
}{
my $result = '';
my $id = strip_id($1);
if (defined $footnotes{$id}) {
$footnote_counter++;
$result = qq{<sup>[<a href="#fn:$id" id="fnref:$id" class="footnote">$footnote_counter</a>]</sup>};
push(@used, $id);
}
$result;
}xsge;
my $fn_block = qq{<div id="footnotes"><hr /><ol>};
# finally, append the footnotes
foreach my $id (@used) {
my $footnote = $footnotes{$id};
$fn_block .= qq{<li id="fn:$id">$footnote</li>};
}
$fn_block .= qq{</ol></div>};
return ($text, $fn_block);
}
# Converts a post file to a metadata hash.
#
# Takes one argument, the path to the post file.
@ -166,11 +117,7 @@ sub post_to_meta {
$return;
}egmxs;
my $fn_body;
($body, $fn_body) = do_footnotes($body);
$fn_body = "" unless defined $fn_body;
$metadata->{content} = make_fragment($body) . $fn_body;
$metadata->{content} = make_fragment($body); # . $fn_body;
}
# HACK: Stuffing the basename in the metadata because I don't want
@ -238,14 +185,21 @@ sub mkpath {
}
}
# subroutines used in templates
my %utils = (
strftime => \&strftime,
include_tmpl => \&include_tmpl,
);
sub include_tmpl {
my $tmpl_name = shift;
# TODO: do we want to cache snippet-type templates like this?
my $tmpl = Text::Template->new(SOURCE => "$tmpl_dir/$tmpl_name.tmpl");
if (defined $tmpl) {
my $props = shift;
$tmpl->fill_in(HASH => { props => \$props });
my %page_hash = %utils;
my $postdata = shift;
$page_hash{props} = $postdata;
$tmpl->fill_in(HASH => \%page_hash);
} else {
"error: $tmpl_dir/$tmpl_name.tmpl is missing"
}
@ -254,12 +208,6 @@ sub include_tmpl {
## End subroutines
# subroutines used in templates
my %utils = (
strftime => \&strftime,
include_tmpl => \&include_tmpl,
);
# make posts dir or die
mkpath($postout_path) or die "Unable to create directory.";

9
lib/Classy.pm Normal file
View file

@ -0,0 +1,9 @@
package Classy;
use 5.010;
use strict;
use warnings;
1;
__END__

11
lib/Classy/Dialect.pm Normal file
View file

@ -0,0 +1,11 @@
package Classy::Dialect;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
1;
__END__

View file

@ -0,0 +1,140 @@
package Classy::Dialect::BlockParser;
use Classy::Event::AsideBlock;
use Markdent::Regexes qw( :block );
use Markdent::Parser::BlockParser;
use Moose::Role;
with 'Markdent::Dialect::GitHub::BlockParser';
around _possible_block_matches => sub {
my $orig = shift;
my $self = shift;
my @look_for = $self->$orig();
unshift @look_for, 'aside_block';
return @look_for;
};
after parse_document => sub {
my $self = shift;
return if $self->_span_parser->_fn_list_count eq 0;
print('Dumping footnotes!') if $self->debug;
$self->_send_event(
'StartHTMLTag',
tag => 'div',
attributes => {
id => 'footnotes',
},
);
$self->_send_event('HorizontalRule');
$self->_send_event('StartOrderedList');
for my $fn_id (@{ $self->_span_parser->_note_idx_map }) {
my $fndata = $self->_span_parser->_get_fn_by_id($fn_id);
print("fn-> $fn_id = $fndata\n");
$self->_send_event(
'StartHTMLTag',
tag => 'li',
attributes => {
id => "fn:$fn_id",
}
);
$self->_span_parser->parse_block( "$fndata " );
$self->_send_event(
'StartLink',
uri => "#fnref:$fn_id",
);
# TODO: make this editable
$self->_send_event(
'HTMLTag',
tag => 'img',
attributes => {
src => '/assets/img/return.gif',
class => 'backbtn',
},
);
$self->_send_event('EndLink');
$self->_send_event(
'EndHTMLTag',
tag => 'li',
);
}
$self->_send_event('EndOrderedList');
$self->_send_event(
'EndHTMLTag',
tag => 'div',
);
};
sub _match_aside_block {
my $self = shift;
my $text = shift;
return unless ${$text} =~ / \G
$BlockStart
!!!
([\w-]+)? # optional extra class name
\n
( # alert block content
(?:.|\n)+?
\n # last newline required for _parse_text
)
!!!
\n
/xmgc;
my $inner = $2;
my $classes = "aside" . (defined $1 ? " $1" : "");
$self->_debug_parse_result(
$inner,
'aside block',
) if $self->debug;
$self->_send_event(
'StartHTMLTag',
tag => 'div',
attributes => {
class => $classes,
}
);
$self->_parse_text( \$inner );
$self->_send_event(
'EndHTMLTag',
tag => 'div',
);
return 1;
}
1;
__END__
=pod
=head1 DESCRIPTION
This role is similar to L<Markdent::Dialect::GitHub>, but adds parsing for
custom "aside" contexts used by datagirl.xyz.
=head1 ROLES
This role does the L<Markdent::Role::Dialect::BlockParser> role.
=head1 BUGS
We'll find out!
=cut

View file

@ -0,0 +1,144 @@
package Classy::Dialect::SpanParser;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
use Markdent::Event::StartHTMLTag;
use Markdent::Event::EndHTMLTag;
use Markdent::Event::HTMLTag;
use Markdent::Event::StartOrderedList;
use Markdent::Event::EndOrderedList;
use Markdent::Event::HorizontalRule;
use Markdent::Event::StartLink;
use Markdent::Event::EndLink;
use Markdent::Event::Text;
use Markdent::Types;
use Moose::Role;
with 'Markdent::Dialect::GitHub::SpanParser';
has _notes_by_id => (
traits => ['Hash'],
is => 'ro',
isa => t( 'HashRef', of => t('Str') ),
default => sub { {} },
init_arg => undef,
handles => {
_add_fn_by_id => 'set',
_get_fn_by_id => 'get',
_reset_footnotes => 'clear',
},
);
has _note_idx_map => (
traits => ['Array'],
is => 'ro',
isa => t( 'ArrayRef', of => t('Str') ),
default => sub { [] },
handles => {
_add_fn_to_map => 'push',
_get_fn_list => 'get',
_fn_list_count => 'count',
_reset_fn_map => 'clear',
},
);
# Footnotes are kinda like links, but IDs are prefixed with a caret (^)
around extract_link_ids => sub {
my $orig = shift;
my $self = shift;
my $text = shift;
${$text} =~ s/ ^
\p{SpaceSeparator}{0,3}
\[ \^ ([^]]+) \]
:
\p{SpaceSeparator}*
\n?
\p{SpaceSeparator}*
(
(.|\n[^\n])+
)
(?:\n\n|$)
/
$self->_process_id_for_fn( $1, $2 );
''
/egxm;
$self->$orig($text);
};
around _possible_span_matches => sub {
my $orig = shift;
my $self = shift;
my @look_for = $self->$orig();
unshift @look_for, 'footnote';
return @look_for;
};
sub _process_id_for_fn {
my $self = shift;
my $id = shift;
my $id_text = shift;
$id_text =~ s/\s+$//;
$id_text =~ s/\n+/ /g;
$self->_debug_parse_result(
$id,
'footnote',
[ text => $id_text ],
) if $self->debug;
$self->_add_fn_by_id( $id => $id_text );
}
# Matches the inline footnote
sub _match_footnote {
my $self = shift;
my $text = shift;
my $pos = pos ${$text} || 0;
return
unless ${$text} =~ / \G
\[ \^ ([^]]+) \] # footnote id
/xgc;
my $fn_id = $1;
$self->_add_fn_to_map($fn_id);
my $fn_idx = $self->_fn_list_count;
# <sup>[<a href="#fn:id" title="fnref:id">idx</a>]</sup>
my $start_sup = $self->_make_event( StartHTMLTag => { tag => "sup" });
my $start_brack = '[';
my $end_brack = ']';
my $end_sup = $self->_make_event( EndHTMLTag => { tag => "sup" });
my $start_link = $self->_make_event( StartHTMLTag => {
tag => 'a',
attributes => {
href => "#fn:$fn_id",
id => "fnref:$fn_id",
},
});
my $end_link = $self->_make_event('EndLink');
$self->_markup_event($start_sup);
$self->_parse_text( \$start_brack );
$self->_markup_event($start_link);
$self->_parse_text( \$fn_idx );
$self->_markup_event($end_link);
$self->_parse_text( \$end_brack );
$self->_markup_event($end_sup);
return 1;
}
1;
__END__

11
lib/Classy/Event.pm Normal file
View file

@ -0,0 +1,11 @@
package Classy::Event;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
1;
__END__

View file

@ -0,0 +1,56 @@
package Classy::Event::AsideBlock;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
use Markdent::Types;
use Moose;
use MooseX::StrictConstructor;
has inner => (
is => 'ro',
isa => t('Str'),
required => 1,
);
has class => (
is => 'ro',
isa => t('Str'),
predicate => 'has_class',
);
with 'Markdent::Role::Event' => { event_class => __PACKAGE__ };
__PACKAGE__->meta->make_immutable;
1;
__END__
=pod
=head1 DESCRIPTION
This class represents an aside in the article.
=head1 ATTRIBUTES
This class has the following attributes:
=head2 inner
The inner markdown text of the block.
=head2 type
Either C<aside> or C<alert>, depending on the "severity" of the aside.
=head1 ROLES
This class does the L<Markdent::Role::Event> role.
=cut

11
lib/Classy/Handler.pm Normal file
View file

@ -0,0 +1,11 @@
package Classy::Handler;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
1;
__END__

View file

@ -0,0 +1,64 @@
package Classy::Handler::Fragment;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
use Markdent::Role::HTMLStream;
use List::Util qw(first);
use Classy::Event::AsideBlock;
use Markdent::Types;
use Params::ValidationCompiler qw( validation_for );
use Moose;
with 'Markdent::Role::HTMLStream';
# We're a fragment, don't output start/end document
sub start_document { }
sub end_document { }
around '_stream_start_tag' => sub {
my $orig = shift;
my $self = shift;
my $tag = shift;
my $attr = shift;
if ($tag eq "img" && !exists $attr->{class}) {
$attr->{class} = "as-post";
}
$self->$orig($tag, $attr);
};
{
my $validator = validation_for(
params => [
inner => { type => t('Str') },
class => {
type => t('Str'),
optional => 1,
},
],
named_to_list => 1,
);
sub aside_block {
my $self = shift;
my ( $inner, $class ) = $validator->(@_);
my %attrs = ( class => "aside" );
if ( $class ) {
$attrs{class} .= " $class";
}
$self->_stream_start_tag( 'div', \%attrs );
$self->_stream_text($inner);
$self->_stream_end_tag('div');
}
}
1;

47
lib/Classy/Simple.pm Normal file
View file

@ -0,0 +1,47 @@
package Classy::Simple;
use strict;
use warnings;
use namespace::autoclean;
our $VERSION = '0.10';
use Classy::Handler::Fragment;
use Params::ValidationCompiler qw( validation_for );
use Markdent::Parser;
use Markdent::Types;
use Moose;
with 'Markdent::Role::Simple';
{
# Validator from Markdent::Simple::Fragment
my $validator = validation_for(
params => [
dialects => {
type => t( 'ArrayRef', of => t('Str') ),
default => sub { [] },
},
markdown => { type => t('Str') },
],
named_to_list => 1,
);
sub markdown_to_html {
my $self = shift;
my ( $dialects, $markdown ) = $validator->(@_);
my $handler_class = 'Classy::Handler::Fragment';
return $self->_parse_markdown(
$markdown,
$dialects,
$handler_class,
);
}
}
1;
__END__