Repository: eclipse-esmf/esmf-sdk
Pull request #759: Add support for markdown formatting in descriptions
Author: login: Yauhenikapl
Created: 2025-05-14T11:38:47Z | Merged: 2025-05-19T11:43:41Z

## Description

Add support for markdown formatting in descriptions.

Fixes #723 

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## Checklist:

- [X] My code follows the style guidelines of this project
- [X] I have performed a self-review of my own code
- [X] I have commented my code, particularly in hard-to-understand areas
- [X] I have made corresponding changes to the documentation
- [X] My changes generate no new warnings
- [X] I have added tests that prove my fix is effective or that my feature works

## Additional notes


Linked issue #723: [Task] Support for Markdown formatting in descriptions
**Is your task related to a problem? Please describe.**
Since SAMM 2.2.0, Markdown [can be used](https://eclipse-esmf.github.io/samm-specification/snapshot/appendix/best-practices.html#markdown-where-needed) in a limited way in multiline `samm:description`s. The SDK should support this:
* The Java API should provide reasonable access to the description's content structure 
* The HTML document generation and diagram generation should produce results that correspond to the formatting

Linked issue comments: user: login: github-actions[bot]
created_at: 2025-05-19T12:45:04Z
body: Release [v2.10.4](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.10.4) addresses this.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T05:19:05Z
body: Release [v2.10.4](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.10.4) addresses this.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-06-20T12:04:32Z
body: Release [v2.11.0](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.11.0) addresses this.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-06-23T05:58:36Z
body: Release [v2.11.0](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.11.0) addresses this.
author_association: NONE

Diff:
diff --git a/core/esmf-aspect-meta-model-java/pom.xml b/core/esmf-aspect-meta-model-java/pom.xml
index 9fc1f3d83..b054dd58e 100644
--- a/core/esmf-aspect-meta-model-java/pom.xml
+++ b/core/esmf-aspect-meta-model-java/pom.xml
@@ -63,6 +63,10 @@
          <artifactId>record-builder-processor</artifactId>
          <scope>provided</scope>
       </dependency>
+      <dependency>
+         <groupId>org.commonmark</groupId>
+         <artifactId>commonmark</artifactId>
+      </dependency>
 
       <!-- Test dependencies -->
       <dependency>
diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtils.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtils.java
new file mode 100644
index 000000000..ce43a17fc
--- /dev/null
+++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtils.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
+ *
+ * See the AUTHORS file(s) distributed with this work for additional
+ * information regarding authorship.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+package org.eclipse.esmf.aspectmodel.utils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Utility class for extracting and rendering structured content blocks (such as NOTE, EXAMPLE, SOURCE)
+ * from SAMM-compliant Markdown descriptions.
+ *
+ * <p>This class supports parsing multi-line Markdown-style input and extracting semantically significant
+ * sections such as {@code > NOTE: ...}, {@code > EXAMPLE: ...}, and {@code > SOURCE: ...}.
+ * These blocks can be retrieved as plain text or rendered into HTML using {@link MarkdownHtmlRenderer}.
+ */
+public class DescriptionsUtils {
+
+   private DescriptionsUtils() {
+   }
+
+   /**
+    * A regex pattern used to identify special SAMM-style Markdown blocks.
+    * Matches lines beginning with {@code > NOTE:}, {@code > EXAMPLE:}, or {@code > SOURCE:},
+    * optionally followed by a number (e.g., {@code > EXAMPLE 2: ...}).
+    */
+   static final Pattern BLOCK_PATTERN = Pattern.compile(
+         "^>\\s*(NOTE|EXAMPLE|SOURCE)(\\s+\\d+)?:\\s*(.*)",
+         Pattern.CASE_INSENSITIVE
+   );
+
+   /**
+    * Extracts all {@code NOTE} blocks from the given set of Markdown description strings.
+    *
+    * @param description A line Markdown description.
+    * @return A list of extracted NOTE block contents.
+    */
+   public static List<String> notes( final String description ) {
+      return extractBlock( description, "NOTE" );
+   }
+
+   /**
+    * Extracts all {@code EXAMPLE} blocks from the given set of Markdown description strings.
+    *
+    * @param description A line Markdown description.
+    * @return A list of extracted EXAMPLE block contents.
+    */
+   public static List<String> examples( final String description ) {
+      return extractBlock( description, "EXAMPLE" );
+   }
+
+   /**
+    * Extracts all {@code SOURCE} blocks from the given set of Markdown description strings.
+    *
+    * @param description A line Markdown description.
+    * @return A list of extracted SOURCE block contents.
+    */
+   public static List<String> sources( final String description ) {
+      return extractBlock( description, "SOURCE" );
+   }
+
+   /**
+    * Renders the given set of Markdown description strings into semantic HTML.
+    * Uses {@link MarkdownHtmlRenderer} to process both special blocks and general Markdown syntax.
+    *
+    * @param description A line of Markdown description string.
+    * @return The HTML representation of the combined input.
+    */
+   public static String toHtml( final String description ) {
+      return MarkdownHtmlRenderer.renderHtmlFromDescriptions( description );
+   }
+
+   /**
+    * Extracts all blocks of a specified type (e.g., NOTE, EXAMPLE, SOURCE) from a set of Markdown strings.
+    *
+    * <p>Each block is expected to begin with a {@code > TYPE:} line and may span multiple lines,
+    * each of which begins with {@code >}.
+    *
+    * @param description A line Markdown description string.
+    * @param type The type of block to extract ("NOTE", "EXAMPLE", or "SOURCE").
+    * @return A list of extracted block contents for the specified type.
+    */
+   private static List<String> extractBlock( final String description, final String type ) {
+      List<String> result = new ArrayList<>();
+      extractFromDescription( stripIndent( description ), type, result );
+      return result;
+   }
+
+   private static void extractFromDescription( final String desc, final String type, final List<String> result ) {
+      String[] lines = desc.split( "\\R" );
+      boolean[] insideBlock = { false };
+      StringBuilder blockContent = new StringBuilder();
+
+      for ( String line : lines ) {
+         handleLine( line, type, insideBlock, blockContent, result );
+      }
+
+      if ( insideBlock[0] && !blockContent.isEmpty() ) {
+         result.add( blockContent.toString().strip() );
+      }
+   }
+
+   private static void handleLine( final String line, final String type, boolean[] insideBlock,
+         StringBuilder blockContent, List<String> result ) {
+      Matcher matcher = BLOCK_PATTERN.matcher( line );
+      if ( matcher.find() ) {
+         String currentType = matcher.group( 1 ).toUpperCase();
+         String content = matcher.group( 3 ); // Corrected: group(3) is the actual content
+
+         flushBlock( insideBlock, blockContent, result );
+
+         if ( currentType.equals( type.toUpperCase() ) ) {
+            blockContent.append( content ).append( "\n" );
+            insideBlock[0] = true;
+         } else {
+            insideBlock[0] = false;
+         }
+      } else if ( insideBlock[0] && line.startsWith( ">" ) ) {
+         blockContent.append( line.substring( 1 ).stripLeading() ).append( "\n" );
+      } else if ( insideBlock[0] ) {
+         flushBlock( insideBlock, blockContent, result );
+      }
+   }
+
+   private static void flushBlock( boolean[] insideBlock, StringBuilder blockContent, List<String> result ) {
+      if ( insideBlock[0] && !blockContent.isEmpty() ) {
+         result.add( blockContent.toString().strip() );
+         blockContent.setLength( 0 );
+         insideBlock[0] = false;
+      }
+   }
+
+   static String stripIndent( final String string ) {
+      final int indent = string.lines()
+            .filter( line -> !line.isEmpty() )
+            .map( line -> line.indexOf( line.trim() ) )
+            .filter( offset -> offset > 0 )
+            .min( Integer::compareTo )
+            .orElse( 0 );
+      return string.lines()
+            .map( line -> indent <= line.length() ? line.substring( indent ) : line )
+            .collect( Collectors.joining( "\n" ) );
+   }
+}
+
diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/MarkdownHtmlRenderer.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/MarkdownHtmlRenderer.java
new file mode 100644
index 000000000..7725ccf0d
--- /dev/null
+++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/MarkdownHtmlRenderer.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
+ *
+ * See the AUTHORS file(s) distributed with this work for additional
+ * information regarding authorship.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+package org.eclipse.esmf.aspectmodel.utils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+
+/**
+ * A utility class for converting SAMM-flavored Markdown descriptions into HTML.
+ *
+ * <p>This renderer supports a limited subset of Markdown syntax and introduces
+ * custom processing for specific annotated blocks commonly used in SAMM descriptions,
+ * such as {@code > NOTE: ...}, {@code > EXAMPLE: ...}, and {@code > SOURCE: ...}.
+ * These blocks are extracted and rendered into semantically meaningful HTML
+ * structures (e.g., {@code <div class="note">}, {@code <ul class="example-list">}, etc.).
+ * Remaining content is rendered using the CommonMark parser.
+ */
+public class MarkdownHtmlRenderer {
+
+   private static final String CLOSE_DIV_TAG = "</div>";
+
+   /**
+    * A reusable CommonMark parser instance for processing standard Markdown syntax.
+    */
+   private static final Parser PARSER = Parser.builder().build();
+
+   /**
+    * A reusable CommonMark HTML renderer instance.
+    */
+   private static final HtmlRenderer RENDERER = HtmlRenderer.builder().build();
+
+   /**
+    * Private constructor to prevent instantiation. This class is intended to be used statically.
+    */
+   private MarkdownHtmlRenderer() {
+   }
+
+   /**
+    * Converts a set of multi-line Markdown descriptions into a single HTML string.
+    * Each entry in the set is processed independently and merged in the resulting output.
+    *
+    * @param description A line of Markdown description blocks to render.
+    * @return Combined HTML output representing all given descriptions.
+    */
+   public static String renderHtmlFromDescriptions( final String description ) {
+      return processSpecialBlocks( description ) + "\n";
+   }
+
+   /**
+    * Parses a single Markdown block:
+    * <ul>
+    *   <li>Identifies and extracts special block types: NOTE, EXAMPLE, and SOURCE</li>
+    *   <li>Renders those blocks using custom HTML wrappers</li>
+    *   <li>Processes the remaining Markdown using the CommonMark renderer</li>
+    * </ul>
+    *
+    * @param rawMarkdown The full Markdown string to process.
+    * @return The rendered HTML output.
+    */
+   private static String processSpecialBlocks( final String rawMarkdown ) {
+      String[] lines = stripLines( rawMarkdown );
+      StringBuilder markdownBuffer = new StringBuilder();
+      Map<String, List<String>> specialBlocks = collectSpecialBlocks( lines, markdownBuffer );
+
+      StringBuilder html = new StringBuilder();
+      specialBlocks.forEach( ( type, items ) -> html.append( renderSpecialBlock( type, items ) ) );
+
+      Node parsed = PARSER.parse( markdownBuffer.toString() );
+      html.append( RENDERER.render( parsed ) );
+      return html.toString();
+   }
+
+   /**
+    * Renders a list of extracted special blocks into HTML.
+    *
+    * <p>- For {@code NOTE} and {@code SOURCE}, each entry is rendered in a {@code <div>} with a matching class.<br>
+    *    - For {@code EXAMPLE}, a single example is rendered as a {@code <div>}; multiple examples as a {@code <ul>}.
+    *
+    * @param type  The type of the special block (e.g., "NOTE", "EXAMPLE", "SOURCE").
+    * @param items The list of block contents for that type.
+    * @return The rendered HTML string for the block.
+    */
+   private static String renderSpecialBlock( final String type, final List<String> items ) {
+      if ( items.isEmpty() ) {
+         return "";
+      }
+
+      return switch ( type ) {
+         case "NOTE", "SOURCE" -> items.stream()
+               .map( text -> "<div class=\"" + type.toLowerCase() + "\">"
+                     + renderMarkdownInline( text.strip() ) + CLOSE_DIV_TAG + "\n" )
+               .collect( Collectors.joining() );
+
+         case "EXAMPLE" -> {
+            if ( items.size() == 1 ) {
+               yield "<div class=\"example\">" + renderMarkdownInline( items.get( 0 ).strip() ) + CLOSE_DIV_TAG + "\n";
+            } else {
+               StringBuilder sb = new StringBuilder( "<ul class=\"example-list\">\n" );
+               for ( String item : items ) {
+                  sb.append( "<li>" ).append( renderMarkdownInline( item.strip() ) ).append( "</li>\n" );
+               }
+               sb.append( "</ul>\n" );
+               yield sb.toString();
+            }
+         }
+
+         default -> items.stream()
+               .map( text -> "<div class=\"block\">" + renderMarkdownInline( text.strip() ) + CLOSE_DIV_TAG + "\n" )
+               .collect( Collectors.joining() );
+      };
+   }
+
+   /**
+    * Collects all special block entries (NOTE, EXAMPLE, SOURCE) from the input lines.
+    * Lines not belonging to special blocks are appended to the {@code markdownBuffer}.
+    *
+    * @param lines          Stripped lines from the raw markdown block.
+    * @param markdownBuffer Buffer to store non-special markdown content.
+    * @return A map of special block types to their associated content.
+    */
+   private static Map<String, List<String>> collectSpecialBlocks( final String[] lines, final StringBuilder markdownBuffer ) {
+      Map<String, List<String>> specialBlocks = new LinkedHashMap<>();
+
+      String currentType = null;
+      StringBuilder block = new StringBuilder();
+
+      for ( String line : lines ) {
+         Matcher matcher = DescriptionsUtils.BLOCK_PATTERN.matcher( line );
+         if ( matcher.find() ) {
+            flushBlock( currentType, block, specialBlocks );
+            currentType = matcher.group( 1 ).toUpperCase();
+            block.append( matcher.group( 3 ) ).append( "\n" );
+         } else if ( currentType != null && line.startsWith( ">" ) ) {
+            block.append( line.substring( 1 ).stripLeading() ).append( "\n" );
+         } else {
+            flushBlock( currentType, block, specialBlocks );
+            currentType = null;
+            markdownBuffer.append( line ).append( "\n" );
+         }
+      }
+
+      flushBlock( currentType, block, specialBlocks );
+      return specialBlocks;
+   }
+
+   /**
+    * Flushes the current block to the target map if non-empty.
+    *
+    * @param currentType The type of block being collected.
+    * @param block       The current content buffer for the block.
+    * @param target      The target map of blocks.
+    */
+   private static void flushBlock( final String currentType, final StringBuilder block, final Map<String, List<String>> target ) {
+      if ( currentType != null && !block.isEmpty() ) {
+         target.computeIfAbsent( currentType, k -> new ArrayList<>() ).add( block.toString().strip() );
+         block.setLength( 0 );
+      }
+   }
+
+   /**
+    * Splits the raw markdown string into lines and strips leading whitespace from each line.
+    *
+    * @param rawMarkdown The original multi-line markdown string.
+    * @return An array of trimmed lines.
+    */
+   private static String[] stripLines( final String rawMarkdown ) {
+      String[] rawLines = rawMarkdown.split( "\\R", -1 );
+      String[] lines = new String[rawLines.length];
+      for ( int i = 0; i < rawLines.length; i++ ) {
+         lines[i] = rawLines[i].stripLeading();
+      }
+      return lines;
+   }
+
+   /**
+    * Renders a single markdown line (inline) to HTML using CommonMark.
+    * This is used for special blocks (e.g., NOTE/EXAMPLE/SOURCE) where
+    * markdown is allowed but not block-level structure.
+    *
+    * @param text Markdown content.
+    * @return HTML output as string.
+    */
+   private static String renderMarkdownInline( final String text ) {
+      Node node = PARSER.parse( text );
+      return RENDERER.render( node ).trim();
+   }
+}
+
+
diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/metamodel/builder/SammBuilder.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/metamodel/builder/SammBuilder.java
index 60ec982a3..a2d66f10c 100644
--- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/metamodel/builder/SammBuilder.java
+++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/metamodel/builder/SammBuilder.java
@@ -1389,12 +1389,12 @@ public static ScalarValue value( final double doubleValue ) {
    /* Intentionally no value(int) method here, because an int value could imply different XSD types */
 
    public static ScalarValue value( final Object value, final Scalar type ) {
-      MetaModelBaseAttributes metaModelBaseAttributes;
+      final MetaModelBaseAttributes metaModelBaseAttributes;
 
-      if ( value instanceof ModelElement modelElement ) {
-         boolean hasUrn = modelElement.urn() != null;
+      if ( value instanceof final ModelElement modelElement ) {
+         final boolean hasUrn = modelElement.urn() != null;
 
-         MetaModelBaseAttributes.Builder builder = MetaModelBaseAttributes.builder()
+         final MetaModelBaseAttributes.Builder builder = MetaModelBaseAttributes.builder()
                .isAnonymous( !hasUrn );
 
          if ( hasUrn ) {
diff --git a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java
new file mode 100644
index 000000000..b9d058ee0
--- /dev/null
+++ b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java
@@ -0,0 +1,143 @@
+package org.eclipse.esmf.aspectmodel.utils;
+
+import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Locale;
+
+import org.eclipse.esmf.aspectmodel.AspectModelFile;
+import org.eclipse.esmf.metamodel.AspectModel;
+import org.eclipse.esmf.test.TestAspect;
+import org.eclipse.esmf.test.TestResources;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class DescriptionsUtilsTest {
+
+   private static String testDescription;
+
+   @BeforeAll
+   public static void init() {
+      final AspectModel aspectModel = TestResources.load( TestAspect.ASPECT_WITH_MARKDOWN_DESCRIPTION );
+      final AspectModelFile originalFile = aspectModel.files().iterator().next();
+      testDescription = originalFile.elements().getFirst().getDescription( Locale.ENGLISH );
+   }
+
+   @Test
+   void testExtractNotesSingleNote() {
+      final List<String> notes = DescriptionsUtils.notes( testDescription );
+      assertThat( notes ).hasSize( 1 );
+      assertEquals( "This is a note block.\nIt supports multiple lines.\nHere's a second line of the note.", notes.get( 0 ) );
+   }
+
+   @Test
+   void testExtractExamplesMultipleExamples() {
+      final List<String> examples = DescriptionsUtils.examples( testDescription );
+
+      assertEquals( 2, examples.size() );
+      assertEquals( "This is the first example block.\nIt can span several lines, and supports *italic* and **bold** text.",
+            examples.get( 0 ) );
+      assertEquals( "This is the second example.\nAlso multiline, for testing multiple example entries.", examples.get( 1 ) );
+   }
+
+   @Test
+   void testExtractExamplesMultipleExamplesWithBoldAndItalicText() {
+      final String html = DescriptionsUtils.toHtml( testDescription );
+
+      assertThat( html ).contains(
+            "This is the first example block.\nIt can span several lines, and supports <em>italic</em> and <strong>bold</strong> text." );
+   }
+
+   @Test
+   void testExtractSourcesWithLink() {
+      final List<String> sources = DescriptionsUtils.sources( testDescription );
+      assertEquals( 1, sources.size() );
+      assertThat( sources.get( 0 ) ).contains( "ISO 12345:2023, section 4.2.1\n" + "with an inline [link](https://www.example.com/spec)." );
+   }
+
+   @Test
+   void testMixedBlockTypes() {
+      assertEquals( 1, DescriptionsUtils.notes( testDescription ).size() );
+      assertEquals( 2, DescriptionsUtils.examples( testDescription ).size() );
+      assertEquals( 1, DescriptionsUtils.sources( testDescription ).size() );
+   }
+
+   @Test
+   void testNoBlocks() {
+      final String description = "This is a plain description without any special blocks.";
+      assertThat( DescriptionsUtils.notes( description ) ).isEmpty();
+      assertThat( DescriptionsUtils.examples( description ) ).isEmpty();
+      assertThat( DescriptionsUtils.sources( description ) ).isEmpty();
+   }
+
+   @Test
+   void testToHtmlWithAllBlockTypes() {
+      final String description = """
+            > NOTE: This is a note.
+            > With multiple lines.
+                
+            > EXAMPLE 1: First example.
+            > Additional example content.
+                
+            > EXAMPLE 2: Second example.
+                
+            > SOURCE: Source information here.
+                
+            Some **markdown** content here.
+            1. Ordered
+            2. List
+            """;
+
+      final String html = DescriptionsUtils.toHtml( testDescription );
+
+      assertThat( html ).contains( "<div class=\"note\">" );
+      assertThat( html ).contains( "This is a note block.\nIt supports multiple lines.\nHere's a second line of the note." );
+      assertTrue( html.contains( "<ul class=\"example-list\">" ) || html.contains( "<div class=\"example\">" ) );
+      assertThat( html ).contains(
+            "This is the first example block.\nIt can span several lines, and supports <em>italic</em> and <strong>bold</strong> text." );
+      assertThat( html ).contains( "<div class=\"source\">" );
+      assertThat( html ).contains( "ISO 12345:2023, section 4.2.1\nwith an inline <a href=\"https://www.example.com/spec\">link</a>." );
+      assertThat( html ).contains( "<ol>" );
+   }
+
+   @Test
+   void testMarkdownRenderingBulletList() {
+      String html = DescriptionsUtils.toHtml( testDescription );
+      assertThat( html ).contains( "<ul>" );
+      assertThat( html ).contains( "<li>Item A</li>" );
+      assertThat( html ).contains( "<li>Item B</li>" );
+      assertThat( html ).contains( "<li>Item C</li>" );
+   }
+
+   @Test
+   void testMarkdownRenderingOrderedList() {
+      String html = DescriptionsUtils.toHtml( testDescription );
+      assertThat( html ).contains( "<ol>" );
+      assertThat( html ).contains( "<li>First</li>" );
+      assertThat( html ).contains( "<li>Second</li>" );
+      assertThat( html ).contains( "<li>Third</li>" );
+   }
+
+   @Test
+   void testMarkdownRenderingWithLink() {
+      String html = DescriptionsUtils.toHtml( testDescription );
+      assertThat( html ).contains( "<a href=\"https://example.com\">Visit Example</a>" );
+   }
+
+   @Test
+   void testHtmlOutputDoesNotContainMarkdownSyntax() {
+      String html = DescriptionsUtils.toHtml( testDescription );
+      assertThat( html ).doesNotContain( "[Visit Example](https://example.com)" );
+   }
+
+   @Test
+   void testStripIndentSingleLine() {
+      String input = "    only one line";
+      String expected = "only one line";
+      String result = DescriptionsUtils.stripIndent( input );
+      assertEquals( expected, result );
+   }
+}
diff --git a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/metamodel/ModelSubtypingTest.java b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/metamodel/ModelSubtypingTest.java
index 58b811366..de5e51aee 100644
--- a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/metamodel/ModelSubtypingTest.java
+++ b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/metamodel/ModelSubtypingTest.java
@@ -18,7 +18,7 @@
 
 import org.junit.jupiter.api.Test;
 
-public class ModelSubtypingTest {
+class ModelSubtypingTest {
    @Test
    void testScalarCasting() {
       assertThat( xsd.byte_.isTypeOrSubtypeOf( xsd.integer ) ).isTrue();
diff --git a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGenerator.java b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGenerator.java
index 55a8b4dea..9d0a8668d 100644
--- a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGenerator.java
+++ b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGenerator.java
@@ -33,6 +33,7 @@
 import org.eclipse.esmf.aspectmodel.generator.diagram.AspectModelDiagramGenerator;
 import org.eclipse.esmf.aspectmodel.generator.diagram.DiagramGenerationConfig;
 import org.eclipse.esmf.aspectmodel.generator.diagram.DiagramGenerationConfigBuilder;
+import org.eclipse.esmf.aspectmodel.utils.DescriptionsUtils;
 import org.eclipse.esmf.aspectmodel.visitor.AspectStreamTraversalVisitor;
 import org.eclipse.esmf.metamodel.Aspect;
 import org.eclipse.esmf.metamodel.Scalar;
@@ -77,6 +78,7 @@ public Stream<DocumentationArtifact> generate() {
       final Map<String, Object> templateContext = new HashMap<>();
       templateContext.put( "aspectModel", aspect() );
       templateContext.put( "aspectModelHelper", new AspectModelHelper() );
+      templateContext.put( "descriptionsUtils", DescriptionsUtils.class );
       templateContext.put( "Scalar", Scalar.class );
 
       final Set<Locale> targetLanguages = config.locale() == null
diff --git a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/AspectModelDatabricksDenormalizedSqlVisitor.java b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/AspectModelDatabricksDenormalizedSqlVisitor.java
index 85756fbb0..cdc399694 100644
--- a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/AspectModelDatabricksDenormalizedSqlVisitor.java
+++ b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/AspectModelDatabricksDenormalizedSqlVisitor.java
@@ -165,17 +165,12 @@ public String visitStructureElement( final StructureElement structureElement, fi
       return result.toString();
    }
 
-   private String escapeComment( final String comment ) {
-      return comment.replace( "'", "\\'" );
-   }
-
    @Override
    public String visitAspect( final Aspect aspect, final Context context ) {
-
       final String columnDeclarations = visitStructureElement( aspect, context );
       final String comment = config.includeTableComment()
             ? Optional.ofNullable( aspect.getDescription( config.commentLanguage() ) ).map( description ->
-            "COMMENT '" + escapeComment( description ) + "'\n" ).orElse( "" )
+            new DatabricksCommentDefinition( description ) + "\n" ).orElse( "" )
             : "";
       return "%s %s (\n%s%s)\n%sTBLPROPERTIES ('%s'='%s');\n".formatted(
             config.createTableCommandPrefix(),
@@ -196,7 +191,7 @@ public String visitProperty( final Property property, final Context context ) {
       }
 
       return property.getCharacteristic().get().accept( this, context.copy()
-            .prefix( (context.prefix().isEmpty() ? "" : context.prefix() + LEVEL_DELIMITER) + columnName( property ) )
+            .prefix( ( context.prefix().isEmpty() ? "" : context.prefix() + LEVEL_DELIMITER ) + columnName( property ) )
             .currentProperty( property )
             .build() );
    }
@@ -242,7 +237,7 @@ public String visitCharacteristic( final Characteristic characteristic, final Co
 
    private String column( final String columnName, final String columnType, final boolean isNullable, final Optional<String> comment ) {
       return "%s %s%s".formatted( columnName, columnType, isNullable ? "" : " NOT NULL" )
-            + comment.map( args -> " COMMENT '%s'".formatted( escapeComment( args ) ) ).orElse( "" );
+            + comment.map( args -> " " + new DatabricksCommentDefinition( args ) ).orElse( "" );
    }
 
    @Override
@@ -278,7 +273,7 @@ public String visitCollection( final Collection collection, final Context contex
 
    private String processComplexType( final ComplexType entity, final Context context, final String parentPrefix,
          final boolean isDefaultList ) {
-      StringBuilder columns = new StringBuilder();
+      final StringBuilder columns = new StringBuilder();
       final String lineDelimiter = ",\n  ";
 
       entity.getAllProperties().forEach( property -> {
@@ -333,7 +328,7 @@ private DatabricksType.DatabricksStruct entityToStruct( final ComplexType entity
                   return Stream.empty();
                }
 
-               boolean isOptional = isInsideNestedType || property.isOptional();
+               final boolean isOptional = isInsideNestedType || property.isOptional();
 
                return Stream.of( new DatabricksType.DatabricksStructEntry( columnName( property ), databricksType,
                      isOptional, Optional.ofNullable( property.getDescription( config.commentLanguage() ) ) ) );
diff --git a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksColumnDefinition.java b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksColumnDefinition.java
index dde72421f..f5714bb72 100644
--- a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksColumnDefinition.java
+++ b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksColumnDefinition.java
@@ -38,6 +38,6 @@ public String toString() {
             name(),
             type(),
             nullable ? "" : " NOT NULL",
-            comment.map( c -> " COMMENT '" + c.replaceAll( "'", "\\\\'" ) + "'" ).orElse( "" ) );
+            comment.map( theComment -> " " + new DatabricksCommentDefinition( theComment ) ).orElse( "" ) );
    }
 }
diff --git a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksCommentDefinition.java b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksCommentDefinition.java
new file mode 100644
index 000000000..5c557a937
--- /dev/null
+++ b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksCommentDefinition.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
+ *
+ * See the AUTHORS file(s) distributed with this work for additional
+ * information regarding authorship.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+package org.eclipse.esmf.aspectmodel.generator.sql.databricks;
+
+public record DatabricksCommentDefinition( String comment ) {
+   @Override
+   public String toString() {
+      return "COMMENT '" + comment
+            .replace( "'", "\\'" )
+            .replace( System.lineSeparator(), "\\n" )
+            + "'";
+   }
+}
diff --git a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksType.java b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksType.java
index 6a86011e0..5f5269430 100644
--- a/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksType.java
+++ b/core/esmf-aspect-model-document-generators/src/main/java/org/eclipse/esmf/aspectmodel/generator/sql/databricks/DatabricksType.java
@@ -80,7 +80,7 @@ record DatabricksStructEntry( String name, DatabricksType type, boolean nullable
       @Override
       public String toString() {
          return name + ": " + type + ( nullable ? "" : " NOT NULL" )
-               + comment.map( c -> " COMMENT '%s'".formatted( c.replace( "'", "\\'" ) ) ).orElse( "" );
+               + comment.map( theComment -> " " + new DatabricksCommentDefinition( theComment ) ).orElse( "" );
       }
    }
 
diff --git a/core/esmf-aspect-model-document-generators/src/main/java/velocity_implicit.vm b/core/esmf-aspect-model-document-generators/src/main/java/velocity_implicit.vm
index efe0e43fc..3b521a67a 100644
--- a/core/esmf-aspect-model-document-generators/src/main/java/velocity_implicit.vm
+++ b/core/esmf-aspect-model-document-generators/src/main/java/velocity_implicit.vm
@@ -33,3 +33,4 @@
 #* @vtlvariable name="languageConstraint" type="org.eclipse.esmf.metamodel.constraint.LanguageConstraint" *#
 #* @vtlvariable name="list" type="org.eclipse.esmf.metamodel.characteristic.List" *#
 #* @vtlvariable name="code" type="org.eclipse.esmf.metamodel.characteristic.Code" *#
+#* @vtlvariable name="descriptionsUtils" type="org.eclipse.esmf.aspectmodel.utils.DescriptionsUtils" *#
diff --git a/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/common-documentation-lib.vm b/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/common-documentation-lib.vm
index 17a4dfec7..54cddb5db 100644
--- a/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/common-documentation-lib.vm
+++ b/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/common-documentation-lib.vm
@@ -37,5 +37,5 @@
 #end
 
 #macro ( description $description )
-<span class="text-base py-4">$description</span>
+<span class="text-base py-4">#evaluate( $description )</span>
 #end
diff --git a/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/property-documentation-lib.vm b/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/property-documentation-lib.vm
index d9a401c3d..2ee07b335 100644
--- a/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/property-documentation-lib.vm
+++ b/core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/property-documentation-lib.vm
@@ -17,7 +17,7 @@
     #paragraph( $property.getPreferredName( $i18n.getLocale() ) "${aspectModelHelper.buildAnchor( $property, $parentElement, 'property' )}" $weight )
 
     #if( $property.getDescription( $i18n.getLocale() ) )
-        #description( $property.getDescription( $i18n.getLocale() ) )
+        #description( $descriptionsUtils.toHtml( $property.getDescription( $i18n.getLocale() ) ) )
     #end
 
     <div class="flex border-b pb-1 py-4">
diff --git a/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGeneratorTest.java b/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGeneratorTest.java
index e3df0070c..c113a6826 100644
--- a/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGeneratorTest.java
+++ b/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGeneratorTest.java
@@ -20,18 +20,20 @@
 import java.io.IOException;
 import java.io.PrintStream;
 
+import org.eclipse.esmf.aspectmodel.utils.DescriptionsUtils;
 import org.eclipse.esmf.metamodel.Aspect;
 import org.eclipse.esmf.test.TestAspect;
 import org.eclipse.esmf.test.TestResources;
 
+import org.assertj.core.api.AssertionsForClassTypes;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.EnumSource;
 
-public class AspectModelDocumentationGeneratorTest {
+class AspectModelDocumentationGeneratorTest {
    @ParameterizedTest
    @EnumSource( value = TestAspect.class )
-   public void testGeneration( final TestAspect testAspect ) {
+   void testGeneration( final TestAspect testAspect ) {
       assertThatCode( () -> {
          final String html = generateHtmlDocumentation( testAspect );
          assertThat( html ).doesNotContain( "UnnamedCharacteristic" );
@@ -41,7 +43,7 @@ public void testGeneration( final TestAspect testAspect ) {
    }
 
    @Test
-   public void testAspectWithEntityCollection() throws Throwable {
+   void testAspectWithEntityCollection() throws Throwable {
       final String htmlResult = generateHtmlDocumentation( TestAspect.ASPECT_WITH_ENTITY_COLLECTION );
 
       assertThat( htmlResult ).isNotEmpty();
@@ -53,7 +55,7 @@ public void testAspectWithEntityCollection() throws Throwable {
    }
 
    @Test
-   public void testAspectWithCollectionOfSimpleType() throws Throwable {
+   void testAspectWithCollectionOfSimpleType() throws Throwable {
       final String htmlResult = generateHtmlDocumentation( TestAspect.ASPECT_WITH_COLLECTION_OF_SIMPLE_TYPE );
 
       assertThat( htmlResult ).isNotEmpty();
@@ -65,14 +67,14 @@ public void testAspectWithCollectionOfSimpleType() throws Throwable {
    }
 
    @Test
-   public void testScriptTagIsEscaped() throws IOException {
+   void testScriptTagIsEscaped() throws IOException {
       assertThat( generateHtmlDocumentation( TestAspect.ASPECT_WITH_SCRIPT_TAGS ) )
             .isNotEmpty()
-            .doesNotContain( "<script>alert('Should not be alerted');</script>" );
+            .doesNotContain( "Test preferred name with script: <script>alert('Should not be alerted');</script>" );
    }
 
    @Test
-   public void testRubyGemUpdateCommandIsNotExecuted() throws IOException {
+   void testRubyGemUpdateCommandIsNotExecuted() throws IOException {
       try ( final ByteArrayOutputStream stdOut = new ByteArrayOutputStream() ) {
          System.setOut( new PrintStream( stdOut ) );
          generateHtmlDocumentation( TestAspect.ASPECT_WITH_RUBY_GEM_UPDATE_COMMAND );
@@ -81,7 +83,7 @@ public void testRubyGemUpdateCommandIsNotExecuted() throws IOException {
    }
 
    @Test
-   public void testHtmlTagsAreEscaped() throws IOException {
+   void testHtmlTagsAreEscaped() throws IOException {
       assertThat( generateHtmlDocumentation( TestAspect.ASPECT_WITH_HTML_TAGS ) )
             .isNotEmpty()
             .doesNotContain( "<img src=xss.png onerror=alert('Boom!')>" )
@@ -90,20 +92,20 @@ public void testHtmlTagsAreEscaped() throws IOException {
    }
 
    @Test
-   public void testEncodedTextIsNotDecoded() throws IOException {
+   void testEncodedTextIsNotDecoded() throws IOException {
       assertThat( generateHtmlDocumentation( TestAspect.ASPECT_WITH_ENCODED_STRINGS ) )
             .doesNotContain( "This is an Aspect with encoded text." )
             .contains( "VGhpcyBpcyBhbiBBc3BlY3Qgd2l0aCBlbmNvZGVkIHRleHQu" );
    }
 
    @Test
-   public void testAspectModelUrnIsDisplayed() throws IOException {
+   void testAspectModelUrnIsDisplayed() throws IOException {
       assertThat( generateHtmlDocumentation( TestAspect.ASPECT_WITH_HTML_TAGS ) )
             .contains( "urn:samm:org.eclipse.esmf.test:1.0.0#AspectWithHtmlTags" );
    }
 
    @Test
-   public void testDocInfosAreDisplayed() throws IOException {
+   void testDocInfosAreDisplayed() throws IOException {
       assertThat( generateHtmlDocumentation( TestAspect.ASPECT_WITH_HTML_TAGS ) )
             .contains( ".toc-list" )
             .contains( "aspect-model-diagram" )
@@ -115,13 +117,13 @@ public void testDocInfosAreDisplayed() throws IOException {
    }
 
    @Test
-   public void testDocumentationIsNotEmptyForModelWithoutLanguageTags() throws IOException {
+   void testDocumentationIsNotEmptyForModelWithoutLanguageTags() throws IOException {
       final String aspectWithoutLanguageTags = generateHtmlDocumentation( TestAspect.ASPECT_WITHOUT_LANGUAGE_TAGS );
       assertThat( aspectWithoutLanguageTags ).isNotEmpty();
    }
 
    @Test
-   public void testAspectWithAbstractSingleEntityExpectSuccess() throws IOException {
+   void testAspectWithAbstractSingleEntityExpectSuccess() throws IOException {
       final String documentation = generateHtmlDocumentation( TestAspect.ASPECT_WITH_ABSTRACT_SINGLE_ENTITY );
       assertThat( documentation ).contains(
             "<h3 id=\"org-eclipse-esmf-test-AspectWithAbstractSingleEntity-org-eclipse-esmf-test-testProperty-property\">testProperty</h3"
@@ -134,7 +136,7 @@ public void testAspectWithAbstractSingleEntityExpectSuccess() throws IOException
    }
 
    @Test
-   public void testAspectWithAbstractEntityExpectSuccess() throws IOException {
+   void testAspectWithAbstractEntityExpectSuccess() throws IOException {
       final String documentation = generateHtmlDocumentation( TestAspect.ASPECT_WITH_ABSTRACT_ENTITY );
       assertThat( documentation ).contains(
             "<h3 id=\"org-eclipse-esmf-test-AspectWithAbstractEntity-org-eclipse-esmf-test-testProperty-property\">Test Property</h3>" );
@@ -146,7 +148,7 @@ public void testAspectWithAbstractEntityExpectSuccess() throws IOException {
    }
 
    @Test
-   public void testAspectWithCollectionWithAbstractEntityExpectSuccess() throws IOException {
+   void testAspectWithCollectionWithAbstractEntityExpectSuccess() throws IOException {
       final String documentation = generateHtmlDocumentation( TestAspect.ASPECT_WITH_COLLECTION_WITH_ABSTRACT_ENTITY );
       assertThat( documentation ).contains(
             "<h3 id=\"org-eclipse-esmf-test-AspectWithCollectionWithAbstractEntity-org-eclipse-esmf-test-testProperty-property"
@@ -159,7 +161,7 @@ public void testAspectWithCollectionWithAbstractEntityExpectSuccess() throws IOE
    }
 
    @Test
-   public void testAspectWithQuantifiableWithoutUnit() throws IOException {
+   void testAspectWithQuantifiableWithoutUnit() throws IOException {
       try ( final ByteArrayOutputStream stdOut = new ByteArrayOutputStream() ) {
          System.setOut( new PrintStream( stdOut ) );
          assertThatCode( () -> generateHtmlDocumentation( TestAspect.ASPECT_WITH_QUANTIFIABLE_WITHOUT_UNIT ) )
@@ -168,7 +170,7 @@ public void testAspectWithQuantifiableWithoutUnit() throws IOException {
    }
 
    @Test
-   public void testAspectWithConstraintWithSeeAttribute() throws IOException {
+   void testAspectWithConstraintWithSeeAttribute() throws IOException {
       final String documentation = generateHtmlDocumentation( TestAspect.ASPECT_WITH_CONSTRAINT_WITH_SEE_ATTRIBUTE );
       assertThat( documentation ).contains(
             "<h3 id=\"org-eclipse-esmf-test-AspectWithConstraintWithSeeAttribute-org-eclipse-esmf-test-testPropertyTwo-property"
@@ -179,6 +181,24 @@ public void testAspectWithConstraintWithSeeAttribute() throws IOException {
             "<li>http://example.com/me2</li>" );
    }
 
+   @Test
+   void testMarkdownRenderingWithLink() throws IOException {
+      final String htmlResult = generateHtmlDocumentation( TestAspect.ASPECT_WITH_MARKDOWN_DESCRIPTION );
+      AssertionsForClassTypes.assertThat( htmlResult ).contains( "<a href=\"https://example.com\">Visit Example</a>" );
+   }
+
+   @Test
+   void testAspectWithMarkdownDescription() throws IOException {
+      final String htmlResult = generateHtmlDocumentation( TestAspect.ASPECT_WITH_MARKDOWN_DESCRIPTION );
+      AssertionsForClassTypes.assertThat( htmlResult ).doesNotContain( "[link](https://www.example.com/spec)" );
+   }
+
+   @Test
+   void testHtmlOutputDoesNotContainMarkdownSyntax() throws IOException {
+      final String htmlResult = generateHtmlDocumentation( TestAspect.ASPECT_WITH_MARKDOWN_DESCRIPTION );
+      AssertionsForClassTypes.assertThat( htmlResult ).doesNotContain( "[Visit Example](https://example.com)" );
+   }
+
    private String generateHtmlDocumentation( final TestAspect testAspect ) throws IOException {
       final Aspect aspect = TestResources.load( testAspect ).aspect();
       final AspectModelDocumentationGenerator aspectModelDocumentationGenerator = new AspectModelDocumentationGenerator( aspect );
diff --git a/core/esmf-test-aspect-models/src/main/java/org/eclipse/esmf/test/TestAspect.java b/core/esmf-test-aspect-models/src/main/java/org/eclipse/esmf/test/TestAspect.java
index 09ec20fc6..0b559b671 100644
--- a/core/esmf-test-aspect-models/src/main/java/org/eclipse/esmf/test/TestAspect.java
+++ b/core/esmf-test-aspect-models/src/main/java/org/eclipse/esmf/test/TestAspect.java
@@ -116,6 +116,7 @@ public enum TestAspect implements TestModel {
    ASPECT_WITH_LIST_AND_ELEMENT_CONSTRAINT,
    ASPECT_WITH_LIST_ENTITY_ENUMERATION,
    ASPECT_WITH_LIST_WITH_LENGTH_CONSTRAINT,
+   ASPECT_WITH_MARKDOWN_DESCRIPTION,
    ASPECT_WITH_MEASUREMENT,
    ASPECT_WITH_MEASUREMENT_WITH_UNIT,
    ASPECT_WITH_MULTILANGUAGE_EXAMPLE_VALUE,
diff --git a/core/esmf-test-aspect-models/src/main/resources/valid/org.eclipse.esmf.test/1.0.0/AspectWithMarkdownDescription.ttl b/core/esmf-test-aspect-models/src/main/resources/valid/org.eclipse.esmf.test/1.0.0/AspectWithMarkdownDescription.ttl
new file mode 100644
index 000000000..2f2437f48
--- /dev/null
+++ b/core/esmf-test-aspect-models/src/main/resources/valid/org.eclipse.esmf.test/1.0.0/AspectWithMarkdownDescription.ttl
@@ -0,0 +1,51 @@
+# Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
+#
+# See the AUTHORS file(s) distributed with this work for additional
+# information regarding authorship.
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# SPDX-License-Identifier: MPL-2.0
+
+@prefix : <urn:samm:org.eclipse.esmf.test:1.0.0#> .
+@prefix samm: <urn:samm:org.eclipse.esmf.samm:meta-model:2.2.0#> .
+@prefix samm-c: <urn:samm:org.eclipse.esmf.samm:characteristic:2.2.0#> .
+
+:AspectWithMarkdownDescription a samm:Aspect ;
+   samm:properties ( :myProperty ) ;
+   samm:operations ( ) .
+
+:myProperty a samm:Property ;
+   samm:description """
+      This is a sample concept demonstrating **Markdown** support in samm:description.
+
+      > NOTE: This is a note block.
+      > It supports multiple lines.
+      > Here's a second line of the note.
+
+      > EXAMPLE 1: This is the first example block.
+      > It can span several lines, and supports *italic* and **bold** text.
+
+      > EXAMPLE 2: This is the second example.
+      > Also multiline, for testing multiple example entries.
+
+      > SOURCE: ISO 12345:2023, section 4.2.1
+      > with an inline [link](https://www.example.com/spec).
+
+      Unordered list:
+        * Item A
+        * Item B
+        * Item C
+
+      Ordered list:
+        1. First
+        2. Second
+        3. Third
+
+      You can also include inline links like [Visit Example](https://example.com).
+
+      Another paragraph after a blank line to simulate text flow and paragraph breaks.
+   """@en ;
+  samm:characteristic samm-c:Text .
diff --git a/pom.xml b/pom.xml
index bd8ab81dd..9bc5ca5eb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,7 +18,7 @@
    <parent>
       <groupId>org.eclipse.esmf</groupId>
       <artifactId>esmf-parent</artifactId>
-      <version>19</version>
+      <version>20</version>
    </parent>
 
    <artifactId>esmf-sdk-parent</artifactId>


Reviews:
user: login: atextor
body: The PR should also add one test Aspect Model that uses Markdown in a description, with
- a bullet list
- an ordered list
- at least one block
- at least one link.
This makes sure that no other SDK component breaks because of the markdown.

Then, in the documentation generation test, there should be test that asserts that the generated HTML does not contain the `[...](url)` structure.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:37:10Z
user: login: Yauhenikapl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T09:07:34Z
user: login: atextor
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T12:36:10Z
user: login: atextor
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T09:24:57Z

Review comments:
diff_hunk: @@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
+ *
+ * See the AUTHORS file(s) distributed with this work for additional
+ * information regarding authorship.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+package org.eclipse.esmf.aspectmodel.utils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+
+/**
+ * A utility class for converting SAMM-flavored Markdown descriptions into HTML.
+ *
+ * <p>This renderer supports a limited subset of Markdown syntax and introduces
+ * custom processing for specific annotated blocks commonly used in SAMM descriptions,
+ * such as {@code > NOTE: ...}, {@code > EXAMPLE: ...}, and {@code > SOURCE: ...}.
+ * These blocks are extracted and rendered into semantically meaningful HTML
+ * structures (e.g., {@code <div class="note">}, {@code <ul class="example-list">}, etc.).
+ * Remaining content is rendered using the CommonMark parser.
+ */
+public class MarkdownHtmlRenderer {
+
+   private static final String CLOSE_DIV_TAG = "</div>";
+
+   /**
+    * A reusable CommonMark parser instance for processing standard Markdown syntax.
+    */
+   private static final Parser PARSER = Parser.builder().build();
+
+   /**
+    * A reusable CommonMark HTML renderer instance.
+    */
+   private static final HtmlRenderer RENDERER = HtmlRenderer.builder().build();
+
+   /**
+    * Private constructor to prevent instantiation. This class is intended to be used statically.
+    */
+   private MarkdownHtmlRenderer() {
+   }
+
+   /**
+    * Converts a set of multi-line Markdown descriptions into a single HTML string.
+    * Each entry in the set is processed independently and merged in the resulting output.
+    *
+    * @param description A line of Markdown description blocks to render.
+    * @return Combined HTML output representing all given descriptions.
+    */
+   public static String renderHtmlFromDescriptions( final String description ) {
+      return processSpecialBlocks( description ) + "\n";
+   }
+
+   /**
+    * Parses a single Markdown block:
+    * <ul>
+    *   <li>Identifies and extracts special block types: NOTE, EXAMPLE, and SOURCE</li>
+    *   <li>Renders those blocks using custom HTML wrappers</li>
+    *   <li>Processes the remaining Markdown using the CommonMark renderer</li>
+    * </ul>
+    *
+    * @param rawMarkdown The full Markdown string to process.
+    * @return The rendered HTML output.
+    */
+   private static String processSpecialBlocks( final String rawMarkdown ) {
+      String[] lines = stripLines( rawMarkdown );
+      StringBuilder markdownBuffer = new StringBuilder();
+      Map<String, List<String>> specialBlocks = collectSpecialBlocks( lines, markdownBuffer );
+
+      StringBuilder html = new StringBuilder();
+      specialBlocks.forEach( ( type, items ) -> html.append( renderSpecialBlock( type, items ) ) );
+
+      Node parsed = PARSER.parse( markdownBuffer.toString() );
+      html.append( RENDERER.render( parsed ) );
+      return html.toString();
+   }
+
+   /**
+    * Renders a list of extracted special blocks into HTML.
+    *
+    * <p>- For {@code NOTE} and {@code SOURCE}, each entry is rendered in a {@code <div>} with a matching class.<br>
+    *    - For {@code EXAMPLE}, a single example is rendered as a {@code <div>}; multiple examples as a {@code <ul>}.
+    *
+    * @param type  The type of the special block (e.g., "NOTE", "EXAMPLE", "SOURCE").
+    * @param items The list of block contents for that type.
+    * @return The rendered HTML string for the block.
+    */
+   private static String renderSpecialBlock( final String type, final List<String> items ) {
+      if ( items.isEmpty() ) {
+         return "";
+      }
+
+      return switch ( type ) {
+         case "NOTE", "SOURCE" -> items.stream()
+               .map( text -> "<div class=\"" + type.toLowerCase() + "\">"
+                     + renderMarkdownInline( text.strip() ) + CLOSE_DIV_TAG + "\n" )
+               .collect( Collectors.joining() );
+
+         case "EXAMPLE" -> {
+            if ( items.size() == 1 ) {
+               yield "<div class=\"example\">" + renderMarkdownInline( items.get( 0 ).strip() ) + CLOSE_DIV_TAG + "\n";
+            } else {
+               StringBuilder sb = new StringBuilder( "<ul class=\"example-list\">\n" );
+               for ( String item : items ) {
+                  sb.append( "<li>" ).append( renderMarkdownInline( item.strip() ) ).append( "</li>\n" );
+               }
+               sb.append( "</ul>\n" );
+               yield sb.toString();
+            }
+         }
+
+         default -> items.stream()
+               .map( text -> "<div class=\"block\">" + renderMarkdownInline( text.strip() ) + CLOSE_DIV_TAG + "\n" )
+               .collect( Collectors.joining() );
+      };
+   }
+
+   /**
+    * Collects all special block entries (NOTE, EXAMPLE, SOURCE) from the input lines.
+    * Lines not belonging to special blocks are appended to the {@code markdownBuffer}.
+    *
+    * @param lines          Stripped lines from the raw markdown block.
+    * @param markdownBuffer Buffer to store non-special markdown content.
+    * @return A map of special block types to their associated content.
+    */
+   private static Map<String, List<String>> collectSpecialBlocks( final String[] lines, final StringBuilder markdownBuffer ) {
+      Pattern pattern = Pattern.compile( "^>\\s*(NOTE|EXAMPLE|SOURCE)(\\s+\\d+)?:\\s*(.*)", Pattern.CASE_INSENSITIVE );
path: core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/utils/MarkdownHtmlRenderer.java
user: login: atextor
body: Isn't that the same pattern as in DescriptionUtils? Maybe reuse it?
created_at: 2025-05-16T08:02:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package org.eclipse.esmf.aspectmodel.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class DescriptionsUtilsTest {
+   @Test
+   void testExtractNotes_singleNote() {
+      final String description = "> NOTE: This is a note.\n> Continued on the next line.";
+      final List<String> notes = DescriptionsUtils.notes( description );
+      assertEquals( 1, notes.size() );
path: core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java
user: login: atextor
body: Please use assertJ `assertThat( notes ).hasSize( 1 )`
created_at: 2025-05-16T08:20:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package org.eclipse.esmf.aspectmodel.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class DescriptionsUtilsTest {
+   @Test
+   void testExtractNotes_singleNote() {
+      final String description = "> NOTE: This is a note.\n> Continued on the next line.";
+      final List<String> notes = DescriptionsUtils.notes( description );
+      assertEquals( 1, notes.size() );
+      assertEquals( "This is a note.\nContinued on the next line.", notes.get( 0 ) );
+   }
+
+   @Test
+   void testExtractExamples_multipleExamples() {
+      final String description =
+            """
+                  > EXAMPLE 1: First example.
+                  > More detail.
+                                    
+                  > EXAMPLE 2: Second example.
+                  """;
+      final List<String> examples = DescriptionsUtils.examples( description );
+
+      assertEquals( 2, examples.size() );
+      assertEquals( "First example.\nMore detail.", examples.get( 0 ) );
+      assertEquals( "Second example.", examples.get( 1 ) );
+   }
+
+   @Test
+   void testExtractSources_withLink() {
+      final String description = "> SOURCE: Source with [link](https://example.com)";
+      final List<String> sources = DescriptionsUtils.sources( description );
+      assertEquals( 1, sources.size() );
+      assertTrue( sources.get( 0 ).contains( "[link](https://example.com)" ) );
path: core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java
user: login: atextor
body: Please use assertJ `assertThat( sources.get(0) ).contains(...)`
created_at: 2025-05-16T08:20:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package org.eclipse.esmf.aspectmodel.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class DescriptionsUtilsTest {
+   @Test
+   void testExtractNotes_singleNote() {
+      final String description = "> NOTE: This is a note.\n> Continued on the next line.";
+      final List<String> notes = DescriptionsUtils.notes( description );
+      assertEquals( 1, notes.size() );
+      assertEquals( "This is a note.\nContinued on the next line.", notes.get( 0 ) );
+   }
+
+   @Test
+   void testExtractExamples_multipleExamples() {
+      final String description =
+            """
+                  > EXAMPLE 1: First example.
+                  > More detail.
+                                    
+                  > EXAMPLE 2: Second example.
+                  """;
+      final List<String> examples = DescriptionsUtils.examples( description );
+
+      assertEquals( 2, examples.size() );
+      assertEquals( "First example.\nMore detail.", examples.get( 0 ) );
+      assertEquals( "Second example.", examples.get( 1 ) );
+   }
+
+   @Test
+   void testExtractSources_withLink() {
+      final String description = "> SOURCE: Source with [link](https://example.com)";
+      final List<String> sources = DescriptionsUtils.sources( description );
+      assertEquals( 1, sources.size() );
+      assertTrue( sources.get( 0 ).contains( "[link](https://example.com)" ) );
+   }
+
+   @Test
+   void testMixedBlockTypes() {
+      final String description =
+            """
+                  > NOTE: A note block.
+                  > EXAMPLE: An example block.
+                                    
+                  > SOURCE: A source block.
+                  """;
+      assertEquals( 1, DescriptionsUtils.notes( description ).size() );
+      assertEquals( 1, DescriptionsUtils.examples( description ).size() );
+      assertEquals( 1, DescriptionsUtils.sources( description ).size() );
+   }
+
+   @Test
+   void testNoBlocks() {
+      final String description = "This is a plain description without any special blocks.";
+      assertTrue( DescriptionsUtils.notes( description ).isEmpty() );
path: core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java
user: login: atextor
body: Please use assertJ `assertThat( DescriptionUtils.notes( description ) ).isEmpty()`
created_at: 2025-05-16T08:21:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+package org.eclipse.esmf.aspectmodel.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class DescriptionsUtilsTest {
+   @Test
+   void testExtractNotes_singleNote() {
+      final String description = "> NOTE: This is a note.\n> Continued on the next line.";
+      final List<String> notes = DescriptionsUtils.notes( description );
+      assertEquals( 1, notes.size() );
+      assertEquals( "This is a note.\nContinued on the next line.", notes.get( 0 ) );
+   }
+
+   @Test
+   void testExtractExamples_multipleExamples() {
+      final String description =
+            """
+                  > EXAMPLE 1: First example.
+                  > More detail.
+                                    
+                  > EXAMPLE 2: Second example.
+                  """;
+      final List<String> examples = DescriptionsUtils.examples( description );
+
+      assertEquals( 2, examples.size() );
+      assertEquals( "First example.\nMore detail.", examples.get( 0 ) );
+      assertEquals( "Second example.", examples.get( 1 ) );
+   }
+
+   @Test
+   void testExtractSources_withLink() {
+      final String description = "> SOURCE: Source with [link](https://example.com)";
+      final List<String> sources = DescriptionsUtils.sources( description );
+      assertEquals( 1, sources.size() );
+      assertTrue( sources.get( 0 ).contains( "[link](https://example.com)" ) );
+   }
+
+   @Test
+   void testMixedBlockTypes() {
+      final String description =
+            """
+                  > NOTE: A note block.
+                  > EXAMPLE: An example block.
+                                    
+                  > SOURCE: A source block.
+                  """;
+      assertEquals( 1, DescriptionsUtils.notes( description ).size() );
+      assertEquals( 1, DescriptionsUtils.examples( description ).size() );
+      assertEquals( 1, DescriptionsUtils.sources( description ).size() );
+   }
+
+   @Test
+   void testNoBlocks() {
+      final String description = "This is a plain description without any special blocks.";
+      assertTrue( DescriptionsUtils.notes( description ).isEmpty() );
+      assertTrue( DescriptionsUtils.examples( description ).isEmpty() );
+      assertTrue( DescriptionsUtils.sources( description ).isEmpty() );
+   }
+
+   @Test
+   void testToHtml_withAllBlockTypes() {
+      final String description =
+            """
+                  > NOTE: This is a note.
+                  > With multiple lines.
+                      
+                  > EXAMPLE 1: First example.
+                  > Additional example content.
+                      
+                  > EXAMPLE 2: Second example.
+                      
+                  > SOURCE: Source information here.
+                      
+                  Some **markdown** content here.
+                  1. Ordered
+                  2. List
+                  """;
+
+      final String html = DescriptionsUtils.toHtml( description );
+
+      assertTrue( html.contains( "<div class=\"note\">" ) );
path: core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/utils/DescriptionsUtilsTest.java
user: login: atextor
body: Please use assertJ `assertThat( html ).contains( ... )`
created_at: 2025-05-16T08:21:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,5 +37,5 @@
 #end
 
 #macro ( description $description )
-<span class="text-base py-4">$description</span>
+<span class="text-base py-4">#evaluate( $description )</span>
path: core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/common-documentation-lib.vm
user: login: atextor
body: Maybe I'm missing something, but where is `evaluate` defined? Should this be `$descriptionUtils.toHtml( $description )`?
created_at: 2025-05-16T08:32:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,5 +37,5 @@
 #end
 
 #macro ( description $description )
-<span class="text-base py-4">$description</span>
+<span class="text-base py-4">#evaluate( $description )</span>
path: core/esmf-aspect-model-document-generators/src/main/resources/docu/templates/html/common-documentation-lib.vm
user: login: Yauhenikapl
body: [#evaluate](https://velocity.apache.org/engine/1.7/user-guide.html#evaluate) It’s a built-in Velocity directive. We using it in some places.
So if `$description` is a string that contains HTML, `#evaluate($description)` will inject it as real HTML, not as plain text.
created_at: 2025-05-16T09:07:34Z
author_association: CONTRIBUTOR
path: core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/docu/AspectModelDocumentationGeneratorTest.java
user: login: atextor
body: Please add a test like the following:

```java
@Test
void testAspectWithMarkdownDescription() throws IOException {
   final String htmlResult = generateHtmlDocumentation( TestAspect.ASPECT_WITH_MARKDOWN_DESCRIPTION );
   assertThat( htmlResult ).doesNotContain( "[link](https://www.example.com/spec)" );
}
```
created_at: 2025-05-16T12:28:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+# Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
+#
+# See the AUTHORS file(s) distributed with this work for additional
+# information regarding authorship.
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# SPDX-License-Identifier: MPL-2.0
+
+@prefix : <urn:samm:org.eclipse.esmf.test:1.0.0#> .
+@prefix samm: <urn:samm:org.eclipse.esmf.samm:meta-model:2.2.0#> .
+@prefix samm-c: <urn:samm:org.eclipse.esmf.samm:characteristic:2.2.0#> .
+
+:AspectWithMarkdownDescription a samm:Aspect ;
+  samm:properties ( :myProperty ) ;
+  samm:operations ( ) .
+
+:myProperty a samm:Property ;
+  samm:description """
+This is a sample concept demonstrating **Markdown** support in samm:description.
+
+> NOTE: This is a note block.
+> It supports multiple lines.
+> Here's a second line of the note.
+
+> EXAMPLE 1: This is the first example block.
+> It can span several lines, and supports *italic* and **bold** text.
+
+> EXAMPLE 2: This is the second example.
+> Also multiline, for testing multiple example entries.
+
+> SOURCE: ISO 12345:2023, section 4.2.1
+> with an inline [link](https://www.example.com/spec).
+
+Unordered list:
+* Item A
+* Item B
+* Item C
+
+Ordered list:
+1. First
+2. Second
+3. Third
+
+You can also include inline links like [Visit Example](https://example.com).
+
+Another paragraph after a blank line to simulate text flow and paragraph breaks.
+     """@en ;
path: core/esmf-test-aspect-models/src/main/resources/valid/org.eclipse.esmf.test/1.0.0/AspectWithMarkdownDescription.ttl
user: login: atextor
body: Please note that in [best practices](https://eclipse-esmf.github.io/samm-specification/snapshot/appendix/best-practices.html#markdown-where-needed) the text inside the descriptions block is indented to be aligned with the remaining source.
I suggest the maximum number of leading spaces across all lines of the description string should be removed before interpreting the Markdown. Something like this function could realize this:

```java
String stripIndent( final String string ) {
   final int indent = string.lines()
         .filter( line -> !line.isEmpty() )
         .map( line -> line.indexOf( line.trim() ) )
         .filter( offset -> offset > 0 )
         .min( Integer::compareTo )
         .orElse( 0 );
   return string.lines()
         .map( line -> indent <= line.length() ? line.substring( indent ) : line )
         .collect( Collectors.joining( "\n" ) );
}
```

I.e., please change the test model with indentations and make sure everthing works as expected:

```suggestion
:myProperty a samm:Property ;
   samm:description """
      This is a sample concept demonstrating **Markdown** support in samm:description.

      > NOTE: This is a note block.
      > It supports multiple lines.
      > Here's a second line of the note.

      > EXAMPLE 1: This is the first example block.
      > It can span several lines, and supports *italic* and **bold** text.

      > EXAMPLE 2: This is the second example.
      > Also multiline, for testing multiple example entries.

      > SOURCE: ISO 12345:2023, section 4.2.1
      > with an inline [link](https://www.example.com/spec).

      Unordered list:
        * Item A
        * Item B
        * Item C

      Ordered list:
        1. First
        2. Second
        3. Third

      You can also include inline links like [Visit Example](https://example.com).

      Another paragraph after a blank line to simulate text flow and paragraph breaks.
   """@en ;
```
created_at: 2025-05-16T12:35:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-19T12:45:04Z
body: Release [v2.10.4](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.10.4) addresses this.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T05:19:05Z
body: Release [v2.10.4](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.10.4) addresses this.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-06-20T12:04:32Z
body: Release [v2.11.0](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.11.0) addresses this.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-06-23T05:58:37Z
body: Release [v2.11.0](https://github.com/eclipse-esmf/esmf-sdk/releases/tag/v2.11.0) addresses this.
author_association: NONE