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/<!-- in a code block -->/)
+ ;
+
+# 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.