Skip to content

Add support for markdown formatting in descriptions #759

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/esmf-aspect-meta-model-java/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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;

/**
* 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 descriptions A set of multi-line Markdown description strings.
* @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 descriptions, final String type ) {
List<String> result = new ArrayList<>();
extractFromDescription( descriptions, 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;
}
}
}

Original file line number Diff line number Diff line change
@@ -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();
}
}


Loading
Loading