Introduce markdown renderer for templates

Change-Id: Ia0166b5c7f3b80f70902c97653e517968d39dcf5
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index e7174f0..0d835e9 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -280,6 +280,19 @@
     $self->plugin($_);
   };
 
+  $self->plugin('Markdown' => {
+    link_callback => sub {
+      my ($c, $url, $text) = @_;
+      if ($url =~ m{^https?://}) {
+        # $text is already-rendered HTML from the Markdown converter (it may
+        # contain inline markup such as <em>/<code>), so mark it safe to keep
+        # link_to from escaping it back to visible tags.
+        return $c->ext_link_to(b($text), $url)->to_string;
+      }
+      return undef;
+    },
+  });
+
   my $serializer = 'JSON';
 
   if (my $chi = $self->config('CHI')) {
diff --git a/lib/Kalamar/Controller/Documentation.pm b/lib/Kalamar/Controller/Documentation.pm
index 9d81282..ad4feed 100644
--- a/lib/Kalamar/Controller/Documentation.pm
+++ b/lib/Kalamar/Controller/Documentation.pm
@@ -32,9 +32,32 @@
   $c->stash(documentation => 1);
   $c->stash('robots' => 'index,follow');
 
+  # 1. Built-in template, localized via the Localize "Template" dictionary
+  #    (e.g. doc/faq -> de/doc/faq for German).
   my $render = $c->render_maybe(
     template => $c->loc('Template_' . join('_', @path), join('/', @path))
-  ) || $c->render_maybe(
+  );
+
+  # 2. Localized custom template by directory: a German visitor gets
+  #    custom/de/doc/... when that file exists, so instances can ship one
+  #    Markdown/EP file per language side by side without a dict entry.
+  #    The visitor's language preferences are tried in order (e.g. de-de,
+  #    then de); English is the default and handled by step 3.
+  unless ($render) {
+    my $tries = 0;
+    for my $locale (@{$c->localize->locale}) {
+      last if $locale =~ /^en(?:-|$)/;
+      # The locale is derived from a client-supplied Accept-Language header,
+      # so only let well-formed language tags reach the template path.
+      next unless $locale =~ /^[a-z]{2,3}(?:-[a-z0-9]+)?\z/i;
+      last if ++$tries > 6;
+      $render = $c->render_maybe(template => join('/', 'custom', $locale, @path))
+        and last;
+    };
+  };
+
+  # 3. Default (English) custom template.
+  $render ||= $c->render_maybe(
     template => $c->loc('Template_' . join('_', 'custom', @path), join('/', 'custom', @path))
   );
   return $render if $render;
diff --git a/lib/Kalamar/Plugin/Markdown.pm b/lib/Kalamar/Plugin/Markdown.pm
new file mode 100644
index 0000000..82cdd75
--- /dev/null
+++ b/lib/Kalamar/Plugin/Markdown.pm
@@ -0,0 +1,182 @@
+package Kalamar::Plugin::Markdown;
+use Mojo::Base 'Mojolicious::Plugin';
+use Mojo::ByteStream 'b';
+use Text::MultiMarkdown;
+
+sub register {
+  my ($plugin, $app, $conf) = @_;
+
+  # Load parameter from config file
+  if (my $config_param = $app->config('Kalamar')) {
+    if ($config_param->{Markdown}) {
+      $conf = {
+        %$conf,
+        %{$config_param->{Markdown}}
+      };
+    };
+  };
+
+  my $heading_offset = $conf->{heading_offset} // 2;
+  my $link_cb        = $conf->{link_callback};
+  my $strip_comments = $conf->{strip_comments} // 1;
+  my $unwrap_blocks  = $conf->{unwrap_html5_blocks} // 1;
+  my $m              = Text::MultiMarkdown->new(
+    heading_ids => 0,
+    %{$conf // {}}
+  );
+
+  # Get embedded Perl handler
+  my $ep_handler = $app->renderer->handlers->{ep};
+
+  # Add "md" extension handler to the renderer
+  $app->renderer->add_handler(md => sub {
+    my ($renderer, $c, $output, $options) = @_;
+
+    # Process the template with the embedded Perl handler
+    $ep_handler->($renderer, $c, $output, $options);
+
+    # Convert the output to HTML using the markdown converter
+    if (defined $$output && !ref $$output) {
+      $$output = _to_html($m, $$output, $c, $heading_offset, $link_cb, $strip_comments, $unwrap_blocks);
+    }
+  });
+
+  # Add "markdown" helper to the application
+  $app->helper(markdown => sub {
+    my ($c, $content) = @_;
+    $content = $content->() if ref $content eq 'CODE';
+    return b(_to_html($m, "$content", $c, $heading_offset, $link_cb, $strip_comments, $unwrap_blocks));
+  });
+}
+
+# HTML5 block-level elements that Text::MultiMarkdown does not know about
+# (its block-tag list predates HTML5). A stray tag of one of these on its own
+# line is treated as inline content and paragraph-wrapped, producing invalid
+# markup such as "<p><details></p>". See _to_html.
+my $HTML5_BLOCKS = qr/details|summary|section|article|aside|figure|figcaption|nav|header|footer|main/i;
+
+# Convert the text to HTML using the markdown converter
+sub _to_html {
+  my ($m, $text, $c, $heading_offset, $link_cb, $strip_comments, $unwrap_blocks) = @_;
+
+  # Use Text::MultiMarkdown to convert the text to HTML
+  my $html = $m->markdown($text);
+
+  # Drop HTML comments, so maintenance notes in the source document do not
+  # end up in the page source. Done after the conversion, where comments
+  # quoted in code blocks and code spans are escaped and therefore kept.
+  if ($strip_comments) {
+    $html =~ s/<!--.*?-->\s*//gs;
+  }
+
+  # Undo the paragraph-wrapping Text::MultiMarkdown applies to HTML5 block
+  # elements it does not recognize, so authors can use <details>/<summary>
+  # collapsibles, <section>, <figure> etc. in Markdown documents. Markdown
+  # inside such blocks is still converted (only the stray wrapper is removed):
+  # "<p><details></p>" becomes "<details>", and a paragraph whose whole
+  # content is one such element (e.g. "<p><summary>…</summary></p>") is
+  # likewise unwrapped.
+  if ($unwrap_blocks) {
+    $html =~ s{<p>\s*(</?(?:$HTML5_BLOCKS)\b[^>]*>)\s*</p>}{$1}g;
+    $html =~ s{<p>\s*(<(?:$HTML5_BLOCKS)\b[^>]*>.*?</(?:$HTML5_BLOCKS)>)\s*</p>}{$1}gs;
+  }
+
+  if ($heading_offset) {
+    for my $src (reverse 1 .. 6) {
+      my $dst = $src + $heading_offset;
+      $dst = 6 if $dst > 6;
+      next if $src == $dst;
+      $html =~ s{<(/?)h$src([\s>])}{<${1}h${dst}${2}}g;
+    }
+  }
+
+  if ($link_cb) {
+    $html =~ s{<a href="([^"]*)">(.*?)</a>}{
+      my ($url, $text) = ($1, $2);
+      $link_cb->($c, $url, $text) // $&
+    }gse;
+  }
+
+  return $html;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+Kalamar::Plugin::Markdown - Markdown template support for Kalamar
+
+=head1 DESCRIPTION
+
+Adds a C<md> renderer handler and a C<markdown> helper using
+L<Text::MultiMarkdown>. Templates (C<*.html.md>) are first
+processed by the EP handler, then converted to HTML.
+
+When both C<.html.ep> and C<.html.md> exist, C<.html.ep> takes
+precedence (C<ep> is the default handler).
+
+B<Limitations:> Fenced code blocks (triple backticks) are not
+supported by L<Text::MultiMarkdown>; use 4-space indentation.
+EP C<begin>/C<end> blocks may produce line breaks that get
+wrapped in C<< <p> >> tags; prefer single-line helper calls.
+
+=head1 OPTIONS
+
+=head2 heading_offset
+
+Number of levels added to Markdown headings (default: C<2>).
+Capped at C<h6>. Set to C<0> to disable.
+
+=head2 strip_comments
+
+Remove HTML comments from the generated markup (default: C<1>).
+Set to C<0> to keep them. Comments are removed after the Markdown
+conversion, so comments quoted inside code blocks or code spans
+are not affected -- but comments inside raw HTML blocks are.
+
+=head2 unwrap_html5_blocks
+
+Undo the paragraph-wrapping L<Text::MultiMarkdown> applies to HTML5
+block-level elements it does not recognize (default: C<1>). Its
+block-tag list predates HTML5, so a stray C<< <details> >>,
+C<< <summary> >>, C<< <section> >>, C<< <article> >>, C<< <aside> >>,
+C<< <figure> >>, C<< <figcaption> >>, C<< <nav> >>, C<< <header> >>,
+C<< <footer> >> or C<< <main> >> tag on its own line is otherwise
+emitted as invalid markup such as C<< <p><details></p> >>. Markdown
+inside such elements is still converted. Set to C<0> to disable.
+
+=head2 link_callback
+
+  sub { my ($c, $url, $text) = @_; ... }
+
+Post-processes plain Markdown-generated C<< <a> >> tags.
+Return replacement HTML or C<undef> to keep the original.
+Links from EP helpers (which have extra attributes) are
+not matched.
+
+=head2 multimarkdown
+
+Extra options passed to L<Text::MultiMarkdown/new>.
+
+=head1 HELPERS
+
+=head2 markdown
+
+  %= markdown begin
+  ## Section
+  Some **bold** text.
+  % end
+
+Converts Markdown to HTML inside C<.html.ep> templates.
+
+=head1 SEE ALSO
+
+L<Text::MultiMarkdown>, L<Kalamar::Plugin::KalamarPages>.
+
+=cut