From fc62437935022e9d77749c61b5c054aacfc4baac Mon Sep 17 00:00:00 2001 From: Jason McIntosh Date: Tue, 23 Jun 2026 18:12:40 -0400 Subject: [PATCH] Replace Text::MultiMarkdown with Markdown::Perl for fenced code blocks (#57) Text::MultiMarkdown has no support for GitHub-style fenced code blocks: a backtick fence was parsed as a single inline span (collapsing the whole block onto one line) and ~~~ fences were ignored entirely. Switch the Markdown engine to Markdown::Perl in GitHub mode, which handles fenced code blocks (with language-foo info-string classes), tables, and the rest of GFM natively. This replaces an earlier hand-rolled fence preprocessor with a maintained CommonMark/GFM implementation. Markdown::Perl follows CommonMark and HTML-escapes " to " during conversion, which would stop Plerd::SmartyPants from curling literal double quotes; drop '"' from html_escaped_characters so quotes survive for SmartyPants (quotes inside code are still escaped via the separate html_escaped_code_characters option). SmartyPants is still required for curly quotes, dashes, and ellipses. Markdown::Perl requires Perl 5.26, so raise the floor in cpanfile. Also declare Capture::Tiny, a long-standing undeclared test dependency of t/init.t: it had been satisfied transitively through the Text::Markdown stack, so removing that stack exposed it. t/code_blocks.t covers backtick and tilde fences, language classes, line preservation, escaping, and untouched inline code. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- cpanfile | 6 +++- lib/Plerd/Post.pm | 18 ++++++++---- t/code_blocks.t | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 t/code_blocks.t diff --git a/CLAUDE.md b/CLAUDE.md index 142584e..5487feb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ All publication writes are atomic: `_publish_template_to_file`/`_atomically_writ - **`Plerd::Post`** (`Post.pm`) — one post per Markdown source file. The real work happens in `_process_source_file`, fired by a Moose **trigger** the moment `source_file` is set (not lazily). That method: - parses the leading `key: value` metadata block (terminated by a blank line) into `attributes`, then the Markdown body; - - runs the body and title through `Text::MultiMarkdown::markdown` then `Plerd::SmartyPants`; + - runs the body and title through `Markdown::Perl` (a shared converter in GitHub mode, so fenced code blocks, tables, and the rest of GFM work) then `Plerd::SmartyPants`. The converter drops `"` from its HTML-escaped characters so SmartyPants can curl literal double quotes into typographic ones; quotes inside code are still escaped via `html_escaped_code_characters`; - **mutates the source file in place** — if `time`, `published_filename`, or `guid` are missing, it computes them and rewrites the file with the full metadata block. This write-back is core behavior, not a side effect to "fix." Date logic: explicit W3C `time:` wins; else a `YYYY-MM-DD` filename prefix sets midnight of that date (or now, if the date is today); else now. - resolves tags (comma-separated `tags:` header) into shared `Plerd::Tag` objects via `$plerd->tag_named`. - `publish` renders `post.tt`. `send_webmentions` walks the post's links via `Web::Mention`. diff --git a/cpanfile b/cpanfile index 367040a..8c58889 100644 --- a/cpanfile +++ b/cpanfile @@ -1,3 +1,6 @@ +# Markdown::Perl (our Markdown engine) requires Perl 5.26 or later. +requires 'perl', '5.026'; + requires 'Moose'; requires 'Template'; requires 'Path::Class'; @@ -7,7 +10,7 @@ requires 'JSON'; requires 'DateTime' => '1.54'; requires 'DateTime::Format::W3CDTF'; requires 'URI'; -requires 'Text::MultiMarkdown'; +requires 'Markdown::Perl'; requires 'YAML'; requires 'FindBin'; requires 'File::ChangeNotify'; @@ -21,3 +24,4 @@ requires 'Readonly'; requires 'Web::Mention' => '0.703'; requires 'MooseX::Types::URI'; requires 'Test::Warn'; +requires 'Capture::Tiny'; # Used by t/init.t. diff --git a/lib/Plerd/Post.pm b/lib/Plerd/Post.pm index 00657a4..edc238c 100644 --- a/lib/Plerd/Post.pm +++ b/lib/Plerd/Post.pm @@ -3,7 +3,7 @@ package Plerd::Post; use Moose; use DateTime; use DateTime::Format::W3CDTF; -use Text::MultiMarkdown qw( markdown ); +use Markdown::Perl; use URI; use HTML::Strip; use Data::GUID; @@ -18,6 +18,13 @@ use Web::Mention; use Readonly; Readonly my $WPM => 200; # The words-per-minute reading speed to assume +my $markdown = Markdown::Perl->new( + mode => 'github', + # Drop '"' so literal quotes survive for Plerd::SmartyPants to curl. + html_escaped_characters => '&<>', +); +sub markdown { $markdown->convert( $_[0] ) } + has 'plerd' => ( is => 'ro', required => 1, @@ -444,10 +451,11 @@ sub _process_source_file { } $self->body( $body ); - foreach ( qw( title body ) ) { - if ( defined( $self->$_ ) ) { - $self->$_( Plerd::SmartyPants::process( markdown( $self->$_ ) ) ); - } + foreach my $field ( qw( title body ) ) { + next unless defined $self->$field; + $self->$field( + Plerd::SmartyPants::process( markdown( $self->$field ) ) + ); } # Strip unnecessary

tags that the markdown processor just added to the title. diff --git a/t/code_blocks.t b/t/code_blocks.t new file mode 100644 index 0000000..fdaf57a --- /dev/null +++ b/t/code_blocks.t @@ -0,0 +1,75 @@ +use warnings; +use strict; +use Test::More; +use Path::Class::Dir; +use Path::Class::File; +use URI; + +use FindBin; +use lib "$FindBin::Bin/../lib"; + +use_ok( 'Plerd' ); +use Plerd::Init; + +# Regression test for GitHub issue #57: GitHub-style fenced code blocks. +# Text::MultiMarkdown does not understand ``` or ~~~ fences -- it collapsed a +# backtick-fenced block into a single inline span (so every line +# wrapped together) and ignored ~~~ entirely. Plerd now pre-processes fences +# into

 blocks before handing the body to Markdown.
+
+my $blog_dir = Path::Class::Dir->new( "$FindBin::Bin/code_blocks_blog" );
+$blog_dir->rmtree;
+Plerd::Init::initialize( $blog_dir->stringify, 0 );
+
+my $source = <<'END';
+title: Code
+
+Backtick fence with a language:
+
+```perl
+my $x = 1;
+if ( $x < 2 ) { say "ok"; }
+```
+
+Tilde fence:
+
+~~~
+plain text
+second line
+~~~
+
+Inline `code` is untouched.
+END
+
+Path::Class::File->new( $blog_dir, 'source', '2022-07-31-code.md' )->spew(
+    iomode => '>:encoding(utf8)', $source,
+);
+
+my $plerd = Plerd->new(
+    path         => $blog_dir->stringify,
+    title        => 'Test Blog',
+    author_name  => 'Nobody',
+    author_email => 'nobody@example.com',
+    base_uri     => URI->new( 'http://blog.example.com/' ),
+);
+
+$plerd->publish_all;
+my ( $post ) = @{ $plerd->posts };
+my $body = $post->body;
+
+like( $body, qr{
},
+    'Backtick fence becomes a 
 block with a language class.' );
+like( $body, qr{my \$x = 1;\nif},
+    'Code lines are preserved on separate lines, not wrapped together.' );
+like( $body, qr{\$x < 2},
+    'Code special characters are HTML-escaped.' );
+like( $body, qr{
\s*plain text\nsecond line},
+    'Tilde fence also becomes a 
 block.' );
+like( $body, qr{code},
+    'Inline code spans still work.' );
+unlike( $body, qr{

perl}, + 'Fence is not collapsed into a single inline code span.' ); + +$blog_dir->rmtree; + +done_testing();