Introduce markdown renderer for templates

Change-Id: Ia0166b5c7f3b80f70902c97653e517968d39dcf5
diff --git a/Changes b/Changes
index 1d00ec9..85e225e 100644
--- a/Changes
+++ b/Changes
@@ -1,10 +1,11 @@
-        - Migration of menu to ES6 (diewald)
-        - Migration of util and datepicker to ES6 (hebasta)
-
-0.66 2026-07-15
+0.66 2026-08-03
         - Fix link to NEGRA corpus (diewald)
         - Use M::P::Localize from CPAN (diewald)
         - Update dependency to Mojolicious (diewald)
+        - Migration of menu to ES6 (diewald)
+        - Migration of util and datepicker to ES6 (hebasta)
+        - Introduced Markdown renderer for docs
+          (diewald; kupietz; with AI assistance, Claude Opus 4.6 and 5.0)
 
 0.65 2026-04-21
         - Expose colors and forms to plugins (diewald)
diff --git a/Makefile.PL b/Makefile.PL
index bc5040c..30270a6 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -39,6 +39,9 @@
     # Required for older perl bundles
     'List::Util' => 1.70,
 
+    # Required for Markdown template support
+    'Text::MultiMarkdown' => 1.005,
+
     # Required for bundled plugins
     'Mojolicious::Plugin::Piwik' => 2.00
   },
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
diff --git a/t/custom/de/doc/md_test.html.md b/t/custom/de/doc/md_test.html.md
new file mode 100644
index 0000000..1b81c93
--- /dev/null
+++ b/t/custom/de/doc/md_test.html.md
@@ -0,0 +1,7 @@
+% layout 'main', title => 'KorAP: Markdown-Test';
+
+%= page_title
+
+## Über diese Seite
+
+Dies ist eine **Testseite** auf Deutsch.
diff --git a/t/custom/doc/md_test.html.md b/t/custom/doc/md_test.html.md
new file mode 100644
index 0000000..472a0ea
--- /dev/null
+++ b/t/custom/doc/md_test.html.md
@@ -0,0 +1,30 @@
+% layout 'main', title => 'KorAP: Markdown Test';
+
+%= page_title
+
+## About This Page
+
+This is a **test page** written in Markdown with EP helpers.
+
+## External Links
+
+Visit <%= ext_link_to 'GitHub', 'https://github.com/KorAP' %>
+for source code.
+
+See also [KorAP on GitHub](https://github.com/KorAP/Kalamar).
+
+## Internal Links
+
+See the [FAQ](/doc/faq) for more information.
+
+## Table Example
+
+| Component  | Role              |
+|------------|-------------------|
+| Kalamar    | User Frontend     |
+| Kustvakt   | Policy Management |
+| Krill      | Search Backend    |
+
+## Query Example
+
+%= doc_query poliqarp => 'Baum'
diff --git a/t/doc.t b/t/doc.t
index f3a3a7d..07cd6fa 100644
--- a/t/doc.t
+++ b/t/doc.t
@@ -99,6 +99,42 @@
   ->status_is(200)
   ->text_is('#page-top', 'KorAP: Annotationen');
 
+# Path-traversal attempt via Accept-Language header
+$t->get_ok('/doc/ql' => { 'Accept-Language' => '../../etc/passwd, en' })
+  ->status_is(200)
+  ->text_is('title', 'KorAP: Query Languages')
+  ;
+
+# Null-byte injection attempt
+$t->get_ok('/doc/ql' => { 'Accept-Language' => "de\x00malicious, en" })
+  ->status_is(200)
+  ;
+
+# Overly long locale
+$t->get_ok('/doc/ql' => { 'Accept-Language' => 'abcdefghijklmnop, en' })
+  ->status_is(200)
+  ->text_is('title', 'KorAP: Query Languages')
+  ;
+
+# Locale with special/shell characters
+$t->get_ok('/doc/ql' => { 'Accept-Language' => 'de;rm -rf /, en' })
+  ->status_is(200)
+  ;
+
+# Locale with slashes (directory traversal)
+$t->get_ok('/doc/ql' => { 'Accept-Language' => 'de/../../../etc, en' })
+  ->status_is(200)
+  ;
+
+# Construct an Accept-Language header with far more than 6 non-English locales.
+my @many_locales = map { "xx-" . sprintf("%02d", $_) } (1..50);
+my $huge_header = join(', ', @many_locales, 'en');
+
+$t->get_ok('/doc/ql' => { 'Accept-Language' => $huge_header })
+  ->status_is(200)
+  ->text_is('title', 'KorAP: Query Languages')
+  ;
+
 my $app = $t->app;
 
 $app->plugin(
diff --git a/t/markdown_integration.t b/t/markdown_integration.t
new file mode 100644
index 0000000..cad9c57
--- /dev/null
+++ b/t/markdown_integration.t
@@ -0,0 +1,47 @@
+use Mojo::Base -strict;
+use Test::More;
+use Test::Mojo;
+use Mojo::File qw/curfile/;
+
+my $t = Test::Mojo->new('Kalamar');
+
+ok($t->app->renderer->handlers->{md}, 'md handler registered in Kalamar');
+
+ok($t->app->renderer->helpers->{markdown}, 'markdown helper registered in Kalamar');
+
+push @{$t->app->renderer->paths}, curfile->dirname;
+
+$t->get_ok('/doc/md_test')
+  ->status_is(200)
+  ->element_exists('html head title')
+  ->content_like(qr/<h4>/)
+  ->content_like(qr/<table>/)
+  ->content_like(qr/<strong>test page<\/strong>/)
+  ->content_like(qr/class="query tutorial"/)
+  ;
+
+# Markdown external link uses ext_link_to (target="_top")
+$t->get_ok('/doc/md_test')
+  ->content_like(qr/<a href="https:\/\/github\.com\/KorAP\/Kalamar" target="_top">/)
+  ;
+
+# Markdown internal link uses undef
+$t->get_ok('/doc/md_test')
+  ->content_like(qr/<a href="\/doc\/faq">FAQ<\/a>/)
+  ;
+
+# Localized custom pages: a German visitor gets custom/de/doc/md_test,
+# an English one falls back to the default custom/doc/md_test.
+$t->get_ok('/doc/md_test' => { 'Accept-Language' => 'de-DE, en-US, en' })
+  ->status_is(200)
+  ->content_like(qr/Testseite/)
+  ->content_unlike(qr/test page/)
+  ;
+
+$t->get_ok('/doc/md_test' => { 'Accept-Language' => 'en-US, en, de-DE' })
+  ->status_is(200)
+  ->content_like(qr/test page/)
+  ->content_unlike(qr/Testseite/)
+  ;
+
+done_testing;
diff --git a/t/plugin/markdown.t b/t/plugin/markdown.t
new file mode 100644
index 0000000..f9bb0ae
--- /dev/null
+++ b/t/plugin/markdown.t
@@ -0,0 +1,234 @@
+use Mojo::Base -strict;
+use Test::More;
+use Test::Mojo;
+use Mojolicious::Lite;
+
+my $link_cb_called = 0;
+
+plugin 'TagHelpers::ContentBlock';
+
+plugin 'Kalamar::Plugin::Markdown' => {
+  heading_offset => 2,
+  link_callback  => sub {
+    my ($c, $url, $text) = @_;
+    $link_cb_called++;
+    if ($url =~ m{^/doc/}) {
+      return qq{<a href="$url" class="embedded-link">$text</a>};
+    }
+    if ($url =~ m{^https?://}) {
+      return qq{<a href="$url" target="_top">$text</a>};
+    }
+    return undef;
+  },
+};
+
+get '/md-inline' => sub {
+  shift->render(
+    handler => 'md',
+    inline  => "# Heading\n\nSome **bold** text.\n"
+  );
+};
+
+get '/md-page' => sub {
+  shift->render(
+    template => 'test_page',
+    handler  => 'md',
+    format   => 'html',
+    name     => 'World'
+  );
+};
+
+get '/md-helper' => sub {
+  shift->render(
+    template => 'test_helper',
+    handler  => 'ep',
+    format   => 'html'
+  );
+};
+
+get '/md-links' => sub {
+  shift->render(
+    handler => 'md',
+    inline  => "[FAQ](/doc/faq)\n\n[GitHub](https://github.com)\n\n[relative](page)\n"
+  );
+};
+
+get '/md-html' => sub {
+  shift->render(
+    handler => 'md',
+    inline  => qq{<pre class="query tutorial"><code>Baum</code></pre>\n\nSome text.\n}
+  );
+};
+
+get '/md-ep' => sub {
+  shift->render(
+    template => 'test_ep',
+    handler  => 'md',
+    format   => 'html',
+    title    => 'Test Title'
+  );
+};
+
+get '/md-table' => sub {
+  shift->render(
+    handler => 'md',
+    inline  => "| A | B |\n|---|---|\n| 1 | 2 |\n"
+  );
+};
+
+get '/md-comment' => sub {
+  shift->render(
+    handler => 'md',
+    inline  => "<!-- maintenance note -->\n\nVisible text.\n\n"
+             . "Inline <!-- hidden --> text.\n\n"
+             . "    <!-- in a code block -->\n"
+  );
+};
+
+get '/md-content-block' => sub {
+  my $c = shift;
+  $c->content_block('test_block' => { inline => '<span class="from-block">block content</span>' });
+  $c->render(
+    template => 'test_content_block',
+    handler  => 'md',
+    format   => 'html'
+  );
+};
+
+get '/md-details' => sub {
+  shift->render(
+    handler => 'md',
+    inline  => "<details>\n<summary>More</summary>\n\n### Section\n\n- item\n\n</details>\n"
+  );
+};
+
+my $t = Test::Mojo->new;
+
+ok($t->app->renderer->handlers->{md}, 'md handler registered');
+
+ok($t->app->renderer->helpers->{markdown}, 'markdown helper registered');
+
+$t->get_ok('/md-inline')
+  ->status_is(200)
+  ->content_like(qr/<h3>Heading<\/h3>/)
+  ->content_like(qr/<strong>bold<\/strong>/)
+  ;
+
+$t->get_ok('/md-inline')
+  ->content_like(qr/<h3>/)
+  ->content_unlike(qr/<h1>/)
+  ;
+
+$t->get_ok('/md-page')
+  ->status_is(200)
+  ->content_like(qr/Hello, World/)
+  ->content_like(qr/<h3>/)
+  ;
+
+$t->get_ok('/md-helper')
+  ->status_is(200)
+  ->content_like(qr/<h3>Helper Section<\/h3>/)
+  ->content_like(qr/<strong>works<\/strong>/)
+  ;
+
+$link_cb_called = 0;
+$t->get_ok('/md-links')
+  ->status_is(200)
+  ->content_like(qr/class="embedded-link"/)
+  ->content_like(qr/target="_top"/)
+  ->content_like(qr/<a href="page">relative/)
+  ;
+ok($link_cb_called >= 3, 'link callback was called');
+
+$t->get_ok('/md-html')
+  ->status_is(200)
+  ->content_like(qr/<pre class="query tutorial">/)
+  ->content_like(qr/<code>Baum<\/code>/)
+  ;
+
+$t->get_ok('/md-ep')
+  ->status_is(200)
+  ->content_like(qr/Test Title/)
+  ;
+
+$t->get_ok('/md-table')
+  ->status_is(200)
+  ->content_like(qr/<table>/)
+  ->content_like(qr/<td>1<\/td>/)
+  ;
+
+$t->get_ok('/md-content-block')
+  ->status_is(200)
+  ->content_like(qr/class="from-block"/)
+  ->content_like(qr/block content/)
+  ;
+
+# HTML5 block elements (details/summary/...) survive conversion: the stray
+# <p> wrapper Text::MultiMarkdown adds is removed and the inner Markdown is
+# still converted.
+$t->get_ok('/md-details')
+  ->status_is(200)
+  ->content_like(qr/<details>/)
+  ->content_like(qr/<summary>More<\/summary>/)
+  ->content_like(qr/<\/details>/)
+  ->content_unlike(qr/<p>\s*<details>/)
+  ->content_unlike(qr/<p>\s*<summary>/)
+  ->content_unlike(qr/<p>\s*<\/details>/)
+  ->content_like(qr/<li>item<\/li>/)
+  ;
+
+# Unwrapping can be disabled
+my $wrapped = Mojolicious->new;
+$wrapped->plugin('Kalamar::Plugin::Markdown' => { unwrap_html5_blocks => 0 });
+like(
+  $wrapped->build_controller->markdown("<details>\n<summary>x</summary>\n</details>\n"),
+  qr/<p>\s*<details>/,
+  'block unwrapping skipped with unwrap_html5_blocks => 0'
+);
+
+$t->get_ok('/md-comment')
+  ->status_is(200)
+  ->content_unlike(qr/maintenance note/)
+  ->content_unlike(qr/hidden/)
+  ->content_like(qr/Visible text\./)
+  ->content_like(qr/Inline\s*text\./)
+  ->content_like(qr/&lt;!-- in a code block --&gt;/)
+  ;
+
+# Comments are kept when the option is disabled
+my $keep = Mojolicious->new;
+$keep->plugin('Kalamar::Plugin::Markdown' => { strip_comments => 0 });
+like(
+  $keep->build_controller->markdown("<!-- kept -->\n\nText.\n"),
+  qr/<!-- kept -->/,
+  'comment kept with strip_comments => 0'
+);
+
+done_testing;
+
+__DATA__
+
+@@ test_page.html.md
+# Welcome
+
+Hello, <%= $name %>!
+
+@@ test_helper.html.ep
+<div>
+%= markdown begin
+# Helper Section
+This **works** well.
+% end
+</div>
+
+@@ test_ep.html.md
+# Page: <%= $title %>
+
+This page is about **<%= $title %>**.
+
+@@ test_content_block.html.md
+# Content Block Test
+
+%= content_block 'test_block'
+
+Some **markdown** after the block.