Skip to content

Commit

Permalink
parsing: allow inlined code in headers (#1206)
Browse files Browse the repository at this point in the history
  • Loading branch information
MykolaGolubyev authored May 7, 2024
1 parent b592102 commit c9a15c4
Show file tree
Hide file tree
Showing 6 changed files with 50 additions and 196 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,10 @@
* Sections do not support bold text, images, bullet points, etc.
* To customize headings style you can pass JSON block at the end of section text
*/
public class HeadingProps {
public record HeadingProps(Map<String, ?> props) {
public static HeadingProps EMPTY = new HeadingProps(Collections.emptyMap());
public static HeadingProps STYLE_API = new HeadingProps(Collections.singletonMap("style", "api"));

private final Map<String, ?> props;

public static HeadingProps styleApiWithBadge(String badgeText) {
Map<String, Object> props = new HashMap<>();
props.put("badge", badgeText);
Expand All @@ -38,11 +36,8 @@ public static HeadingProps styleApiWithBadge(String badgeText) {
return new HeadingProps(props);
}

public HeadingProps(Map<String, ?> props) {
this.props = props;
}

public Map<String, ?> getProps() {
@Override
public Map<String, ?> props() {
return Collections.unmodifiableMap(props);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@
import org.testingisdocumenting.znai.utils.JsonUtils;

import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.*;

public class MarkdownVisitor extends AbstractVisitor {
private final ComponentsRegistry componentsRegistry;
Expand Down Expand Up @@ -293,28 +291,29 @@ private void handleParamsValidationResult(PluginParamValidationResult validation

private HeadingTextAndProps extractHeadingTextAndProps(Heading heading) {
heading.accept(ValidateNoExtraSyntaxExceptInlineCodeInHeadingVisitor.INSTANCE);
Node firstChild = heading.getFirstChild();

if (firstChild == null) {
return new HeadingTextAndProps("", HeadingProps.EMPTY);
}

String extractedText = extractText(firstChild);
List<String> textParts = new ArrayList<>();
heading.accept(new AbstractVisitor() {
@Override
public void visit(Text text) {
HeadingTextAndProps headingTextAndProps = HeadingTextAndProps.extractTextAndProps(text.getLiteral());
textParts.add(headingTextAndProps.text());
}

int startOfCurlyIdx = extractedText.indexOf('{');
if (startOfCurlyIdx == -1) {
return new HeadingTextAndProps(extractedText, HeadingProps.EMPTY);
}
@Override
public void visit(Code code) {
textParts.add(code.getLiteral());
}
});

String combinedText = String.join("", textParts).trim();

try {
String jsonStart = extractedText.substring(startOfCurlyIdx);

Map<String, ?> props = JsonUtils.deserializeAsMap(jsonStart);
String headingTextOnly = extractedText.substring(0, startOfCurlyIdx).trim();
return new HeadingTextAndProps(headingTextOnly, new HeadingProps(props));
} catch (JsonParseException e) {
throw new RuntimeException("Can't parse props of heading: " + extractedText, e);
Node lastChild = heading.getLastChild();
if (lastChild instanceof Text textNode) {
HeadingTextAndProps headingTextAndProps = HeadingTextAndProps.extractTextAndProps(textNode.getLiteral());
return new HeadingTextAndProps(combinedText, headingTextAndProps.props);
} else {
return new HeadingTextAndProps(combinedText, HeadingProps.EMPTY);
}
}

Expand All @@ -326,13 +325,22 @@ private String extractText(Node node) {
return ((Text) node).getLiteral().trim();
}

private static class HeadingTextAndProps {
private final String text;
private final HeadingProps props;
private record HeadingTextAndProps(String text, HeadingProps props) {
public static HeadingTextAndProps extractTextAndProps(String text) {
int startOfCurlyIdx = text.indexOf('{');
if (startOfCurlyIdx == -1) {
return new HeadingTextAndProps(text, HeadingProps.EMPTY);
}

try {
String jsonStart = text.substring(startOfCurlyIdx);

public HeadingTextAndProps(String text, HeadingProps props) {
this.text = text;
this.props = props;
Map<String, ?> props = JsonUtils.deserializeAsMap(jsonStart);
String headingTextOnly = text.substring(0, startOfCurlyIdx);
return new HeadingTextAndProps(headingTextOnly, new HeadingProps(props));
} catch (JsonParseException e) {
throw new RuntimeException("Can't parse props of heading: " + text, e);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ public void visit(BulletList bulletList) {

@Override
public void visit(Code code) {
onlyRegularTextAllowed();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public void onSectionStart(String title, HeadingProps headingProps) {
onSectionEnd();
}

Map<String, ?> headingPropsMap = headingProps.getProps();
Map<String, ?> headingPropsMap = headingProps.props();

String id = new PageSectionIdTitle(title, headingPropsMap).getId();
Map<String, Object> props = new LinkedHashMap<>(headingPropsMap);
Expand Down Expand Up @@ -132,7 +132,7 @@ public void onSectionEnd() {

@Override
public void onSubHeading(int level, String title, HeadingProps headingProps) {
Map<String, ?> headingPropsMap = headingProps.getProps();
Map<String, ?> headingPropsMap = headingProps.props();

String idByTitle = new PageSectionIdTitle(title, headingPropsMap).getId();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,170 +242,20 @@ world""")

@Test
void "second level section without text"() {
parse("## ")
parse("## ", Paths.get("empty-header.md"))
content.should == [[type: 'SubHeading', level: 2, title: '', id: '']]
}

@Test
void "second level section with payload"() {
parse('## Secondary Section {badge: "v2.0"}" \ntext text')
content.should == [[type: 'SubHeading', level: 2, title: 'Secondary Section', id: 'secondary-section', badge: 'v2.0'],
[type: 'Paragraph', content: [[type: 'SimpleText', text: 'text text']]]]
}

@Test
void "repeating sub headings within different parent sections"() {
parse("""
# top level section
## example
#### java
### java
## constraint
### java
#### java
#### java
# another top level
## example
## example
""")

content.should == [[title: 'top level section', id: 'top-level-section', type: 'Section',
content: [[level: 2, title: 'example', id: 'top-level-section-example', type: 'SubHeading'],
[level: 4, title: 'java', id: 'top-level-section-example-java', type: 'SubHeading'],
[level: 3, title: 'java', id: 'top-level-section-example-java-2', type: 'SubHeading'],
[level: 2, title: 'constraint', id: 'top-level-section-constraint', type: 'SubHeading'],
[level: 3, title: 'java', id: 'top-level-section-constraint-java', type: 'SubHeading'],
[level: 4, title: 'java', id: 'top-level-section-constraint-java-java', type: 'SubHeading'],
[level: 4, title: 'java', id: 'top-level-section-constraint-java-java-2', type: 'SubHeading']]],
[title: 'another top level', id: 'another-top-level', type: 'Section',
content: [[level: 2, title: 'example', id: 'another-top-level-example', type: 'SubHeading'],
[level: 2, title: 'example', id: 'another-top-level-example-2', type: 'SubHeading']]]]
}

@Test
void "top level section with styles"() {
code {
parse("# title with **text**")
} should throwException("only regular text is supported in headings")

code {
parse("# title *with*")
} should throwException("only regular text is supported in headings")
}

@Test
void "second level section with styles"() {
code {
parse("## title with **text**")
} should throwException("only regular text is supported in headings")

code {
parse("## title *with*")
} should throwException("only regular text is supported in headings")
}

@Test
void "inlined image"() {
TEST_COMPONENTS_REGISTRY.timeService().fakedFileTime = 300000

parse("text ![alt text](images/png-test.png \"custom title\") another text")
content.should == [[type: 'Paragraph', content:[
[text: "text " , type: "SimpleText"],
[title: "custom title", destination: '/test-doc/png-test.png', alt: 'alt text', type: 'Image', inlined: true,
width:762, height:581, timestamp: 300000],
[text: " another text" , type: "SimpleText"]]]]
}

@Test
void "standalone image"() {
TEST_COMPONENTS_REGISTRY.timeService().fakedFileTime = 200000

parse("![alt text](images/png-test.png \"custom title\")")
content.should == [[title: "custom title", destination: '/test-doc/png-test.png',
alt: 'alt text', inlined: false,
width:762, height:581,
timestamp: 200000,
type: 'Image']]
}

@Test
void "standalone svg image"() {
TEST_COMPONENTS_REGISTRY.timeService().fakedFileTime = 200000

parse("![alt text](images/test.svg \"custom title\")")
content.should == [[title: "custom title", destination: '/test-doc/test.svg',
alt: 'alt text', inlined: false,
timestamp: 200000,
type: 'Image']]
void "header inline code text is allowed"() {
parse('# my header about `thing` here {badge: "v3.4"}')
content.should == [[title: 'my header about thing here', id: 'my-header-about-thing-here', badge: 'v3.4', type: 'Section']]
}

@Test
void "image with external ref"() {
TEST_COMPONENTS_REGISTRY.timeService().fakedFileTime = 200000

parse("![alt text](https://host/images/png-test.png \"custom title\")")
content.should == [[title: "custom title", destination: 'https://host/images/png-test.png',
alt: 'alt text', inlined: false,
type: 'Image']]
}

@Test
void "image with no alt text"() {
TEST_COMPONENTS_REGISTRY.timeService().fakedFileTime = 200000

parse("![](images/png-test.png \"custom title\")")
content.should == [[title: "custom title", destination: '/test-doc/png-test.png',
alt: 'image', inlined: false,
width:762, height:581,
timestamp: 200000,
type: 'Image']]
}

@Test
void "include plugin"() {
parse(":include-dummy: free-form text {param1: 'v1', param2: 'v2'}")
content.should == [[type: 'IncludeDummy', ff: 'free-form text', opts: [param1: 'v1', param2: 'v2']]]
}

@Test
void "include plugin with any number of spaces in front"() {
parse(" :include-dummy: free-form text {param1: 'v1', param2: 'v2'}")
def expected = [[type: 'IncludeDummy', ff: 'free-form text', opts: [param1: 'v1', param2: 'v2']]]
content.should == expected

parse(" :include-dummy: free-form text {param1: 'v1', param2: 'v2'}")
content.should == expected
}

@Test
void "include plugin right after paragraph of text"() {
parse("hello text\n:include-dummy: free-form text {param1: 'v1', param2: 'v2'}")

content.should == [[type: 'Paragraph', content: [[text: 'hello text', type: 'SimpleText']]],
[ff: 'free-form text', opts: [param1: 'v1', param2: 'v2'], type: 'IncludeDummy']]
}

@Test
void "include plugin inside nested code block"() {
parse(" :include-dummy: free-form text {param1: 'v1', param2: 'v2'}")
content.should == [[lang: "",
snippet: ":include-dummy: free-form text {param1: 'v1', param2: 'v2'}\n",
lineNumber: "", type: "Snippet"]]
}

@Test
void "include plugin inside numbered list"() {
parse("1. step one\n\n" +
" :include-dummy: free-form text1 {param1: 'v1', param2: 'v2'}\n" +
"2. step two\n\n" +
" :include-dummy: free-form text2 {param1: 'v3', param2: 'v4'}\n")

content.should == [[delimiter: '.', startNumber: 1, type: 'OrderedList',
content: [[type: 'ListItem', content: [[type: 'Paragraph',
content: [[text: 'step one', type: 'SimpleText']]],
[ff: 'free-form text1', opts: [param1: 'v1', param2: 'v2'], type: 'IncludeDummy']]],
[type: 'ListItem', content: [[type: 'Paragraph', content: [[text: 'step two', type: 'SimpleText']]],
[ff: 'free-form text2', opts: [param1: 'v3', param2: 'v4'], type: 'IncludeDummy']]]]]]
void "sub-header inline code text is allowed"() {
parse('## my header about `thing` here {badge: "v3.4"}', Paths.get("sub-header.md"))
content.should == [[title: 'my header about thing here', id: 'my-header-about-thing-here', badge: 'v3.4', type: 'SubHeading', level: 2]]
}

@Test
Expand Down Expand Up @@ -603,6 +453,7 @@ world""")
}

private void parse(String markdown, Path path = Paths.get("test.md")) {
// use different path names if you use `sub headings` as the heading states is maintained per file/parsing
parseResult = parser.parse(path, markdown)
content = parseResult.docElement().getContent().collect { it.toMap() }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Add: Initial support for `inlined-code` in headers. For now it displays it as a regular text.

0 comments on commit c9a15c4

Please sign in to comment.