~jan0sch/smederee

Showing details for patch ef026d73e765585726fc31da8b4e61b59e013787.
2024-06-01 (Sat), 6:58 PM - Jens Grassel - ef026d73e765585726fc31da8b4e61b59e013787

html-utils: Switch to Laika for content rendering.

- add laika to dependencies
- add new general `ContentRenderer` for markdown and reStructuredText
- refactor views to simplify possible rendering of HTML from md or rst files
- remove common mark dependencies
- remove `MarkdownRenderer`
- fix local links from the repository overview page
- fix todo rendering
- enable rendering of rst readme files on the repository overview page
Summary of changes
2 files added
  • modules/html-utils/src/main/scala/de/smederee/html/ContentRenderer.scala
  • modules/html-utils/src/test/scala/de/smederee/html/RenderableContentTest.scala
6 files modified with 113 lines added and 113 lines removed
  • build.sbt with 39 added and 49 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 29 added and 29 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala with 5 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 5 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html with 32 added and 30 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 3 added and 3 removed lines
1 files removed
  • modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/build.sbt	2025-01-11 17:45:25.731881668 +0000
@@ -110,14 +110,9 @@
             scalacOptions += "-Xfatal-warnings",
             libraryDependencies ++= Seq(
                 library.catsCore,
-                library.commonMark,
-                library.commonMarkExtHeadingAnchor,
-                library.commonMarkExtImageAttrs,
-                library.commonMarkExtStrikethrough,
-                library.commonMarkExtTables,
-                library.commonMarkExtTaskListItems,
                 library.http4sCore,
                 library.ip4sCore,
+                library.laikaCore,
                 library.logback,
                 library.munit           % Test,
                 library.munitScalaCheck % Test,
@@ -282,7 +277,6 @@
             val cats            = "2.12.0"
             val catsEffect      = "3.5.4"
             val circe           = "0.14.7"
-            val commonMark      = "0.22.0"
             val doobie          = "1.0.0-RC5"
             val flyway          = "10.13.0"
             val fs2             = "3.5.0"
@@ -290,6 +284,7 @@
             val ip4s            = "3.5.0"
             val jansi           = "2.4.2"
             val jclOverSlf4j    = "2.0.13"
+            val laika           = "1.1.0"
             val log4cats        = "2.7.0"
             val logback         = "1.5.6"
             val munit           = "1.0.0"
@@ -301,48 +296,43 @@
             val simpleJavaMail  = "8.11.1"
             val springSecurity  = "6.3.0"
         }
-        val apacheSshdCore             = "org.apache.sshd"  % "sshd-core"                        % Version.apacheSshd
-        val apacheSshdSftp             = "org.apache.sshd"  % "sshd-sftp"                        % Version.apacheSshd
-        val apacheSshdScp              = "org.apache.sshd"  % "sshd-scp"                         % Version.apacheSshd
-        val bouncyCastleProvider       = "org.bouncycastle" % "bcprov-jdk15to18"                 % Version.bouncyCastle
-        val catsCore                   = "org.typelevel"   %% "cats-core"                        % Version.cats
-        val catsEffect                 = "org.typelevel"   %% "cats-effect"                      % Version.catsEffect
-        val circeCore                  = "io.circe"        %% "circe-core"                       % Version.circe
-        val circeGeneric               = "io.circe"        %% "circe-generic"                    % Version.circe
-        val circeParser                = "io.circe"        %% "circe-parser"                     % Version.circe
-        val commonMark                 = "org.commonmark"   % "commonmark"                       % Version.commonMark
-        val commonMarkExtHeadingAnchor = "org.commonmark"   % "commonmark-ext-heading-anchor"    % Version.commonMark
-        val commonMarkExtImageAttrs    = "org.commonmark"   % "commonmark-ext-image-attributes"  % Version.commonMark
-        val commonMarkExtStrikethrough = "org.commonmark"   % "commonmark-ext-gfm-strikethrough" % Version.commonMark
-        val commonMarkExtTables        = "org.commonmark"   % "commonmark-ext-gfm-tables"        % Version.commonMark
-        val commonMarkExtTaskListItems = "org.commonmark"   % "commonmark-ext-task-list-items"   % Version.commonMark
-        val doobieCore                 = "org.tpolecat"    %% "doobie-core"                      % Version.doobie
-        val doobieHikari               = "org.tpolecat"    %% "doobie-hikari"                    % Version.doobie
-        val doobiePostgres             = "org.tpolecat"    %% "doobie-postgres"                  % Version.doobie
-        val doobieScalaTest            = "org.tpolecat"    %% "doobie-scalatest"                 % Version.doobie
-        val ed25519Java                = "net.i2p.crypto"   % "eddsa"                            % "0.3.0"
-        val flywayCore                 = "org.flywaydb"     % "flyway-core"                      % Version.flyway
-        val flywayPostgreSQL           = "org.flywaydb"     % "flyway-database-postgresql"       % Version.flyway
-        val fs2Core                    = "co.fs2"          %% "fs2-core"                         % Version.fs2
-        val fs2IO                      = "co.fs2"          %% "fs2-io"                           % Version.fs2
-        val http4sCirce                = "org.http4s"      %% "http4s-circe"                     % Version.http4s
-        val http4sCore                 = "org.http4s"      %% "http4s-core"                      % Version.http4s
-        val http4sDsl                  = "org.http4s"      %% "http4s-dsl"                       % Version.http4s
-        val http4sEmberServer          = "org.http4s"      %% "http4s-ember-server"              % Version.http4s
-        val http4sEmberClient          = "org.http4s"      %% "http4s-ember-client"              % Version.http4s
-        val ip4sCore                   = "com.comcast"     %% "ip4s-core"                        % Version.ip4s
-        val jansi                = "com.github.Osiris-Team"       % "jansi"                  % Version.jansi
-        val jclOverSlf4j         = "org.slf4j"                    % "jcl-over-slf4j"         % Version.jclOverSlf4j
-        val log4catsSlf4j        = "org.typelevel"               %% "log4cats-slf4j"         % Version.log4cats
-        val logback              = "ch.qos.logback"               % "logback-classic"        % Version.logback
-        val munit                = "org.scalameta"               %% "munit"                  % Version.munit
-        val munitCatsEffect      = "org.typelevel"               %% "munit-cats-effect"      % Version.munitCatsEffect
-        val munitScalaCheck      = "org.scalameta"               %% "munit-scalacheck"       % Version.munit
-        val osLib                = "com.lihaoyi"                 %% "os-lib"                 % Version.osLib
-        val postgresql           = "org.postgresql"               % "postgresql"             % Version.postgresql
-        val pureConfig           = "com.github.pureconfig"       %% "pureconfig-core"        % Version.pureConfig
-        val scalaCheck           = "org.scalacheck"              %% "scalacheck"             % Version.scalaCheck
-        val simpleJavaMail       = "org.simplejavamail"           % "simple-java-mail"       % Version.simpleJavaMail
+        val apacheSshdCore       = "org.apache.sshd"        % "sshd-core"                  % Version.apacheSshd
+        val apacheSshdSftp       = "org.apache.sshd"        % "sshd-sftp"                  % Version.apacheSshd
+        val apacheSshdScp        = "org.apache.sshd"        % "sshd-scp"                   % Version.apacheSshd
+        val bouncyCastleProvider = "org.bouncycastle"       % "bcprov-jdk15to18"           % Version.bouncyCastle
+        val catsCore             = "org.typelevel"         %% "cats-core"                  % Version.cats
+        val catsEffect           = "org.typelevel"         %% "cats-effect"                % Version.catsEffect
+        val circeCore            = "io.circe"              %% "circe-core"                 % Version.circe
+        val circeGeneric         = "io.circe"              %% "circe-generic"              % Version.circe
+        val circeParser          = "io.circe"              %% "circe-parser"               % Version.circe
+        val doobieCore           = "org.tpolecat"          %% "doobie-core"                % Version.doobie
+        val doobieHikari         = "org.tpolecat"          %% "doobie-hikari"              % Version.doobie
+        val doobiePostgres       = "org.tpolecat"          %% "doobie-postgres"            % Version.doobie
+        val doobieScalaTest      = "org.tpolecat"          %% "doobie-scalatest"           % Version.doobie
+        val ed25519Java          = "net.i2p.crypto"         % "eddsa"                      % "0.3.0"
+        val flywayCore           = "org.flywaydb"           % "flyway-core"                % Version.flyway
+        val flywayPostgreSQL     = "org.flywaydb"           % "flyway-database-postgresql" % Version.flyway
+        val fs2Core              = "co.fs2"                %% "fs2-core"                   % Version.fs2
+        val fs2IO                = "co.fs2"                %% "fs2-io"                     % Version.fs2
+        val http4sCirce          = "org.http4s"            %% "http4s-circe"               % Version.http4s
+        val http4sCore           = "org.http4s"            %% "http4s-core"                % Version.http4s
+        val http4sDsl            = "org.http4s"            %% "http4s-dsl"                 % Version.http4s
+        val http4sEmberServer    = "org.http4s"            %% "http4s-ember-server"        % Version.http4s
+        val http4sEmberClient    = "org.http4s"            %% "http4s-ember-client"        % Version.http4s
+        val ip4sCore             = "com.comcast"           %% "ip4s-core"                  % Version.ip4s
+        val jansi                = "com.github.Osiris-Team" % "jansi"                      % Version.jansi
+        val jclOverSlf4j         = "org.slf4j"              % "jcl-over-slf4j"             % Version.jclOverSlf4j
+        val laikaCore            = "org.typelevel"         %% "laika-core"                 % Version.laika
+        val log4catsSlf4j        = "org.typelevel"         %% "log4cats-slf4j"             % Version.log4cats
+        val logback              = "ch.qos.logback"         % "logback-classic"            % Version.logback
+        val munit                = "org.scalameta"         %% "munit"                      % Version.munit
+        val munitCatsEffect      = "org.typelevel"         %% "munit-cats-effect"          % Version.munitCatsEffect
+        val munitScalaCheck      = "org.scalameta"         %% "munit-scalacheck"           % Version.munit
+        val osLib                = "com.lihaoyi"           %% "os-lib"                     % Version.osLib
+        val postgresql           = "org.postgresql"         % "postgresql"                 % Version.postgresql
+        val pureConfig           = "com.github.pureconfig" %% "pureconfig-core"            % Version.pureConfig
+        val scalaCheck           = "org.scalacheck"        %% "scalacheck"                 % Version.scalaCheck
+        val simpleJavaMail       = "org.simplejavamail"     % "simple-java-mail"           % Version.simpleJavaMail
         val springSecurityCrypto = "org.springframework.security" % "spring-security-crypto" % Version.springSecurity
     }
 
diff -rN -u old-smederee/modules/html-utils/src/main/scala/de/smederee/html/ContentRenderer.scala new-smederee/modules/html-utils/src/main/scala/de/smederee/html/ContentRenderer.scala
--- old-smederee/modules/html-utils/src/main/scala/de/smederee/html/ContentRenderer.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/html-utils/src/main/scala/de/smederee/html/ContentRenderer.scala	2025-01-11 17:45:25.735881676 +0000
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.html
+
+import java.util.Locale
+
+import cats.Eq
+import cats.data.*
+import cats.syntax.all.*
+import laika.api.MarkupParser
+import laika.api.Renderer
+import laika.ast.*
+import laika.ast.InternalTarget.Resolved
+import laika.config.LinkValidation
+import laika.config.MessageFilter
+import laika.format.HTML
+import laika.format.Markdown
+import laika.format.ReStructuredText
+import org.http4s.Uri
+import org.slf4j.LoggerFactory
+
+import scala.util.matching.Regex
+
+/** An enumeration of content that may be passed into a [[ContentRenderer]].
+  */
+enum RenderableContent {
+    case Markdown
+    case ReStructuredText
+}
+
+object RenderableContent {
+    given Eq[RenderableContent] = Eq.fromUniversalEquals
+
+    private val mappedExtensions: Map[RenderableContent, NonEmptyList[String]] = Map(
+        Markdown         -> NonEmptyList.of(".md", ".markdown"),
+        ReStructuredText -> NonEmptyList.of(".rst")
+    )
+
+    /** Parse the given URI that should point to a file that might be renderable and return the matching renderable
+      * content type.
+      *
+      * @param fileUri
+      *   URI to a file that might be renderable.
+      * @return
+      *   An option to the renderable content type of the file.
+      */
+    def fromUri(fileUri: Uri): Option[RenderableContent] = {
+        val path = fileUri.path.toString.toLowerCase(Locale.ROOT)
+        mappedExtensions.find((_, extensions) => extensions.exists(ext => path.endsWith(ext))).map(_._1)
+    }
+}
+
+object ContentRenderer {
+    private val log = LoggerFactory.getLogger(getClass)
+
+    /** Render the given source using the given content type into HTML.
+      *
+      * @param localUrlPathPrefix
+      *   An optional URL path that shall be prepended before any local (relative) links.
+      * @param contentType
+      *   The renderable content type that defines which renderer implementation will be used.
+      * @param source
+      *   Markup source code that shall be rendered.
+      * @return
+      *   Either the string containing the rendered HTML or an error message.
+      */
+    def render(
+        localUrlPathPrefix: Option[Uri.Path]
+    )(contentType: RenderableContent)(source: String): Either[String, String] = {
+        val parserBuilder = contentType match {
+            case RenderableContent.Markdown =>
+                log.debug("Rendering markdown.")
+                MarkupParser.of(Markdown).using(Markdown.GitHubFlavor).failOnMessages(MessageFilter.None)
+            case RenderableContent.ReStructuredText =>
+                log.debug("Rendering reStructuredText")
+                MarkupParser.of(ReStructuredText).failOnMessages(MessageFilter.None)
+        }
+        val config = parserBuilder.config.withConfigValue(LinkValidation.Local)
+        val parser = parserBuilder.build
+
+        @SuppressWarnings(Array("scalafix:DisableSyntax.null"))
+        val renderer = Renderer
+            .of(HTML)
+            .withConfig(config)
+            .rendering { case (fmt, link: SpanLink) =>
+                val modifiedLink = link.target match {
+                    case Resolved(absolute, relative, formats) =>
+                        log.debug(s"Found possible to correct local URI: $absolute, $relative, $formats")
+                        val renderedLink = link.target.render(internalTargetsAbsolute = false)
+                        localUrlPathPrefix.match {
+                            case None => link
+                            case Some(pathPrefix) =>
+                                if (renderedLink.startsWith(pathPrefix.toString))
+                                    link
+                                else {
+                                    val correctedLink =
+                                        link.withTarget(Target.parse(pathPrefix.toString |+| "/" |+| renderedLink))
+                                    log.debug(s"Corrected URI for local link rendering: $link -> $correctedLink")
+                                    correctedLink
+                                }
+                        }
+                    case _ =>
+                        link
+                }
+                fmt.element("a", modifiedLink, "href" -> modifiedLink.target.render(internalTargetsAbsolute = false))
+            }
+            .rendering { case (fmt, text: Text) =>
+                ToDoTextCssMapping.isToDoItem.findFirstMatchIn(text.content) match {
+                    case None => text.content
+                    case Some(matchedItem) =>
+                        if (matchedItem.group(4) === null) {
+                            val prefix = matchedItem.group(1)
+                            val item   = matchedItem.group(2)
+                            val suffix = matchedItem.group(3)
+                            log.debug(
+                                s"Found TODO item: $text with extracted prefix: $prefix, item: $item and suffix: $suffix."
+                            )
+                            val cssClass = ToDoTextCssMapping.todoCssClasses(item.toLowerCase(Locale.ROOT))
+                            fmt.text(prefix) |+| fmt.textElement(
+                                "span",
+                                text.copy(content = item |+| ":"),
+                                "class" -> cssClass
+                            ) |+| fmt.text(suffix)
+                        } else {
+                            val item = matchedItem.group(4)
+                            log.debug(s"Found TODO item: $text with extracted item: $item.")
+                            val cssClass = ToDoTextCssMapping.todoCssClasses(item.toLowerCase(Locale.ROOT))
+                            fmt.textElement("span", text.copy(content = item), "class" -> cssClass)
+                        }
+                }
+            }
+            .build
+        parser.parse(source).flatMap(renderer.render).leftMap(_.message)
+    }
+}
+
+object ToDoTextCssMapping {
+    /* We want to match either on specific words followed by a colon (`:`) and also on complete text nodes
+     * which can be created if markdown rendering is involved.
+     * For example a `**WORD** some text` will produce two text nodes (`WORD` and ` some text`)
+     * while `WORD: some text` will only produce one.
+     */
+    val isToDoItem: Regex = "(?i)(.*)(DEBUG|FIXME|HACK|TODO):(.*)|^(DEBUG|FIXME|HACK|TODO)$".r
+
+    // Mapping of todo item (words) to css classes for highlighting.
+    val todoCssClasses: Map[String, String] = Map(
+        "todo"  -> "todo-default",
+        "fixme" -> "todo-error",
+        "debug" -> "todo-info",
+        "hack"  -> "todo-warning"
+    ).withDefaultValue("todo-default")
+}
diff -rN -u old-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala new-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala
--- old-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/modules/html-utils/src/main/scala/de/smederee/html/MarkdownRenderer.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,192 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.html
-
-import java.util.Locale
-
-import cats.syntax.all.*
-import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
-import org.commonmark.ext.gfm.tables.TablesExtension
-import org.commonmark.ext.heading.anchor.HeadingAnchorExtension
-import org.commonmark.ext.task.list.items.TaskListItemsExtension
-import org.commonmark.node.*
-import org.commonmark.parser.Parser
-import org.commonmark.renderer.NodeRenderer
-import org.commonmark.renderer.html.*
-import org.http4s.Uri
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-
-import scala.annotation.nowarn
-import scala.jdk.CollectionConverters.*
-import scala.util.matching.Regex
-
-object MarkdownRenderer {
-    private val log = LoggerFactory.getLogger(getClass())
-
-    private val MarkdownExtensions =
-        List(
-            HeadingAnchorExtension.create(),
-            StrikethroughExtension.create(),
-            TablesExtension.create(),
-            TaskListItemsExtension.create()
-        )
-
-    /** Render the given markdown content into HTML.
-      *
-      * @param markdownSource
-      *   Markdown source code that shall be rendered.
-      * @return
-      *   A string containing the rendered markdown (HTML).
-      */
-    def render(markdownSource: String): String = {
-        val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
-        val markdown = parser.parse(markdownSource)
-        val renderer = HtmlRenderer
-            .builder()
-            .escapeHtml(true)
-            .extensions(MarkdownExtensions.asJava)
-            .sanitizeUrls(true)
-            .build()
-        renderer.render(markdown)
-    }
-
-    /** Render the given markdown sources and adjust all relative links by prefixing them with the path for the
-      * repostiory file browsing (`repo-name/files`).
-      *
-      * @param repositoryName
-      *   The name of the repository which contains the markdown sources. If the option is empty then no URI path prefix
-      *   will be set.
-      * @param markdownSource
-      *   A string containing the markdown sources to be rendered (usually the content of the README.md file in the
-      *   repository root).
-      * @return
-      *   A string containing the rendered markdown (HTML).
-      */
-    def renderRepositoryMarkdownFile(repositoryName: Option[String])(markdownSource: String): String = {
-        val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
-        val markdown = parser.parse(markdownSource)
-        val renderer = HtmlRenderer
-            .builder()
-            .attributeProviderFactory(new AttributeProviderFactory {
-                override def create(context: AttributeProviderContext): AttributeProvider =
-                    new LinkHrefCorrector(repositoryName)
-            })
-            .escapeHtml(true)
-            .extensions(MarkdownExtensions.asJava)
-            .nodeRendererFactory(new HtmlNodeRendererFactory {
-                override def create(context: HtmlNodeRendererContext): NodeRenderer = new ToDoTextRenderer(context)
-            })
-            .sanitizeUrls(true)
-            .build()
-        renderer.render(markdown)
-    }
-
-    /** A helper class used by the `renderRepositoryOverviewReadme` function to adjust the `href` attribute of links
-      * that are not absolute.
-      *
-      * @param repositoryName
-      *   The name of the repository which contains the markdown sources. If the option is empty then no URI path prefix
-      *   will be set.
-      */
-    @SuppressWarnings(
-        Array("scalafix:DisableSyntax.isInstanceOf")
-    ) // TODO: Find a way to get rid of the isInstanceOf below.
-    @nowarn("msg=discarded non-Unit value.*")
-    class LinkHrefCorrector(private val repositoryName: Option[String]) extends AttributeProvider {
-        override def setAttributes(node: Node, tagName: String, attributes: java.util.Map[String, String]): Unit =
-            if (node.isInstanceOf[Link]) {
-                (repositoryName, attributes.asScala.get("href").flatMap(href => Uri.fromString(href).toOption)).mapN {
-                    case (repositoryName, uri) =>
-                        if (uri.scheme.isEmpty) {
-                            val pathPrefix =
-                                Uri.Path(Vector(Uri.Path.Segment(repositoryName), Uri.Path.Segment("files")))
-                            val correctedUri =
-                                if (uri.path.startsWith(pathPrefix))
-                                    uri
-                                else
-                                    uri.copy(path = pathPrefix |+| uri.path)
-                            log.debug(s"Corrected URI for repository overview README rendering: $uri -> $correctedUri")
-                            val _ = attributes.put("href", correctedUri.toString)
-                        }
-                }
-            }
-    }
-
-    /** A custom text node renderer which is supposed to highlight several words which are considered "todo items". This
-      * is currently a very limited approach with a fixed list of matching words.
-      *
-      * @param context
-      *   A context for an html node renderer that is needed to extract the html writer from it.
-      */
-    class ToDoTextRenderer(context: HtmlNodeRendererContext) extends NodeRenderer {
-        import ToDoTextCssMapping.*
-
-        private final val htmlWriter: HtmlWriter = context.getWriter()
-        private final val log: Logger            = LoggerFactory.getLogger(getClass())
-
-        override def getNodeTypes(): java.util.Set[Class[? <: Node]] = Set(classOf[Text]).asJava
-
-        @SuppressWarnings(Array("scalafix:DisableSyntax.null", "scalafix:DisableSyntax.asInstanceOf"))
-        override def render(node: Node): Unit = {
-            val text = node.asInstanceOf[Text] // We only receive text nodes (see `getNodeTypes`).
-            isToDoItem.findFirstMatchIn(text.getLiteral()) match {
-                case Some(matchedItem) =>
-                    log.debug(s"Matched TODO item: ${text.getLiteral()} (${matchedItem.groupCount})")
-                    if (matchedItem.group(4) === null) {
-                        val prefix   = matchedItem.group(1)
-                        val item     = matchedItem.group(2)
-                        val suffix   = matchedItem.group(3)
-                        val cssClass = todoCssClasses(item.toLowerCase(Locale.ROOT))
-                        htmlWriter.text(prefix)
-                        htmlWriter.tag("span", Map("class" -> cssClass).asJava)
-                        htmlWriter.text(item + ":")
-                        htmlWriter.tag("/span")
-                        htmlWriter.text(suffix)
-                    } else {
-                        val item     = matchedItem.group(4)
-                        val cssClass = todoCssClasses(item.toLowerCase(Locale.ROOT))
-                        htmlWriter.tag("span", Map("class" -> cssClass).asJava)
-                        htmlWriter.text(item)
-                        htmlWriter.tag("/span")
-                    }
-                case _ =>
-                    htmlWriter.text(text.getLiteral())
-            }
-        }
-
-    }
-}
-
-object ToDoTextCssMapping {
-    /* We want to match either on specific words followed by a colon (`:`) and also on complete text nodes
-     * which can be created if markdown rendering is involved.
-     * For example a `**WORD** some text` will produce two text nodes (`WORD` and ` some text`)
-     * while `WORD: some text` will only produce one.
-     */
-    val isToDoItem: Regex = "(?i)(.*)(DEBUG|FIXME|HACK|TODO):(.*)|^(DEBUG|FIXME|HACK|TODO)$".r
-
-    // Mapping of todo item (words) to css classes for highlighting.
-    val todoCssClasses: Map[String, String] = Map(
-        "todo"  -> "todo-default",
-        "fixme" -> "todo-error",
-        "debug" -> "todo-info",
-        "hack"  -> "todo-warning"
-    ).withDefaultValue("todo-default")
-
-}
diff -rN -u old-smederee/modules/html-utils/src/test/scala/de/smederee/html/RenderableContentTest.scala new-smederee/modules/html-utils/src/test/scala/de/smederee/html/RenderableContentTest.scala
--- old-smederee/modules/html-utils/src/test/scala/de/smederee/html/RenderableContentTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/html-utils/src/test/scala/de/smederee/html/RenderableContentTest.scala	2025-01-11 17:45:25.735881676 +0000
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.html
+
+import cats.syntax.all.*
+import org.http4s.Uri
+import org.http4s.implicits.*
+
+import munit.*
+
+final class RenderableContentTest extends ScalaCheckSuite {
+    private val baseUri            = uri"https://www.example.com/some/path"
+    private val markdownExtensions = List(".md", ".MD", ".markdown", ".MARKDOWN")
+    private val rstExtensions      = List(".rst", ".RST")
+
+    test("fromUri must recognize markdown files") {
+        markdownExtensions.foreach(ext =>
+            assert(
+                RenderableContent.fromUri(baseUri.addSegment(s"readme${ext}")).exists(_ === RenderableContent.Markdown)
+            )
+        )
+    }
+
+    test("fromUri must recognize reStructuredText files") {
+        rstExtensions.foreach(ext =>
+            assert(
+                RenderableContent
+                    .fromUri(baseUri.addSegment(s"readme${ext}"))
+                    .exists(_ === RenderableContent.ReStructuredText)
+            )
+        )
+    }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-01-11 17:45:25.735881676 +0000
@@ -19,15 +19,14 @@
 
 import java.io.IOException
 import java.nio.file.*
-import java.util.Locale
 
 import cats.*
 import cats.data.*
 import cats.effect.*
 import cats.syntax.all.*
 import de.smederee.darcs.*
+import de.smederee.html.*
 import de.smederee.html.LinkTools.*
-import de.smederee.html.MarkdownRenderer
 import de.smederee.hub.RelatedTypesConverter.given
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config.*
@@ -472,20 +471,19 @@
             )
             readmeData <- repo.traverse(repo => doLoadReadme(repo))
             readme <- readmeData match {
-                case Some((lines, Some(filename))) =>
-                    if (filename.matches("(?iu).*\\.(md|markdown)$")) {
-                        Sync[F]
-                            .delay(
-                                MarkdownRenderer
-                                    .renderRepositoryMarkdownFile(repo.map(_.name.toString))(lines.mkString("\n"))
+                case Some((lines, Some(_), Some(renderableContent))) =>
+                    Sync[F].delay(
+                        ContentRenderer
+                            .render(Uri.fromString(s"$repositoryName/files").toOption.map(_.path))(renderableContent)(
+                                lines.mkString("\n")
                             )
-                            .map(_.some)
-                    } else {
-                        Sync[F].delay(lines.mkString("\n").some)
-                    }
+                            .toOption
+                    )
+                case Some((lines, Some(_), None)) =>
+                    Sync[F].delay(lines.mkString("\n").some)
                 case _ => Sync[F].delay(None)
             }
-            readmeName = readmeData.flatMap(_._2)
+            readmeContent = readmeData.flatMap(_._3)
             parentFork <- repo match {
                 case None       => Sync[F].pure(None)
                 case Some(repo) => vcsMetadataRepo.findVcsRepositoryParentFork(repo.owner, repo.name)
@@ -506,7 +504,7 @@
                             vcsRepositoryHistory = patches,
                             vcsRepositoryParentFork = parentFork,
                             vcsRepositoryReadme = readme,
-                            vcsRepositoryReadmeFilename = readmeName,
+                            vcsRepositoryReadmeContent = readmeContent,
                             vcsRepositorySshUri = sshUri
                         )
                     )
@@ -615,16 +613,16 @@
                     Uri(path = Uri.Path(actionBaseUri.path.segments.reverse.drop(1).reverse))
                 )
             )
-            fileContent <- content.isEmpty match {
-                case false =>
-                    if (actionBaseUri.path.toString.toLowerCase(java.util.Locale.ROOT).endsWith(".md"))
-                        Sync[F].delay(
-                            List(MarkdownRenderer.renderRepositoryMarkdownFile(None)(content.mkString("\n")))
-                        )
-                    else
-                        Sync[F].delay(content.toList)
-                case true =>
-                    Sync[F].pure(List.empty)
+            renderableContent = RenderableContent.fromUri(actionBaseUri)
+            fileContent <- renderableContent match {
+                case None => Sync[F].delay(content.toList)
+                case Some(contentType) =>
+                    Sync[F].delay(
+                        ContentRenderer
+                            .render(None)(contentType)(content.mkString("\n"))
+                            .map(html => List(html))
+                            .getOrElse(List.empty)
+                    )
             }
             branches <- repoAndId.map(_._2) match {
                 case Some(repoId) => vcsMetadataRepo.findVcsRepositoryBranches(repoId).compile.toList
@@ -651,7 +649,7 @@
                                     linkToTicketService,
                                     title.some,
                                     user
-                                )(fileContent, listing, repositoryBaseUri, repo, branches)
+                                )(fileContent, renderableContent, listing, repositoryBaseUri, repo, branches)
                             )
                 }
         } yield resp
@@ -957,9 +955,10 @@
       * @param repo
       *   A repository in which we should search.
       * @return
-      *   A tuple containing a list of strings (lines) that may be empty and an option to the file name.
+      *   A tuple containing a list of strings (lines) that may be empty, an option to the file name and an option to
+      *   the renderable content type.
       */
-    private def doLoadReadme(repo: VcsRepository): F[(Vector[String], Option[String])] =
+    private def doLoadReadme(repo: VcsRepository): F[(Vector[String], Option[String], Option[RenderableContent])] =
         for {
             path <- Sync[F].delay(
                 Paths.get(
@@ -971,7 +970,8 @@
             _     <- Sync[F].delay(log.debug(s"Trying to find README file in $path."))
             files <- Sync[F].delay(os.list(os.Path(path)))
             readme = files.find(_.last.matches("(?iu)^readme(\\..+)?$"))
-            _    <- Sync[F].delay(log.debug(s"Found README at $readme."))
+            _ <- Sync[F].delay(log.debug(s"Found README at $readme."))
+            renderableContent = readme.flatMap(path => RenderableContent.fromUri(Uri.unsafeFromString(path.toString)))
             size <- readme.traverse(path => Sync[F].delay(os.size(path)))
             stream <- readme.traverse { path =>
                 if (size.getOrElse(0L) <= MaximumFileSize)
@@ -980,7 +980,7 @@
                     Sync[F].delay(os.Generator(""))
             }
             lines <- Sync[F].delay(stream.map(_.toVector).getOrElse(Vector.empty))
-        } yield (lines, readme.map(_.last))
+        } yield (lines, readme.map(_.last), renderableContent)
 
     private val cloneRepository: HttpRoutes[F] = HttpRoutes.of {
         case req @ GET -> UsernamePathParameter(repositoryOwnerName) /: VcsRepositoryClonePathParameter(
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-11 17:45:25.735881676 +0000
@@ -104,7 +104,11 @@
                             projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)
                         )
                         renderedDescription <- Sync[F].delay(
-                            milestone.description.map(_.toString).map(MarkdownRenderer.render)
+                            milestone.description
+                                .map(_.toString)
+                                .flatMap(content =>
+                                    ContentRenderer.render(None)(RenderableContent.Markdown)(content).toOption
+                                )
                         )
                         resp <- Ok(
                             views.html.showMilestone(lang = language)(
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-11 17:45:25.735881676 +0000
@@ -146,7 +146,11 @@
                             )
                         )
                         renderedTicketContent <- Sync[F].delay(
-                            ticket.content.map(_.toString).map(MarkdownRenderer.render)
+                            ticket.content
+                                .map(_.toString)
+                                .flatMap(content =>
+                                    ContentRenderer.render(None)(RenderableContent.Markdown)(content).toOption
+                                )
                         )
                         resp <- Ok(
                             views.html.showTicket(lang = language)(
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryFiles.scala.html	2025-01-11 17:45:25.735881676 +0000
@@ -1,6 +1,7 @@
 @import java.util.Locale
-@import de.smederee.html.ToDoTextCssMapping._
-@import de.smederee.hub._
+@import de.smederee.html.RenderableContent
+@import de.smederee.html.ToDoTextCssMapping.*
+@import de.smederee.hub.*
 
 @(baseUri: Uri,
   lang: LanguageCode = LanguageCode("en")
@@ -11,6 +12,7 @@
   title: Option[String] = None,
   user: Option[Account]
 )(fileContent: List[String],
+  renderableContent: Option[RenderableContent],
   listing: IndexedSeq[(os.RelPath, os.StatInfo)],
   repositoryBaseUri: Uri,
   vcsRepository: VcsRepository,
@@ -67,40 +69,40 @@
             @for(link <- goBackUri) {
               <a href="@link">@icon(baseUri)("chevron-up")</a>&nbsp;<a href="@link">..</a>
             }
-              @if(actionBaseUri.path.toString.toLowerCase(java.util.Locale.ROOT).endsWith(".md")) {
-                <div class="repository-markdown-file-content">
-                @for(content <- fileContent) {
-                  @Html(content)
-                }
-                </div>
-              } else {
-                <table class="pure-table">
-                  <thead>
-                  </thead>
-                  <tbody class="repository-file-content">
-                  @for(tuple <- fileContent.zipWithIndex) {
-                    @defining(tuple._1) { content =>
-                      @defining(tuple._2) { lineNumber =>
-                        @defining(isToDoItem.findFirstMatchIn(content)) { matchedItem =>
-                          @if(matchedItem.nonEmpty) {
-                            @for(todoItem <- matchedItem) {
-                              @defining(todoCssClasses(Option(todoItem.group(4)).getOrElse(todoItem.group(2)).toLowerCase(Locale.ROOT))) { cssClass =>
-                            <tr class="code-line @cssClass">
-                              }
+            @if(renderableContent.nonEmpty) {
+              <div class="repository-markdown-file-content">
+              @for(content <- fileContent) {
+                @Html(content)
+              }
+              </div>
+            } else {
+              <table class="pure-table">
+                <thead>
+                </thead>
+                <tbody class="repository-file-content">
+                @for(tuple <- fileContent.zipWithIndex) {
+                  @defining(tuple._1) { content =>
+                    @defining(tuple._2) { lineNumber =>
+                      @defining(isToDoItem.findFirstMatchIn(content)) { matchedItem =>
+                        @if(matchedItem.nonEmpty) {
+                          @for(todoItem <- matchedItem) {
+                            @defining(todoCssClasses(Option(todoItem.group(4)).getOrElse(todoItem.group(2)).toLowerCase(Locale.ROOT))) { cssClass =>
+                          <tr class="code-line @cssClass">
                             }
-                          } else {
-                            <tr class="code-line">
                           }
-                              <td class="code-line-number" id="L@lineNumber"><a href="#L@lineNumber">@lineNumber</a></td>
-                              <td class="code-line"><code class="code-line">@content</code></td>
-                            </tr>
+                        } else {
+                          <tr class="code-line">
                         }
+                            <td class="code-line-number" id="L@lineNumber"><a href="#L@lineNumber">@lineNumber</a></td>
+                            <td class="code-line"><code class="code-line">@content</code></td>
+                          </tr>
                       }
                     }
                   }
-                  </tbody>
-                </table>
-              }
+                }
+                </tbody>
+              </table>
+            }
           }
         </div>
       </div>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-11 17:45:25.731881668 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-01-11 17:45:25.735881676 +0000
@@ -1,4 +1,4 @@
-@import de.smederee.hub._
+@import de.smederee.hub.*
 
 @(baseUri: Uri,
   lang: LanguageCode = LanguageCode("en")
@@ -12,7 +12,7 @@
   vcsRepositoryHistory: List[VcsRepositoryPatchMetadata],
   vcsRepositoryParentFork: Option[VcsRepository] = None,
   vcsRepositoryReadme: Option[String] = None,
-  vcsRepositoryReadmeFilename: Option[String] = None,
+  vcsRepositoryReadmeContent: Option[RenderableContent] = None,
   vcsRepositorySshUri: Option[String] = None
 )
 @main(baseUri, lang)()(csrf, title, user) {
@@ -100,7 +100,7 @@
       <div class="pure-u-1 repo-summary-readme">
         <div class="l-box">
         @for(content <- vcsRepositoryReadme) {
-          @if(vcsRepositoryReadmeFilename.exists(_.matches("(?iu).*\\.(md|markdown)$"))) {
+          @if(vcsRepositoryReadmeContent.nonEmpty) {
             <div class="repository-markdown-file-content">
             @Html(content)
             </div>