|
| 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 | + |
0 commit comments