Skip to content

Commit a3da600

Browse files
authored
Merge pull request #759 from bci-oss/723-support-for-markdown-formatting-in-descriptions
Add support for markdown formatting in descriptions
2 parents b9bd279 + a7f3a71 commit a3da600

File tree

18 files changed

+645
-37
lines changed

18 files changed

+645
-37
lines changed

core/esmf-aspect-meta-model-java/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
<artifactId>record-builder-processor</artifactId>
6464
<scope>provided</scope>
6565
</dependency>
66+
<dependency>
67+
<groupId>org.commonmark</groupId>
68+
<artifactId>commonmark</artifactId>
69+
</dependency>
6670

6771
<!-- Test dependencies -->
6872
<dependency>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
3+
*
4+
* See the AUTHORS file(s) distributed with this work for additional
5+
* information regarding authorship.
6+
*
7+
* This Source Code Form is subject to the terms of the Mozilla Public
8+
* License, v. 2.0. If a copy of the MPL was not distributed with this
9+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
10+
*
11+
* SPDX-License-Identifier: MPL-2.0
12+
*/
13+
14+
package org.eclipse.esmf.aspectmodel.utils;
15+
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
import java.util.regex.Matcher;
19+
import java.util.regex.Pattern;
20+
import java.util.stream.Collectors;
21+
22+
/**
23+
* Utility class for extracting and rendering structured content blocks (such as NOTE, EXAMPLE, SOURCE)
24+
* from SAMM-compliant Markdown descriptions.
25+
*
26+
* <p>This class supports parsing multi-line Markdown-style input and extracting semantically significant
27+
* sections such as {@code > NOTE: ...}, {@code > EXAMPLE: ...}, and {@code > SOURCE: ...}.
28+
* These blocks can be retrieved as plain text or rendered into HTML using {@link MarkdownHtmlRenderer}.
29+
*/
30+
public class DescriptionsUtils {
31+
32+
private DescriptionsUtils() {
33+
}
34+
35+
/**
36+
* A regex pattern used to identify special SAMM-style Markdown blocks.
37+
* Matches lines beginning with {@code > NOTE:}, {@code > EXAMPLE:}, or {@code > SOURCE:},
38+
* optionally followed by a number (e.g., {@code > EXAMPLE 2: ...}).
39+
*/
40+
static final Pattern BLOCK_PATTERN = Pattern.compile(
41+
"^>\\s*(NOTE|EXAMPLE|SOURCE)(\\s+\\d+)?:\\s*(.*)",
42+
Pattern.CASE_INSENSITIVE
43+
);
44+
45+
/**
46+
* Extracts all {@code NOTE} blocks from the given set of Markdown description strings.
47+
*
48+
* @param description A line Markdown description.
49+
* @return A list of extracted NOTE block contents.
50+
*/
51+
public static List<String> notes( final String description ) {
52+
return extractBlock( description, "NOTE" );
53+
}
54+
55+
/**
56+
* Extracts all {@code EXAMPLE} blocks from the given set of Markdown description strings.
57+
*
58+
* @param description A line Markdown description.
59+
* @return A list of extracted EXAMPLE block contents.
60+
*/
61+
public static List<String> examples( final String description ) {
62+
return extractBlock( description, "EXAMPLE" );
63+
}
64+
65+
/**
66+
* Extracts all {@code SOURCE} blocks from the given set of Markdown description strings.
67+
*
68+
* @param description A line Markdown description.
69+
* @return A list of extracted SOURCE block contents.
70+
*/
71+
public static List<String> sources( final String description ) {
72+
return extractBlock( description, "SOURCE" );
73+
}
74+
75+
/**
76+
* Renders the given set of Markdown description strings into semantic HTML.
77+
* Uses {@link MarkdownHtmlRenderer} to process both special blocks and general Markdown syntax.
78+
*
79+
* @param description A line of Markdown description string.
80+
* @return The HTML representation of the combined input.
81+
*/
82+
public static String toHtml( final String description ) {
83+
return MarkdownHtmlRenderer.renderHtmlFromDescriptions( description );
84+
}
85+
86+
/**
87+
* Extracts all blocks of a specified type (e.g., NOTE, EXAMPLE, SOURCE) from a set of Markdown strings.
88+
*
89+
* <p>Each block is expected to begin with a {@code > TYPE:} line and may span multiple lines,
90+
* each of which begins with {@code >}.
91+
*
92+
* @param description A line Markdown description string.
93+
* @param type The type of block to extract ("NOTE", "EXAMPLE", or "SOURCE").
94+
* @return A list of extracted block contents for the specified type.
95+
*/
96+
private static List<String> extractBlock( final String description, final String type ) {
97+
List<String> result = new ArrayList<>();
98+
extractFromDescription( stripIndent( description ), type, result );
99+
return result;
100+
}
101+
102+
private static void extractFromDescription( final String desc, final String type, final List<String> result ) {
103+
String[] lines = desc.split( "\\R" );
104+
boolean[] insideBlock = { false };
105+
StringBuilder blockContent = new StringBuilder();
106+
107+
for ( String line : lines ) {
108+
handleLine( line, type, insideBlock, blockContent, result );
109+
}
110+
111+
if ( insideBlock[0] && !blockContent.isEmpty() ) {
112+
result.add( blockContent.toString().strip() );
113+
}
114+
}
115+
116+
private static void handleLine( final String line, final String type, boolean[] insideBlock,
117+
StringBuilder blockContent, List<String> result ) {
118+
Matcher matcher = BLOCK_PATTERN.matcher( line );
119+
if ( matcher.find() ) {
120+
String currentType = matcher.group( 1 ).toUpperCase();
121+
String content = matcher.group( 3 ); // Corrected: group(3) is the actual content
122+
123+
flushBlock( insideBlock, blockContent, result );
124+
125+
if ( currentType.equals( type.toUpperCase() ) ) {
126+
blockContent.append( content ).append( "\n" );
127+
insideBlock[0] = true;
128+
} else {
129+
insideBlock[0] = false;
130+
}
131+
} else if ( insideBlock[0] && line.startsWith( ">" ) ) {
132+
blockContent.append( line.substring( 1 ).stripLeading() ).append( "\n" );
133+
} else if ( insideBlock[0] ) {
134+
flushBlock( insideBlock, blockContent, result );
135+
}
136+
}
137+
138+
private static void flushBlock( boolean[] insideBlock, StringBuilder blockContent, List<String> result ) {
139+
if ( insideBlock[0] && !blockContent.isEmpty() ) {
140+
result.add( blockContent.toString().strip() );
141+
blockContent.setLength( 0 );
142+
insideBlock[0] = false;
143+
}
144+
}
145+
146+
static String stripIndent( final String string ) {
147+
final int indent = string.lines()
148+
.filter( line -> !line.isEmpty() )
149+
.map( line -> line.indexOf( line.trim() ) )
150+
.filter( offset -> offset > 0 )
151+
.min( Integer::compareTo )
152+
.orElse( 0 );
153+
return string.lines()
154+
.map( line -> indent <= line.length() ? line.substring( indent ) : line )
155+
.collect( Collectors.joining( "\n" ) );
156+
}
157+
}
158+
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH
3+
*
4+
* See the AUTHORS file(s) distributed with this work for additional
5+
* information regarding authorship.
6+
*
7+
* This Source Code Form is subject to the terms of the Mozilla Public
8+
* License, v. 2.0. If a copy of the MPL was not distributed with this
9+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
10+
*
11+
* SPDX-License-Identifier: MPL-2.0
12+
*/
13+
14+
package org.eclipse.esmf.aspectmodel.utils;
15+
16+
import java.util.ArrayList;
17+
import java.util.LinkedHashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.regex.Matcher;
21+
import java.util.regex.Pattern;
22+
import java.util.stream.Collectors;
23+
24+
import org.commonmark.node.Node;
25+
import org.commonmark.parser.Parser;
26+
import org.commonmark.renderer.html.HtmlRenderer;
27+
28+
/**
29+
* A utility class for converting SAMM-flavored Markdown descriptions into HTML.
30+
*
31+
* <p>This renderer supports a limited subset of Markdown syntax and introduces
32+
* custom processing for specific annotated blocks commonly used in SAMM descriptions,
33+
* such as {@code > NOTE: ...}, {@code > EXAMPLE: ...}, and {@code > SOURCE: ...}.
34+
* These blocks are extracted and rendered into semantically meaningful HTML
35+
* structures (e.g., {@code <div class="note">}, {@code <ul class="example-list">}, etc.).
36+
* Remaining content is rendered using the CommonMark parser.
37+
*/
38+
public class MarkdownHtmlRenderer {
39+
40+
private static final String CLOSE_DIV_TAG = "</div>";
41+
42+
/**
43+
* A reusable CommonMark parser instance for processing standard Markdown syntax.
44+
*/
45+
private static final Parser PARSER = Parser.builder().build();
46+
47+
/**
48+
* A reusable CommonMark HTML renderer instance.
49+
*/
50+
private static final HtmlRenderer RENDERER = HtmlRenderer.builder().build();
51+
52+
/**
53+
* Private constructor to prevent instantiation. This class is intended to be used statically.
54+
*/
55+
private MarkdownHtmlRenderer() {
56+
}
57+
58+
/**
59+
* Converts a set of multi-line Markdown descriptions into a single HTML string.
60+
* Each entry in the set is processed independently and merged in the resulting output.
61+
*
62+
* @param description A line of Markdown description blocks to render.
63+
* @return Combined HTML output representing all given descriptions.
64+
*/
65+
public static String renderHtmlFromDescriptions( final String description ) {
66+
return processSpecialBlocks( description ) + "\n";
67+
}
68+
69+
/**
70+
* Parses a single Markdown block:
71+
* <ul>
72+
* <li>Identifies and extracts special block types: NOTE, EXAMPLE, and SOURCE</li>
73+
* <li>Renders those blocks using custom HTML wrappers</li>
74+
* <li>Processes the remaining Markdown using the CommonMark renderer</li>
75+
* </ul>
76+
*
77+
* @param rawMarkdown The full Markdown string to process.
78+
* @return The rendered HTML output.
79+
*/
80+
private static String processSpecialBlocks( final String rawMarkdown ) {
81+
String[] lines = stripLines( rawMarkdown );
82+
StringBuilder markdownBuffer = new StringBuilder();
83+
Map<String, List<String>> specialBlocks = collectSpecialBlocks( lines, markdownBuffer );
84+
85+
StringBuilder html = new StringBuilder();
86+
specialBlocks.forEach( ( type, items ) -> html.append( renderSpecialBlock( type, items ) ) );
87+
88+
Node parsed = PARSER.parse( markdownBuffer.toString() );
89+
html.append( RENDERER.render( parsed ) );
90+
return html.toString();
91+
}
92+
93+
/**
94+
* Renders a list of extracted special blocks into HTML.
95+
*
96+
* <p>- For {@code NOTE} and {@code SOURCE}, each entry is rendered in a {@code <div>} with a matching class.<br>
97+
* - For {@code EXAMPLE}, a single example is rendered as a {@code <div>}; multiple examples as a {@code <ul>}.
98+
*
99+
* @param type The type of the special block (e.g., "NOTE", "EXAMPLE", "SOURCE").
100+
* @param items The list of block contents for that type.
101+
* @return The rendered HTML string for the block.
102+
*/
103+
private static String renderSpecialBlock( final String type, final List<String> items ) {
104+
if ( items.isEmpty() ) {
105+
return "";
106+
}
107+
108+
return switch ( type ) {
109+
case "NOTE", "SOURCE" -> items.stream()
110+
.map( text -> "<div class=\"" + type.toLowerCase() + "\">"
111+
+ renderMarkdownInline( text.strip() ) + CLOSE_DIV_TAG + "\n" )
112+
.collect( Collectors.joining() );
113+
114+
case "EXAMPLE" -> {
115+
if ( items.size() == 1 ) {
116+
yield "<div class=\"example\">" + renderMarkdownInline( items.get( 0 ).strip() ) + CLOSE_DIV_TAG + "\n";
117+
} else {
118+
StringBuilder sb = new StringBuilder( "<ul class=\"example-list\">\n" );
119+
for ( String item : items ) {
120+
sb.append( "<li>" ).append( renderMarkdownInline( item.strip() ) ).append( "</li>\n" );
121+
}
122+
sb.append( "</ul>\n" );
123+
yield sb.toString();
124+
}
125+
}
126+
127+
default -> items.stream()
128+
.map( text -> "<div class=\"block\">" + renderMarkdownInline( text.strip() ) + CLOSE_DIV_TAG + "\n" )
129+
.collect( Collectors.joining() );
130+
};
131+
}
132+
133+
/**
134+
* Collects all special block entries (NOTE, EXAMPLE, SOURCE) from the input lines.
135+
* Lines not belonging to special blocks are appended to the {@code markdownBuffer}.
136+
*
137+
* @param lines Stripped lines from the raw markdown block.
138+
* @param markdownBuffer Buffer to store non-special markdown content.
139+
* @return A map of special block types to their associated content.
140+
*/
141+
private static Map<String, List<String>> collectSpecialBlocks( final String[] lines, final StringBuilder markdownBuffer ) {
142+
Map<String, List<String>> specialBlocks = new LinkedHashMap<>();
143+
144+
String currentType = null;
145+
StringBuilder block = new StringBuilder();
146+
147+
for ( String line : lines ) {
148+
Matcher matcher = DescriptionsUtils.BLOCK_PATTERN.matcher( line );
149+
if ( matcher.find() ) {
150+
flushBlock( currentType, block, specialBlocks );
151+
currentType = matcher.group( 1 ).toUpperCase();
152+
block.append( matcher.group( 3 ) ).append( "\n" );
153+
} else if ( currentType != null && line.startsWith( ">" ) ) {
154+
block.append( line.substring( 1 ).stripLeading() ).append( "\n" );
155+
} else {
156+
flushBlock( currentType, block, specialBlocks );
157+
currentType = null;
158+
markdownBuffer.append( line ).append( "\n" );
159+
}
160+
}
161+
162+
flushBlock( currentType, block, specialBlocks );
163+
return specialBlocks;
164+
}
165+
166+
/**
167+
* Flushes the current block to the target map if non-empty.
168+
*
169+
* @param currentType The type of block being collected.
170+
* @param block The current content buffer for the block.
171+
* @param target The target map of blocks.
172+
*/
173+
private static void flushBlock( final String currentType, final StringBuilder block, final Map<String, List<String>> target ) {
174+
if ( currentType != null && !block.isEmpty() ) {
175+
target.computeIfAbsent( currentType, k -> new ArrayList<>() ).add( block.toString().strip() );
176+
block.setLength( 0 );
177+
}
178+
}
179+
180+
/**
181+
* Splits the raw markdown string into lines and strips leading whitespace from each line.
182+
*
183+
* @param rawMarkdown The original multi-line markdown string.
184+
* @return An array of trimmed lines.
185+
*/
186+
private static String[] stripLines( final String rawMarkdown ) {
187+
String[] rawLines = rawMarkdown.split( "\\R", -1 );
188+
String[] lines = new String[rawLines.length];
189+
for ( int i = 0; i < rawLines.length; i++ ) {
190+
lines[i] = rawLines[i].stripLeading();
191+
}
192+
return lines;
193+
}
194+
195+
/**
196+
* Renders a single markdown line (inline) to HTML using CommonMark.
197+
* This is used for special blocks (e.g., NOTE/EXAMPLE/SOURCE) where
198+
* markdown is allowed but not block-level structure.
199+
*
200+
* @param text Markdown content.
201+
* @return HTML output as string.
202+
*/
203+
private static String renderMarkdownInline( final String text ) {
204+
Node node = PARSER.parse( text );
205+
return RENDERER.render( node ).trim();
206+
}
207+
}
208+
209+

core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/metamodel/builder/SammBuilder.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,12 +1389,12 @@ public static ScalarValue value( final double doubleValue ) {
13891389
/* Intentionally no value(int) method here, because an int value could imply different XSD types */
13901390

13911391
public static ScalarValue value( final Object value, final Scalar type ) {
1392-
MetaModelBaseAttributes metaModelBaseAttributes;
1392+
final MetaModelBaseAttributes metaModelBaseAttributes;
13931393

1394-
if ( value instanceof ModelElement modelElement ) {
1395-
boolean hasUrn = modelElement.urn() != null;
1394+
if ( value instanceof final ModelElement modelElement ) {
1395+
final boolean hasUrn = modelElement.urn() != null;
13961396

1397-
MetaModelBaseAttributes.Builder builder = MetaModelBaseAttributes.builder()
1397+
final MetaModelBaseAttributes.Builder builder = MetaModelBaseAttributes.builder()
13981398
.isAnonymous( !hasUrn );
13991399

14001400
if ( hasUrn ) {

0 commit comments

Comments
 (0)