diff --git a/doc/changes/changes_4.11.0.md b/doc/changes/changes_4.11.0.md index 2e3958be..da02e291 100644 --- a/doc/changes/changes_4.11.0.md +++ b/doc/changes/changes_4.11.0.md @@ -14,6 +14,10 @@ Fix typo and syntax in agent skills. - #612: Add support for Visual Basic in the source tag importer +## Bugfixes + +- #574: Fixed invalid HTML when inline code contains underscores + ## Documentation - #604: Fixed link to Product Lifecycle in SECURITY.md diff --git a/doc/user_guide/use_cases/writing_a_specification.md b/doc/user_guide/use_cases/writing_a_specification.md index 8b95ed6d..6f0b88d1 100644 --- a/doc/user_guide/use_cases/writing_a_specification.md +++ b/doc/user_guide/use_cases/writing_a_specification.md @@ -177,6 +177,24 @@ is functionally equivalent to Tags are described in detail later in this document, see section [Distributing the Detailing Work](distributing_the_detailing_work.md#distributing-the-detailing-work). +#### Notation in Multiline Text Blocks + +Multiline text blocks within specification items—specifically the `Description` (or implicit description text), `Rationale`, and `Comment`—support a subset of Markdown notation. When OpenFastTrace processes and renders these blocks (such as in HTML reports), the following markup elements are supported: + +* **Paragraphs**: Blocks of text are grouped into paragraphs separated by blank lines. +* **Unordered Lists**: Lines starting with a bullet marker (`-`, `*`, or `+`) with up to three leading spaces create bulleted lists. Multi-line list items are supported by continuing text on following lines without an empty line in between. +* **Ordered Lists**: Lines starting with numbers followed by a period (e.g., `1.`, `2.`) create numbered lists. +* **Preformatted Blocks / Code**: Lines indented by at least four spaces are treated as preformatted text and rendered in `
` blocks.
+* **Inline Code**: Text enclosed in backticks (`` `code` ``) is rendered as inline monospace code (``).
+* **Hyperlinks**: Markdown links in the format `[Link Text](URL)` are rendered as clickable links.
+* **Emphasis (Italics)**: Text surrounded by single asterisks (`*text*`) or single underscores (`_text_`) is formatted with emphasis (``).
+* **Strong (Bold)**: Text surrounded by double asterisks (`**text**`) or double underscores (`__text__`) is formatted in bold (``).
+* **HTML Escaping**: Angle brackets (`<` and `>`) are automatically escaped (as `<` and `>`) so that code snippets, XML/HTML tags, or type parameters (such as ``) are safely rendered without breaking the document structure.
+
+##### Constraints on Multiline Text Blocks
+
+* **No Zero Characters**: Original multiline texts must not contain zero characters (NUL / `\0`). Any zero characters present in the text are removed before rendering.
+
### Quality Scenarios
diff --git a/reporter/html/src/main/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverter.java b/reporter/html/src/main/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverter.java
index 6836cc7b..b3889cb8 100644
--- a/reporter/html/src/main/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverter.java
+++ b/reporter/html/src/main/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverter.java
@@ -1,10 +1,14 @@
package org.itsallcode.openfasttrace.report.html.view.html;
+import java.util.ArrayList;
import java.util.List;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class MarkdownSpanConverter
{
+ private static final String ZERO_SEPARATOR = "\0";
+ private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile(ZERO_SEPARATOR + "(\\d+)" + ZERO_SEPARATOR);
private static final RegexReplacement INDENTED_CODE = RegexReplacement.create("( .*[\n])+", "$1
");
private static final RegexReplacement BACKTICK_QUOTED_CODE = RegexReplacement.create("`(.*?)`", "$1");
private static final RegexReplacement LINK = RegexReplacement.create("\\[([^]]*?)\\]\\(([^)].*?)\\)",
@@ -14,8 +18,9 @@ final class MarkdownSpanConverter
private static final RegexReplacement EMPHASIZED_TEXT = RegexReplacement.create("([_*])(\\p{L}(?:.*\\p{L}))\\1",
"$2");
- private static final List ALL_MARKDOWN_REPLACEMENTS = List.of(INDENTED_CODE, BACKTICK_QUOTED_CODE,
- LINK, BOLD_TEXT, EMPHASIZED_TEXT);
+ private static final List CODE_REPLACEMENTS = List.of(INDENTED_CODE, BACKTICK_QUOTED_CODE);
+ private static final List INLINE_MARKDOWN_REPLACEMENTS = List.of(LINK, BOLD_TEXT,
+ EMPHASIZED_TEXT);
// Prevent instantiation
private MarkdownSpanConverter()
@@ -25,12 +30,46 @@ private MarkdownSpanConverter()
// [impl->dsn~reporting.html.escape-html~1]
static String convertLineContent(final String input)
{
- String text = escapeHtml(input);
- for (final RegexReplacement replacement : ALL_MARKDOWN_REPLACEMENTS)
+ String text = escapeHtml(input).replace(ZERO_SEPARATOR, "");
+ final List codeSpans = new ArrayList<>();
+ for (final RegexReplacement codeReplacement : CODE_REPLACEMENTS)
+ {
+ text = extractCodeSpans(codeReplacement, text, codeSpans);
+ }
+ for (final RegexReplacement replacement : INLINE_MARKDOWN_REPLACEMENTS)
{
text = replacement.apply(text);
}
- return text;
+ return restoreCodeSpans(text, codeSpans);
+ }
+
+ private static String extractCodeSpans(final RegexReplacement codeReplacement, final String input,
+ final List codeSpans)
+ {
+ final Matcher matcher = codeReplacement.pattern.matcher(input);
+ final StringBuilder builder = new StringBuilder();
+ while (matcher.find())
+ {
+ final String placeholder = ZERO_SEPARATOR + codeSpans.size() + ZERO_SEPARATOR;
+ final String converted = codeReplacement.apply(matcher.group());
+ codeSpans.add(converted);
+ matcher.appendReplacement(builder, Matcher.quoteReplacement(placeholder));
+ }
+ matcher.appendTail(builder);
+ return builder.toString();
+ }
+
+ private static String restoreCodeSpans(final String text, final List codeSpans)
+ {
+ final Matcher matcher = PLACEHOLDER_PATTERN.matcher(text);
+ final StringBuilder builder = new StringBuilder();
+ while (matcher.find())
+ {
+ final int index = Integer.parseInt(matcher.group(1));
+ matcher.appendReplacement(builder, Matcher.quoteReplacement(codeSpans.get(index)));
+ }
+ matcher.appendTail(builder);
+ return builder.toString();
}
static String escapeHtml(String text)
diff --git a/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverterTest.java b/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverterTest.java
index 0b137b4e..890009f0 100644
--- a/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverterTest.java
+++ b/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/MarkdownSpanConverterTest.java
@@ -3,8 +3,11 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
class MarkdownSpanConverterTest
{
@@ -12,9 +15,24 @@ class MarkdownSpanConverterTest
@CsvSource(
{
"' code\n', ' code\n
'",
+ "' code_with_underscore\n', ' code_with_underscore\n
'",
+ "' code with `backticks` and _underscores_\n', ' code with `backticks` and _underscores_\n
'",
"' code\n', ' code\n
'",
"' code', ' code'",
"`code`, code",
+ "`pull_request_target`, pull_request_target",
+ "`GITHUB_OUTPUT`, GITHUB_OUTPUT",
+ "`pull_request_target` and `GITHUB_OUTPUT`, pull_request_target and GITHUB_OUTPUT",
+ "`_text_`, _text_",
+ "`__text__`, __text__",
+ "`*text*`, *text*",
+ "`**text**`, **text**",
+ "`$foo_bar`, $foo_bar",
+ "`\\d+_test`, \\d+_test",
+ "``, <xml_tag>",
+ "`[text](https://example.com)`, [text](https://example.com)",
+ "`pull_request_target` and _emphasis_ and **bold**, pull_request_target and emphasis and bold",
+ "_emphasis_ `code_with_underscore` *emphasis*, emphasis code_with_underscore emphasis",
"[text](https://example.com), text",
"[ text ](https://example.com), text ",
"prefix [text](https://example.com) suffix, prefix text suffix",
@@ -47,7 +65,32 @@ void assertConverted(final String inputLine, final String expected)
assertThat(MarkdownSpanConverter.convertLineContent(inputLine), equalTo(expected));
}
+ @Test
+ void testZeroCharactersStrippedBeforeRendering()
+ {
+ assertThat(MarkdownSpanConverter.convertLineContent("x \u00000\u0000 `y`"),
+ equalTo("x 0 y"));
+ assertThat(MarkdownSpanConverter.convertLineContent("`a` \u00001\u0000 `b`"),
+ equalTo("a 1 b"));
+ }
+
// [utest->dsn~reporting.html.escape-html~1]
+ @ParameterizedTest(name = "Line ''{0}'' converted to HTML ''{1}''")
+ @MethodSource("provideSpecialTestCases")
+ void assertConvertedSpecial(final String inputLine, final String expected)
+ {
+ assertThat(MarkdownSpanConverter.convertLineContent(inputLine), equalTo(expected));
+ }
+
+ private static java.util.stream.Stream provideSpecialTestCases()
+ {
+ return java.util.stream.Stream.of(
+ Arguments.of("`a`, `b`0`c`", "a, b0c"),
+ Arguments.of("`x` `y`0`z`", "x y0z"),
+ Arguments.of(" indented\n0`backtick`", " indented\n
0backtick")
+ );
+ }
+
@ParameterizedTest(name = "Line ''{0}'' escaped as HTML ''{1}''")
@CsvSource(
{
diff --git a/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/TestMarkdownConverter.java b/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/TestMarkdownConverter.java
index c6339273..38f2814f 100644
--- a/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/TestMarkdownConverter.java
+++ b/reporter/html/src/test/java/org/itsallcode/openfasttrace/report/html/view/html/TestMarkdownConverter.java
@@ -149,6 +149,13 @@ void testConvertCode()
"This text contains code and regular text
");
}
+ @Test
+ void testConvertCodeWithUnderscores()
+ {
+ assertConverted("Workflows that run in `pull_request_target` context must not write `GITHUB_OUTPUT`.",
+ "Workflows that run in pull_request_target context must not write GITHUB_OUTPUT.
");
+ }
+
@Test
void testConvertLink()
{