From fdf837493e0fa0a66b7e3dfbf384096886ba418d Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Wed, 5 Nov 2025 12:36:30 +0500 Subject: [PATCH 01/21] feat: add AST structure --- cs/MarkupConverter/AST/Blocks/Block.cs | 11 +++++++++++ cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs | 8 ++++++++ cs/MarkupConverter/AST/Blocks/Document.cs | 6 ++++++ cs/MarkupConverter/AST/Blocks/Header.cs | 13 +++++++++++++ cs/MarkupConverter/AST/Blocks/Paragraph.cs | 8 ++++++++ cs/MarkupConverter/AST/Inlines/Bold.cs | 8 ++++++++ cs/MarkupConverter/AST/Inlines/Inline.cs | 3 +++ cs/MarkupConverter/AST/Inlines/InlineLeaf.cs | 3 +++ cs/MarkupConverter/AST/Inlines/Italic.cs | 8 ++++++++ cs/MarkupConverter/AST/Inlines/Text.cs | 8 ++++++++ cs/MarkupConverter/AST/Node.cs | 3 +++ cs/clean-code.sln | 6 ++++++ cs/clean-code.sln.DotSettings | 3 +++ 13 files changed, 88 insertions(+) create mode 100644 cs/MarkupConverter/AST/Blocks/Block.cs create mode 100644 cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs create mode 100644 cs/MarkupConverter/AST/Blocks/Document.cs create mode 100644 cs/MarkupConverter/AST/Blocks/Header.cs create mode 100644 cs/MarkupConverter/AST/Blocks/Paragraph.cs create mode 100644 cs/MarkupConverter/AST/Inlines/Bold.cs create mode 100644 cs/MarkupConverter/AST/Inlines/Inline.cs create mode 100644 cs/MarkupConverter/AST/Inlines/InlineLeaf.cs create mode 100644 cs/MarkupConverter/AST/Inlines/Italic.cs create mode 100644 cs/MarkupConverter/AST/Inlines/Text.cs create mode 100644 cs/MarkupConverter/AST/Node.cs diff --git a/cs/MarkupConverter/AST/Blocks/Block.cs b/cs/MarkupConverter/AST/Blocks/Block.cs new file mode 100644 index 000000000..2fb13f3c6 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Block.cs @@ -0,0 +1,11 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.AST.Blocks; + +public abstract class Block : Node +{ + public List Inlines { get; } + + protected Block(List inlines) => Inlines = inlines; + +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs b/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs new file mode 100644 index 000000000..3f3b5f565 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs @@ -0,0 +1,8 @@ +namespace MarkupConverter.AST.Blocks; + +public abstract class BlockContainerNode +{ + public List Blocks { get; } + + protected BlockContainerNode(List blocks) => Blocks = blocks; +} diff --git a/cs/MarkupConverter/AST/Blocks/Document.cs b/cs/MarkupConverter/AST/Blocks/Document.cs new file mode 100644 index 000000000..59af56e34 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Document.cs @@ -0,0 +1,6 @@ +namespace MarkupConverter.AST.Blocks; + +public class Document : BlockContainerNode +{ + public Document(List blocks) : base(blocks) {} +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Header.cs b/cs/MarkupConverter/AST/Blocks/Header.cs new file mode 100644 index 000000000..7c544e818 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Header.cs @@ -0,0 +1,13 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.AST.Blocks; + +public class Header : Block +{ + public int Level { get; } + + public Header(List inlines, int level) : base(inlines) + { + Level = level; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Paragraph.cs b/cs/MarkupConverter/AST/Blocks/Paragraph.cs new file mode 100644 index 000000000..76c4a24f8 --- /dev/null +++ b/cs/MarkupConverter/AST/Blocks/Paragraph.cs @@ -0,0 +1,8 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.AST.Blocks; + +public class Paragraph : Block +{ + public Paragraph(List inlines) : base(inlines) {} +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Bold.cs b/cs/MarkupConverter/AST/Inlines/Bold.cs new file mode 100644 index 000000000..776e32857 --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Bold.cs @@ -0,0 +1,8 @@ +namespace MarkupConverter.AST.Inlines; + +public class Bold : Inline +{ + public List Inlines { get; } + + public Bold(List inlines) => Inlines = inlines; +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Inline.cs b/cs/MarkupConverter/AST/Inlines/Inline.cs new file mode 100644 index 000000000..cf3d83631 --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Inline.cs @@ -0,0 +1,3 @@ +namespace MarkupConverter.AST.Inlines; + +public abstract class Inline : Node; \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/InlineLeaf.cs b/cs/MarkupConverter/AST/Inlines/InlineLeaf.cs new file mode 100644 index 000000000..d67ce7a0d --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/InlineLeaf.cs @@ -0,0 +1,3 @@ +namespace MarkupConverter.AST.Inlines; + +public abstract class InlineLeaf : Inline; \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Italic.cs b/cs/MarkupConverter/AST/Inlines/Italic.cs new file mode 100644 index 000000000..fb3a79b82 --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Italic.cs @@ -0,0 +1,8 @@ +namespace MarkupConverter.AST.Inlines; + +public class Italic : Inline +{ + public List InlineLeaves { get; } + + public Italic(List inlineLeaves) => InlineLeaves = inlineLeaves; +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Text.cs b/cs/MarkupConverter/AST/Inlines/Text.cs new file mode 100644 index 000000000..ae9164eec --- /dev/null +++ b/cs/MarkupConverter/AST/Inlines/Text.cs @@ -0,0 +1,8 @@ +namespace MarkupConverter.AST.Inlines; + +public class Text : InlineLeaf +{ + public string Content { get; } + + public Text(string content) => Content = content; +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Node.cs b/cs/MarkupConverter/AST/Node.cs new file mode 100644 index 000000000..575f84293 --- /dev/null +++ b/cs/MarkupConverter/AST/Node.cs @@ -0,0 +1,3 @@ +namespace MarkupConverter.AST; + +public class Node; \ No newline at end of file diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 2206d54db..5f0a7b0f3 100644 --- a/cs/clean-code.sln +++ b/cs/clean-code.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlDigit", "ControlDigi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples.csproj", "{C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkupConverter", "MarkupConverter\MarkupConverter.csproj", "{1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,5 +29,9 @@ Global {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3EF41D7-50EF-4CE1-B30A-D1D81C93D7FA}.Release|Any CPU.Build.0 = Release|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/cs/clean-code.sln.DotSettings b/cs/clean-code.sln.DotSettings index 135b83ecb..53fe49b2f 100644 --- a/cs/clean-code.sln.DotSettings +++ b/cs/clean-code.sln.DotSettings @@ -1,6 +1,9 @@  <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy> + True True True Imported 10.10.2016 From 477ebb201416c5f28d77b342ee07232742fc190b Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Wed, 5 Nov 2025 12:42:22 +0500 Subject: [PATCH 02/21] feat: add parsers interfaces --- cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs | 9 +++++++++ cs/MarkupConverter/Parsers/IParser.cs | 8 ++++++++ .../Parsers/InlineParsers/IInlineParser.cs | 9 +++++++++ 3 files changed, 26 insertions(+) create mode 100644 cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs create mode 100644 cs/MarkupConverter/Parsers/IParser.cs create mode 100644 cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs diff --git a/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs new file mode 100644 index 000000000..cfe7250d9 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs @@ -0,0 +1,9 @@ +using MarkupConverter.AST.Blocks; + +namespace MarkupConverter.Parsers.BlockParsers; + +public interface IBlockParser +{ + public bool CanParse(string text); + public Block Parse(string text); +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/IParser.cs b/cs/MarkupConverter/Parsers/IParser.cs new file mode 100644 index 000000000..11414b423 --- /dev/null +++ b/cs/MarkupConverter/Parsers/IParser.cs @@ -0,0 +1,8 @@ +using MarkupConverter.AST.Blocks; + +namespace MarkupConverter.Parsers; + +public interface IParser +{ + public Document Parse(string text); +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs b/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs new file mode 100644 index 000000000..d3f9191cd --- /dev/null +++ b/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs @@ -0,0 +1,9 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.Parsers.InlineParsers; + +public interface IInlineParser +{ + public bool CanParse(string text); + public Inline Parse(string text); +} \ No newline at end of file From 585ca975810eee82fc366e17229fe6881c66dc46 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Wed, 5 Nov 2025 12:43:25 +0500 Subject: [PATCH 03/21] feat: add renderer interface --- cs/MarkupConverter/Renderers/IRenderer.cs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 cs/MarkupConverter/Renderers/IRenderer.cs diff --git a/cs/MarkupConverter/Renderers/IRenderer.cs b/cs/MarkupConverter/Renderers/IRenderer.cs new file mode 100644 index 000000000..e2f2eee69 --- /dev/null +++ b/cs/MarkupConverter/Renderers/IRenderer.cs @@ -0,0 +1,8 @@ +using MarkupConverter.AST.Blocks; + +namespace MarkupConverter.Renderers; + +public interface IRenderer +{ + public string Render(Document doc); +} \ No newline at end of file From 20b630a832be919da61c7187b12b7f7cfc2e90f3 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Wed, 5 Nov 2025 12:45:44 +0500 Subject: [PATCH 04/21] feat: add MarkupConverter --- cs/MarkupConverter/MarkupConverter.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 cs/MarkupConverter/MarkupConverter.cs diff --git a/cs/MarkupConverter/MarkupConverter.cs b/cs/MarkupConverter/MarkupConverter.cs new file mode 100644 index 000000000..868617e0f --- /dev/null +++ b/cs/MarkupConverter/MarkupConverter.cs @@ -0,0 +1,24 @@ +using MarkupConverter.Parsers; +using MarkupConverter.Renderers; + +namespace MarkupConverter; + +public class MarkupConverter +{ + private readonly IParser _parser; + private readonly IRenderer _renderer; + + public MarkupConverter(IParser parser, IRenderer renderer) + { + _parser = parser; + _renderer = renderer; + } + + public string Convert(string text) + { + var ast = _parser.Parse(text); + var markup = _renderer.Render(ast); + + return markup; + } +} \ No newline at end of file From a6fbe7bb15fc03d3a29257f763fc836d3dd7f94a Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Wed, 5 Nov 2025 21:01:13 +0500 Subject: [PATCH 05/21] feat: add csproj file --- cs/MarkupConverter/MarkupConverter.csproj | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 cs/MarkupConverter/MarkupConverter.csproj diff --git a/cs/MarkupConverter/MarkupConverter.csproj b/cs/MarkupConverter/MarkupConverter.csproj new file mode 100644 index 000000000..3a6353295 --- /dev/null +++ b/cs/MarkupConverter/MarkupConverter.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + From f7740828bf25003dd9366131343118ffaa154cf1 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:24:43 +0500 Subject: [PATCH 06/21] test: add test project --- cs/Tests/GlobalUsings.cs | 1 + cs/Tests/Tests.csproj | 25 +++++++++++++++++++++++++ cs/clean-code.sln | 6 ++++++ 3 files changed, 32 insertions(+) create mode 100644 cs/Tests/GlobalUsings.cs create mode 100644 cs/Tests/Tests.csproj diff --git a/cs/Tests/GlobalUsings.cs b/cs/Tests/GlobalUsings.cs new file mode 100644 index 000000000..cefced496 --- /dev/null +++ b/cs/Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/cs/Tests/Tests.csproj b/cs/Tests/Tests.csproj new file mode 100644 index 000000000..53c629d74 --- /dev/null +++ b/cs/Tests/Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + diff --git a/cs/clean-code.sln b/cs/clean-code.sln index 5f0a7b0f3..d12ed4bf3 100644 --- a/cs/clean-code.sln +++ b/cs/clean-code.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "Samples\Samples. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkupConverter", "MarkupConverter\MarkupConverter.csproj", "{1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{4CE52D84-D1A1-4183-908E-75A54ED2F926}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,5 +35,9 @@ Global {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C395AAA-D756-40B3-9FE9-9DB8D79CAE48}.Release|Any CPU.Build.0 = Release|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CE52D84-D1A1-4183-908E-75A54ED2F926}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 7df670621187c172cf9d49e366adada8ceb21b1a Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:25:18 +0500 Subject: [PATCH 07/21] test: add test base for MarkupConverter tests --- cs/Tests/MarkupConverterTestBase.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cs/Tests/MarkupConverterTestBase.cs diff --git a/cs/Tests/MarkupConverterTestBase.cs b/cs/Tests/MarkupConverterTestBase.cs new file mode 100644 index 000000000..c57d4c7bb --- /dev/null +++ b/cs/Tests/MarkupConverterTestBase.cs @@ -0,0 +1,23 @@ +using MarkupConverter.Parsers; +using MarkupConverter.Parsers.BlockParsers; +using MarkupConverter.Parsers.InlineParsers; +using MarkupConverter.Renderers; + +namespace Tests; + +public class MarkupConverterTestBase +{ + protected MarkupConverter.MarkupConverter Converter; + + [SetUp] + public void Setup() + { + var parser = new Parser( + [new HeaderParser(), new ParagraphParser()], + new InlineParser() + ); + var renderer = new HtmlRenderer(); + + Converter = new MarkupConverter.MarkupConverter(parser ,renderer); + } +} \ No newline at end of file From 4f36785284502cced25a93e1c1199703ab50876f Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:25:40 +0500 Subject: [PATCH 08/21] test: add Header converting tests --- .../HeaderConvertingTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs diff --git a/cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs b/cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs new file mode 100644 index 000000000..b1290d3ea --- /dev/null +++ b/cs/Tests/BlockConvertingTests/HeaderConvertingTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; + +namespace Tests.BlockConvertingTests; + +public class HeaderConvertingTests : MarkupConverterTestBase +{ + [TestCase("# header", "

header

", TestName = "One hash")] + [TestCase("## header", "

header

", TestName = "Two hashes")] + [TestCase("### header", "

header

", TestName = "Three hashes")] + [TestCase("#### header", "

header

", TestName = "Four hashes")] + [TestCase("##### header", "
header
", TestName = "Five hashes")] + [TestCase("###### header", "
header
", TestName = "Six hashes")] + public void Convert_WhenOneToSixHashes_ShouldCreateHtmlHeading(string markdown, string expectedHtml) + { + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMoreThanSixHashes_ShouldCreateParagraph() + { + var markdown = "####### header"; + var expectedHtml = "

####### header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenHasIsNotFollowedBySpace_ShouldCreateParagraph() + { + var markdown = "#header"; + var expectedHtml = "

#header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [TestCase("# header ", TestName = "Leading and trailing spaces after hash")] + [TestCase(" # header", TestName = "Leading spaces before hash")] + [TestCase(" # header ", TestName = "Leading and trailing spaces before and after hash")] + public void Convert_WhenHeadingHasLeadingAndTrailingSpaces_ShouldTrimSpaces(string markdown) + { + var expectedHtml = "

header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmptyHeading_ShouldCreateEmptyHtmlHeading() + { + var markdown = "# "; + var expectedHtml = "

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenHashesAfterOpenHash_ShouldNotCreateAnotherHtmlHeading() + { + var markdown = "# header # another header"; + var expectedHtml = "

header # another header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_ManyHeadings_ShouldCreateManyHtmlHeading() + { + var markdown = $"# header{Environment.NewLine}## another header"; + var expectedHtml = $"

header

{Environment.NewLine}

another header

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } +} \ No newline at end of file From c4d56a3fccf43f2b9710f1bde708585a67b15975 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:26:15 +0500 Subject: [PATCH 09/21] test: add Paragraph converting tests --- .../ParagraphConvertingTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs diff --git a/cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs b/cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs new file mode 100644 index 000000000..8c5374244 --- /dev/null +++ b/cs/Tests/BlockConvertingTests/ParagraphConvertingTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; + +namespace Tests.BlockConvertingTests; + +public class ParagraphConvertingTests : MarkupConverterTestBase +{ + [Test] + public void Convert_WhenSingleLineParagraph_ShouldCreateSingleParagraph() + { + var markdown = "aaa"; + var expectedHtml = "

aaa

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenTwoParagraphsSeparatedByBlankLine_ShouldCreateTwoParagraphs() + { + var markdown = $"aaa{Environment.NewLine}{Environment.NewLine}bbb"; + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMultiLineParagraphWithoutBlankLines_ShouldCreateSingleParagraph() + { + var markdown = $"aaa{Environment.NewLine}bbb{Environment.NewLine}ccc{Environment.NewLine}ddd"; + var expectedHtml = $"

aaa bbb ccc ddd

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMultipleBlankLinesBetweenParagraphs_ShouldCreateTwoParagraphs() + { + var markdown = $"aaa{Environment.NewLine}{Environment.NewLine}{Environment.NewLine}bbb"; + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenBlankLinesAtStartAndEnd_ShouldIgnoreBlankLines() + { + var markdown = $" {Environment.NewLine}{Environment.NewLine}aaa" + + $"{Environment.NewLine}{Environment.NewLine}" + + $"bbb{Environment.NewLine}{Environment.NewLine}"; + + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenParagraphHasTrailingSpacesAndLeadingSpaces_ShouldTrimSpaces() + { + var markdown = " aaa "; + var expectedHtml = "

aaa

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenHeaderAfterParagraph_ShouldCloseParagraph() + { + var markdown = $"aaa{Environment.NewLine}# bbb"; + var expectedHtml = $"

aaa

{Environment.NewLine}

bbb

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } +} \ No newline at end of file From 4be50b67866fd059be347df8b3bc8a82e6265fe9 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:29:27 +0500 Subject: [PATCH 10/21] feat: add simple inline parser realization for tests --- .../Parsers/InlineParsers/InlineParser.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs diff --git a/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs b/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs new file mode 100644 index 000000000..6de719925 --- /dev/null +++ b/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs @@ -0,0 +1,11 @@ +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.Parsers.InlineParsers; + +public class InlineParser : IInlineParser +{ + public List Parse(string text) + { + return new List { new Text(text) }; + } +} \ No newline at end of file From f2d0d286a97c84845d44b0d911adaf27205cc372 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:29:50 +0500 Subject: [PATCH 11/21] feat: add IOpenBlock interface --- .../Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs new file mode 100644 index 000000000..680a47bda --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/IOpenBlock.cs @@ -0,0 +1,13 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +public interface IOpenBlock +{ + string Content { get; } + Type BlockType { get; } + bool CanAccept(IOpenBlock block); + void Accept(IOpenBlock block); + Block Close(IInlineParser parser); +} \ No newline at end of file From 553e189169423ca9f6d4bef6c4bf6951d117efb3 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:30:45 +0500 Subject: [PATCH 12/21] feat: add realization for IOpenBlock --- .../OpenBlocks/HeaderOpenBlock.cs | 26 ++++++++++++++++ .../OpenBlocks/ParagraphOpenBlock.cs | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs create mode 100644 cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs new file mode 100644 index 000000000..231788086 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs @@ -0,0 +1,26 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +public class HeaderOpenBlock : IOpenBlock +{ + private readonly int level; + public string Content { get; } + public Type BlockType => typeof(Header); + + public HeaderOpenBlock(string content, int level) + { + Content = content; + this.level = level; + } + + public bool CanAccept(IOpenBlock block) => false; + + public void Accept(IOpenBlock block) { } + + public Block Close(IInlineParser parser) + { + return new Header(parser.Parse(Content), level); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs new file mode 100644 index 000000000..9a8eb9f24 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs @@ -0,0 +1,30 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +public class ParagraphOpenBlock : IOpenBlock +{ + public string Content { get; private set; } + public Type BlockType => typeof(Paragraph); + + public ParagraphOpenBlock(string content) + { + Content = content; + } + + public bool CanAccept(IOpenBlock block) + { + return block.BlockType == BlockType; + } + + public void Accept(IOpenBlock block) + { + Content += ' ' + block.Content; + } + + public Block Close(IInlineParser parser) + { + return new Paragraph(parser.Parse(Content)); + } +} \ No newline at end of file From 941e300f42f571e047344697359c97f295556d39 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:31:10 +0500 Subject: [PATCH 13/21] fix: refactor IBlockParser --- cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs index cfe7250d9..9cd89adc1 100644 --- a/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs +++ b/cs/MarkupConverter/Parsers/BlockParsers/IBlockParser.cs @@ -1,9 +1,9 @@ -using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; namespace MarkupConverter.Parsers.BlockParsers; public interface IBlockParser { - public bool CanParse(string text); - public Block Parse(string text); + bool CanParse(string line); + IOpenBlock Parse(string line); } \ No newline at end of file From 25eead8ac1476654af3aabdfba2aad2a26b224ca Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:32:06 +0500 Subject: [PATCH 14/21] feat: add realization for IBlockParser --- .../Parsers/BlockParsers/HeaderParser.cs | 36 +++++++++++++++++++ .../Parsers/BlockParsers/ParagraphParser.cs | 17 +++++++++ 2 files changed, 53 insertions(+) create mode 100644 cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs create mode 100644 cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs diff --git a/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs new file mode 100644 index 000000000..f04a43c64 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs @@ -0,0 +1,36 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers.BlockParsers; + +public class HeaderParser : IBlockParser +{ + public bool CanParse(string line) + { + var trimmed = line.TrimStart(); + var headingLevel = CountHeadingLevel(trimmed); + + return headingLevel is >= 1 and <= 6 + && (headingLevel == trimmed.Length || char.IsWhiteSpace(trimmed[headingLevel])); + } + + public IOpenBlock Parse(string line) + { + var trimmed = line.TrimStart(); + var headingLevel = CountHeadingLevel(trimmed); + var content = trimmed.Substring(headingLevel + 1).Trim(); + + return new HeaderOpenBlock(content, headingLevel); + } + + private static int CountHeadingLevel(string line) + { + var level = 0; + foreach (var c in line) + { + if (c == '#') level++; + else break; + } + + return level; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs new file mode 100644 index 000000000..06f8c1043 --- /dev/null +++ b/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs @@ -0,0 +1,17 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers.BlockParsers; + +public class ParagraphParser : IBlockParser +{ + public bool CanParse(string line) + { + return !string.IsNullOrWhiteSpace(line); + } + public IOpenBlock Parse(string line) + { + var content = line.Trim(); + + return new ParagraphOpenBlock(content); + } +} \ No newline at end of file From b5f43cec2b795ec29e35ca2eb8b91adedd934f97 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:32:27 +0500 Subject: [PATCH 15/21] feat: add parsing context --- cs/MarkupConverter/Parsers/ParsingContext.cs | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cs/MarkupConverter/Parsers/ParsingContext.cs diff --git a/cs/MarkupConverter/Parsers/ParsingContext.cs b/cs/MarkupConverter/Parsers/ParsingContext.cs new file mode 100644 index 000000000..2177d12ac --- /dev/null +++ b/cs/MarkupConverter/Parsers/ParsingContext.cs @@ -0,0 +1,23 @@ +using MarkupConverter.Parsers.BlockParsers.OpenBlocks; + +namespace MarkupConverter.Parsers; + +public class ParsingContext +{ + private readonly Stack stack = new(); + + public IOpenBlock? CurrentBlock => + stack.Count > 0 ? stack.Peek() : null; + + public void Push(IOpenBlock? block) + { + stack.Push(block); + } + + public IOpenBlock? Pop() + { + return stack.Count > 0 ? stack.Pop() : null; + } + + public bool HasOpenBlocks => stack.Count > 0; +} \ No newline at end of file From 26e301d559ccabae1099ef5d42a26f1f7221b971 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:33:02 +0500 Subject: [PATCH 16/21] feat: add markdown parser --- .../Parsers/InlineParsers/IInlineParser.cs | 3 +- cs/MarkupConverter/Parsers/Parser.cs | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 cs/MarkupConverter/Parsers/Parser.cs diff --git a/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs b/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs index d3f9191cd..20d1e10d6 100644 --- a/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs +++ b/cs/MarkupConverter/Parsers/InlineParsers/IInlineParser.cs @@ -4,6 +4,5 @@ namespace MarkupConverter.Parsers.InlineParsers; public interface IInlineParser { - public bool CanParse(string text); - public Inline Parse(string text); + public List Parse(string text); } \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/Parser.cs b/cs/MarkupConverter/Parsers/Parser.cs new file mode 100644 index 000000000..4585febbe --- /dev/null +++ b/cs/MarkupConverter/Parsers/Parser.cs @@ -0,0 +1,70 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.BlockParsers; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers; + +public class Parser : IParser +{ + private readonly List blockParsers; + private readonly IInlineParser inlineParser; + + public Parser(List blockParsers, IInlineParser inlineParser) + { + this.blockParsers = blockParsers; + this.inlineParser = inlineParser; + } + + public Document Parse(string text) + { + var doc = new Document([]); + var parsingContext = new ParsingContext(); + + var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + foreach (var line in lines) + { + if (IsBlank(line)) + { + if (parsingContext.HasOpenBlocks) + { + var block = parsingContext.Pop(); + doc.Blocks.Add(block.Close(inlineParser)); + } + continue; + } + + var selectedParser = blockParsers.FirstOrDefault(blockParser => blockParser.CanParse(line)); + + if(selectedParser is null) + continue; + + var newBlock = selectedParser.Parse(line); + + if (parsingContext.CurrentBlock is null) + { + parsingContext.Push(newBlock); + } + else if (parsingContext.CurrentBlock.CanAccept(newBlock)) + { + parsingContext.CurrentBlock.Accept(newBlock); + } + else + { + var oldBlock = parsingContext.Pop(); + doc.Blocks.Add(oldBlock.Close(inlineParser)); + parsingContext.Push(newBlock); + } + } + + if (parsingContext.CurrentBlock != null) + { + var block = parsingContext.Pop(); + doc.Blocks.Add(block.Close(inlineParser)); + } + + return doc; + } + + private static bool IsBlank(string line) => string.IsNullOrWhiteSpace(line); +} \ No newline at end of file From ca8411b771c2ce1bf4953212f17f50b2111862ce Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 02:33:30 +0500 Subject: [PATCH 17/21] feat: add first version of Html renderer --- cs/MarkupConverter/Renderers/HtmlRenderer.cs | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 cs/MarkupConverter/Renderers/HtmlRenderer.cs diff --git a/cs/MarkupConverter/Renderers/HtmlRenderer.cs b/cs/MarkupConverter/Renderers/HtmlRenderer.cs new file mode 100644 index 000000000..27d5b8848 --- /dev/null +++ b/cs/MarkupConverter/Renderers/HtmlRenderer.cs @@ -0,0 +1,50 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.AST.Inlines; + +namespace MarkupConverter.Renderers; + +public class HtmlRenderer : IRenderer +{ + public string Render(Document document) + { + var htmlBlocks = document.Blocks.Select(Render); + return string.Join(Environment.NewLine, htmlBlocks); + } + + private string Render(Block block) + { + return block switch + { + Header header => RenderHeader(header), + Paragraph paragraph => RenderParagraph(paragraph), + _ => throw new NotSupportedException($"Block type {block.GetType().Name} not supported") + }; + } + + private string RenderHeader(Header header) + { + var tag = $"h{header.Level}"; + var content = RenderInline(header.Inlines); + return $"<{tag}>{content}"; + } + + private string RenderParagraph(Paragraph paragraph) + { + var content = RenderInline(paragraph.Inlines); + return $"

{content}

"; + } + + + private string RenderInline(List inlineContent) + { + var res = string.Empty; + foreach (var variabInline in inlineContent) + { + if (variabInline is Text text) + { + res = text.Content; + } + } + return res; + } +} \ No newline at end of file From c9a5adee8bf9d26fb7f1a8e70f49c7f3be921792 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 03:00:00 +0500 Subject: [PATCH 18/21] refactor: apply reformating --- cs/MarkupConverter/AST/Blocks/Block.cs | 8 +- .../AST/Blocks/BlockContainerNode.cs | 9 ++- cs/MarkupConverter/AST/Blocks/Document.cs | 4 +- cs/MarkupConverter/AST/Blocks/Header.cs | 2 +- cs/MarkupConverter/AST/Blocks/Paragraph.cs | 4 +- cs/MarkupConverter/AST/Inlines/Bold.cs | 7 +- cs/MarkupConverter/AST/Inlines/Italic.cs | 7 +- cs/MarkupConverter/AST/Inlines/Text.cs | 5 +- cs/MarkupConverter/MarkupConverter.cs | 14 ++-- .../Parsers/BlockParsers/HeaderParser.cs | 8 +- .../OpenBlocks/HeaderOpenBlock.cs | 15 ++-- .../OpenBlocks/ParagraphOpenBlock.cs | 2 +- .../Parsers/BlockParsers/ParagraphParser.cs | 3 +- cs/MarkupConverter/Parsers/MarkdownParser.cs | 79 +++++++++++++++++++ cs/MarkupConverter/Parsers/Parser.cs | 70 ---------------- cs/MarkupConverter/Parsers/ParsingContext.cs | 21 +++-- cs/MarkupConverter/Renderers/HtmlRenderer.cs | 5 +- cs/Tests/MarkupConverterTestBase.cs | 2 +- 18 files changed, 149 insertions(+), 116 deletions(-) create mode 100644 cs/MarkupConverter/Parsers/MarkdownParser.cs delete mode 100644 cs/MarkupConverter/Parsers/Parser.cs diff --git a/cs/MarkupConverter/AST/Blocks/Block.cs b/cs/MarkupConverter/AST/Blocks/Block.cs index 2fb13f3c6..6ff8a231b 100644 --- a/cs/MarkupConverter/AST/Blocks/Block.cs +++ b/cs/MarkupConverter/AST/Blocks/Block.cs @@ -5,7 +5,9 @@ namespace MarkupConverter.AST.Blocks; public abstract class Block : Node { public List Inlines { get; } - - protected Block(List inlines) => Inlines = inlines; - + + protected Block(List inlines) + { + Inlines = inlines; + } } \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs b/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs index 3f3b5f565..65a003c22 100644 --- a/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs +++ b/cs/MarkupConverter/AST/Blocks/BlockContainerNode.cs @@ -3,6 +3,9 @@ namespace MarkupConverter.AST.Blocks; public abstract class BlockContainerNode { public List Blocks { get; } - - protected BlockContainerNode(List blocks) => Blocks = blocks; -} + + protected BlockContainerNode(List blocks) + { + Blocks = blocks; + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Document.cs b/cs/MarkupConverter/AST/Blocks/Document.cs index 59af56e34..0df0036fc 100644 --- a/cs/MarkupConverter/AST/Blocks/Document.cs +++ b/cs/MarkupConverter/AST/Blocks/Document.cs @@ -2,5 +2,7 @@ namespace MarkupConverter.AST.Blocks; public class Document : BlockContainerNode { - public Document(List blocks) : base(blocks) {} + public Document(List blocks) : base(blocks) + { + } } \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Blocks/Header.cs b/cs/MarkupConverter/AST/Blocks/Header.cs index 7c544e818..00225a5c8 100644 --- a/cs/MarkupConverter/AST/Blocks/Header.cs +++ b/cs/MarkupConverter/AST/Blocks/Header.cs @@ -5,7 +5,7 @@ namespace MarkupConverter.AST.Blocks; public class Header : Block { public int Level { get; } - + public Header(List inlines, int level) : base(inlines) { Level = level; diff --git a/cs/MarkupConverter/AST/Blocks/Paragraph.cs b/cs/MarkupConverter/AST/Blocks/Paragraph.cs index 76c4a24f8..fec6d263e 100644 --- a/cs/MarkupConverter/AST/Blocks/Paragraph.cs +++ b/cs/MarkupConverter/AST/Blocks/Paragraph.cs @@ -4,5 +4,7 @@ namespace MarkupConverter.AST.Blocks; public class Paragraph : Block { - public Paragraph(List inlines) : base(inlines) {} + public Paragraph(List inlines) : base(inlines) + { + } } \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Bold.cs b/cs/MarkupConverter/AST/Inlines/Bold.cs index 776e32857..9eeeaaddf 100644 --- a/cs/MarkupConverter/AST/Inlines/Bold.cs +++ b/cs/MarkupConverter/AST/Inlines/Bold.cs @@ -3,6 +3,9 @@ namespace MarkupConverter.AST.Inlines; public class Bold : Inline { public List Inlines { get; } - - public Bold(List inlines) => Inlines = inlines; + + public Bold(List inlines) + { + Inlines = inlines; + } } \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Italic.cs b/cs/MarkupConverter/AST/Inlines/Italic.cs index fb3a79b82..112efd5be 100644 --- a/cs/MarkupConverter/AST/Inlines/Italic.cs +++ b/cs/MarkupConverter/AST/Inlines/Italic.cs @@ -3,6 +3,9 @@ namespace MarkupConverter.AST.Inlines; public class Italic : Inline { public List InlineLeaves { get; } - - public Italic(List inlineLeaves) => InlineLeaves = inlineLeaves; + + public Italic(List inlineLeaves) + { + InlineLeaves = inlineLeaves; + } } \ No newline at end of file diff --git a/cs/MarkupConverter/AST/Inlines/Text.cs b/cs/MarkupConverter/AST/Inlines/Text.cs index ae9164eec..a79d08444 100644 --- a/cs/MarkupConverter/AST/Inlines/Text.cs +++ b/cs/MarkupConverter/AST/Inlines/Text.cs @@ -4,5 +4,8 @@ public class Text : InlineLeaf { public string Content { get; } - public Text(string content) => Content = content; + public Text(string content) + { + Content = content; + } } \ No newline at end of file diff --git a/cs/MarkupConverter/MarkupConverter.cs b/cs/MarkupConverter/MarkupConverter.cs index 868617e0f..a8926fa9d 100644 --- a/cs/MarkupConverter/MarkupConverter.cs +++ b/cs/MarkupConverter/MarkupConverter.cs @@ -5,20 +5,20 @@ namespace MarkupConverter; public class MarkupConverter { - private readonly IParser _parser; - private readonly IRenderer _renderer; + private readonly IParser parser; + private readonly IRenderer renderer; public MarkupConverter(IParser parser, IRenderer renderer) { - _parser = parser; - _renderer = renderer; + this.parser = parser; + this.renderer = renderer; } public string Convert(string text) { - var ast = _parser.Parse(text); - var markup = _renderer.Render(ast); - + var ast = parser.Parse(text); + var markup = renderer.Render(ast); + return markup; } } \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs index f04a43c64..d1e7bfb04 100644 --- a/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs +++ b/cs/MarkupConverter/Parsers/BlockParsers/HeaderParser.cs @@ -8,7 +8,7 @@ public bool CanParse(string line) { var trimmed = line.TrimStart(); var headingLevel = CountHeadingLevel(trimmed); - + return headingLevel is >= 1 and <= 6 && (headingLevel == trimmed.Length || char.IsWhiteSpace(trimmed[headingLevel])); } @@ -18,7 +18,7 @@ public IOpenBlock Parse(string line) var trimmed = line.TrimStart(); var headingLevel = CountHeadingLevel(trimmed); var content = trimmed.Substring(headingLevel + 1).Trim(); - + return new HeaderOpenBlock(content, headingLevel); } @@ -26,11 +26,9 @@ private static int CountHeadingLevel(string line) { var level = 0; foreach (var c in line) - { if (c == '#') level++; else break; - } - + return level; } } \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs index 231788086..ba47fd83b 100644 --- a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/HeaderOpenBlock.cs @@ -14,11 +14,16 @@ public HeaderOpenBlock(string content, int level) Content = content; this.level = level; } - - public bool CanAccept(IOpenBlock block) => false; - - public void Accept(IOpenBlock block) { } - + + public bool CanAccept(IOpenBlock block) + { + return false; + } + + public void Accept(IOpenBlock block) + { + } + public Block Close(IInlineParser parser) { return new Header(parser.Parse(Content), level); diff --git a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs index 9a8eb9f24..a1129ede4 100644 --- a/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs +++ b/cs/MarkupConverter/Parsers/BlockParsers/OpenBlocks/ParagraphOpenBlock.cs @@ -7,7 +7,7 @@ public class ParagraphOpenBlock : IOpenBlock { public string Content { get; private set; } public Type BlockType => typeof(Paragraph); - + public ParagraphOpenBlock(string content) { Content = content; diff --git a/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs b/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs index 06f8c1043..e1b0b1538 100644 --- a/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs +++ b/cs/MarkupConverter/Parsers/BlockParsers/ParagraphParser.cs @@ -8,10 +8,11 @@ public bool CanParse(string line) { return !string.IsNullOrWhiteSpace(line); } + public IOpenBlock Parse(string line) { var content = line.Trim(); - + return new ParagraphOpenBlock(content); } } \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/MarkdownParser.cs b/cs/MarkupConverter/Parsers/MarkdownParser.cs new file mode 100644 index 000000000..a5d195ef2 --- /dev/null +++ b/cs/MarkupConverter/Parsers/MarkdownParser.cs @@ -0,0 +1,79 @@ +using MarkupConverter.AST.Blocks; +using MarkupConverter.Parsers.BlockParsers; +using MarkupConverter.Parsers.InlineParsers; + +namespace MarkupConverter.Parsers; + +public class MarkdownParser : IParser +{ + private readonly List blockParsers; + private readonly IInlineParser inlineParser; + + public MarkdownParser(List blockParsers, IInlineParser inlineParser) + { + this.blockParsers = blockParsers; + this.inlineParser = inlineParser; + } + + public Document Parse(string text) + { + var doc = new Document([]); + var parsingContext = new ParsingContext(); + + var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + + foreach (var line in lines) + { + ProcessLine(line, parsingContext, doc); + } + + while (parsingContext.HasOpenBlocks) + { + var block = parsingContext.Pop(); + doc.Blocks.Add(block.Close(inlineParser)); + } + + return doc; + } + + private void ProcessLine(string line, ParsingContext parsingContext, Document doc) + { + if (IsBlank(line)) + { + if (parsingContext.HasOpenBlocks) + { + var block = parsingContext.Pop(); + doc.Blocks.Add(block.Close(inlineParser)); + } + + return; + } + + var selectedParser = blockParsers.FirstOrDefault(blockParser => blockParser.CanParse(line)); + + if (selectedParser is null) + return; + + var newBlock = selectedParser.Parse(line); + + if (!parsingContext.HasOpenBlocks) + { + parsingContext.Push(newBlock); + } + else if (parsingContext.CurrentBlock.CanAccept(newBlock)) + { + parsingContext.CurrentBlock.Accept(newBlock); + } + else + { + var oldBlock = parsingContext.Pop(); + doc.Blocks.Add(oldBlock.Close(inlineParser)); + parsingContext.Push(newBlock); + } + } + + private static bool IsBlank(string line) + { + return string.IsNullOrWhiteSpace(line); + } +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/Parser.cs b/cs/MarkupConverter/Parsers/Parser.cs deleted file mode 100644 index 4585febbe..000000000 --- a/cs/MarkupConverter/Parsers/Parser.cs +++ /dev/null @@ -1,70 +0,0 @@ -using MarkupConverter.AST.Blocks; -using MarkupConverter.Parsers.BlockParsers; -using MarkupConverter.Parsers.InlineParsers; - -namespace MarkupConverter.Parsers; - -public class Parser : IParser -{ - private readonly List blockParsers; - private readonly IInlineParser inlineParser; - - public Parser(List blockParsers, IInlineParser inlineParser) - { - this.blockParsers = blockParsers; - this.inlineParser = inlineParser; - } - - public Document Parse(string text) - { - var doc = new Document([]); - var parsingContext = new ParsingContext(); - - var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); - - foreach (var line in lines) - { - if (IsBlank(line)) - { - if (parsingContext.HasOpenBlocks) - { - var block = parsingContext.Pop(); - doc.Blocks.Add(block.Close(inlineParser)); - } - continue; - } - - var selectedParser = blockParsers.FirstOrDefault(blockParser => blockParser.CanParse(line)); - - if(selectedParser is null) - continue; - - var newBlock = selectedParser.Parse(line); - - if (parsingContext.CurrentBlock is null) - { - parsingContext.Push(newBlock); - } - else if (parsingContext.CurrentBlock.CanAccept(newBlock)) - { - parsingContext.CurrentBlock.Accept(newBlock); - } - else - { - var oldBlock = parsingContext.Pop(); - doc.Blocks.Add(oldBlock.Close(inlineParser)); - parsingContext.Push(newBlock); - } - } - - if (parsingContext.CurrentBlock != null) - { - var block = parsingContext.Pop(); - doc.Blocks.Add(block.Close(inlineParser)); - } - - return doc; - } - - private static bool IsBlank(string line) => string.IsNullOrWhiteSpace(line); -} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/ParsingContext.cs b/cs/MarkupConverter/Parsers/ParsingContext.cs index 2177d12ac..8be020d22 100644 --- a/cs/MarkupConverter/Parsers/ParsingContext.cs +++ b/cs/MarkupConverter/Parsers/ParsingContext.cs @@ -4,20 +4,25 @@ namespace MarkupConverter.Parsers; public class ParsingContext { - private readonly Stack stack = new(); + private readonly Stack stack = new(); - public IOpenBlock? CurrentBlock => - stack.Count > 0 ? stack.Peek() : null; + public IOpenBlock CurrentBlock => + stack.TryPeek(out var block) + ? block + : throw new InvalidOperationException("No open blocks in the parsing context."); - public void Push(IOpenBlock? block) + public bool HasOpenBlocks => stack.Count > 0; + + public void Push(IOpenBlock block) { stack.Push(block); } - public IOpenBlock? Pop() + public IOpenBlock Pop() { - return stack.Count > 0 ? stack.Pop() : null; - } + if (!HasOpenBlocks) + throw new InvalidOperationException("Cannot pop from an empty parsing context."); - public bool HasOpenBlocks => stack.Count > 0; + return stack.Pop(); + } } \ No newline at end of file diff --git a/cs/MarkupConverter/Renderers/HtmlRenderer.cs b/cs/MarkupConverter/Renderers/HtmlRenderer.cs index 27d5b8848..558142b3f 100644 --- a/cs/MarkupConverter/Renderers/HtmlRenderer.cs +++ b/cs/MarkupConverter/Renderers/HtmlRenderer.cs @@ -39,12 +39,9 @@ private string RenderInline(List inlineContent) { var res = string.Empty; foreach (var variabInline in inlineContent) - { if (variabInline is Text text) - { res = text.Content; - } - } + return res; } } \ No newline at end of file diff --git a/cs/Tests/MarkupConverterTestBase.cs b/cs/Tests/MarkupConverterTestBase.cs index c57d4c7bb..c24fec356 100644 --- a/cs/Tests/MarkupConverterTestBase.cs +++ b/cs/Tests/MarkupConverterTestBase.cs @@ -12,7 +12,7 @@ public class MarkupConverterTestBase [SetUp] public void Setup() { - var parser = new Parser( + var parser = new MarkdownParser( [new HeaderParser(), new ParagraphParser()], new InlineParser() ); From ffd3730f99200f001439b21a0f5a7785dcf6489e Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 11:16:09 +0500 Subject: [PATCH 19/21] test: add inline tests --- .../EmphasisConvertingTests.cs | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs diff --git a/cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs b/cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs new file mode 100644 index 000000000..1fed19329 --- /dev/null +++ b/cs/Tests/InlineConvertingTests/EmphasisConvertingTests.cs @@ -0,0 +1,297 @@ +using FluentAssertions; + +namespace Tests.InlineConvertingTests; + +public class MarkupConverterEmphasisTests : MarkupConverterTestBase +{ + [Test] + public void Convert_WhenSingleUnderscoreEmphasis_ShouldCreateEmTag() + { + var markdown = "Текст, _окруженный с двух сторон_ одинарными символами подчерка"; + var expectedHtml = "

Текст, окруженный с двух сторон одинарными символами подчерка

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenDoubleUnderscoreEmphasis_ShouldCreateStrongTag() + { + var markdown = "__Выделенный двумя символами текст__ должен становиться полужирным"; + var expectedHtml = "

Выделенный двумя символами текст должен становиться полужирным

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEscapedUnderscores_ShouldNotCreateEmphasis() + { + var markdown = @"\_Вот это\_, не должно выделиться тегом"; + var expectedHtml = "

_Вот это_, не должно выделиться тегом

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenBackSlashNotEscapingAnything_ShouldRemain() + { + var markdown = @"Здесь сим\волы экранирования\ \должны остаться.\"; + var expectedHtml = "

Здесь сим\\волы экранирования\\ \\должны остаться.\\

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEscapedBackslashBeforeUnderscore_ShouldCreateEmphasis() + { + var markdown = @"\\_вот это будет выделено тегом_"; + var expectedHtml = "

\\вот это будет выделено тегом

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenBoldContainsItalic_ShouldWorkCorrectly() + { + var markdown = "Внутри __двойного выделения _одинарное_ тоже__ работает."; + var expectedHtml = "

Внутри двойного выделения одинарное тоже работает.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenItalicContainsBold_ShouldNotWork() + { + var markdown = "Но не наоборот — внутри _одинарного __двойное__ не_ работает."; + var expectedHtml = "

Но не наоборот — внутри одинарного __двойное__ не работает.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoresInTextWithNumbers_ShouldNotCreateEmphasis() + { + var markdown = "Подчерки внутри текста c цифрами_12_3 не считаются выделением"; + var expectedHtml = "

Подчерки внутри текста c цифрами_12_3 не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisAtBeginningOfWord_ShouldWork() + { + var markdown = "и в _нач_але слова"; + var expectedHtml = "

и в начале слова

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisInMiddleOfWord_ShouldWork() + { + var markdown = "и в сер_еди_не слова"; + var expectedHtml = "

и в середине слова

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisAtEndOfWord_ShouldWork() + { + var markdown = "и в кон_це._ слова"; + var expectedHtml = "

и в конце. слова

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmphasisAcrossDifferentWords_ShouldNotWork() + { + var markdown = "В то же время выделение в ра_зных сл_овах не работает."; + var expectedHtml = "

В то же время выделение в ра_зных сл_овах не работает.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnpairedUnderscoresInParagraph_ShouldNotCreateEmphasis() + { + var markdown = "__Непарные_ символы в рамках одного абзаца не считаются выделением."; + var expectedHtml = "

__Непарные_ символы в рамках одного абзаца не считаются выделением.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoreFollowedBySpaceAfterOpening_ShouldNotStartEmphasis() + { + var markdown = "Иначе эти_ подчерки_ не считаются выделением"; + var expectedHtml = "

Иначе эти_ подчерки_ не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoreFollowedBySpaceBeforeOpening_ShouldStartEmphasis() + { + var markdown = "Иначе эти _подчерки_ не считаются выделением"; + var expectedHtml = "

Иначе эти подчерки не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscorePrecededBySpaceBeforeClosing_ShouldNotEndEmphasis() + { + var markdown = "Иначе эти _подчерки _не считаются окончанием выделения"; + var expectedHtml = "

Иначе эти _подчерки _не считаются окончанием выделения

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenUnderscoreNotPrecededBySpaceBeforeClosing_ShouldEndEmphasis() + { + var markdown = "Иначе эти _подчерки_ не считаются окончанием выделения"; + var expectedHtml = "

Иначе эти подчерки не считаются окончанием выделения

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenIntersectingDoubleAndSingleUnderscores_ShouldNotCreateEmphasis() + { + var markdown = "В случае __пересечения _двойных__ и одинарных_ подчерков"; + var expectedHtml = "

В случае __пересечения _двойных__ и одинарных_ подчерков

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenEmptyEmphasisBetweenUnderscores_ShouldNotCreateEmphasis() + { + var markdown = "Если внутри подчерков пустая строка ____, то они остаются символами подчерка."; + var expectedHtml = "

Если внутри подчерков пустая строка ____, то они остаются символами подчерка.

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenSingleUnderscoresWithSpacesBetween_ShouldNotCreateEmphasis() + { + var markdown = "Если _ здесь есть пробелы _, то теги не создаются"; + var expectedHtml = "

Если _ здесь есть пробелы _, то теги не создаются

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenDoubleUnderscoresWithSpacesBetween_ShouldNotCreateEmphasis() + { + var markdown = "Если __ здесь есть пробелы __, то теги не создаются"; + var expectedHtml = "

Если __ здесь есть пробелы __, то теги не создаются

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMixedEmphasisWithEscaping_ShouldHandleCorrectly() + { + var markdown = "Текст с _курсивом_, __жирным__ и \\_экранированным\\_"; + var expectedHtml = "

Текст с курсивом, жирным и _экранированным_

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenMultipleParagraphsWithEmphasis_ShouldHandleCorrectly() + { + var markdown = $"Первый абзац с _курсивом_{Environment.NewLine}{Environment.NewLine}" + + $"Второй абзац с __жирным__"; + var expectedHtml = $"

Первый абзац с курсивом

{Environment.NewLine}" + + $"

Второй абзац с жирным

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenComplexNestedEmphasis_ShouldHandleCorrectly() + { + var markdown = "Вот __пример _вложенного_ выделения__ и обычного _курсива_"; + var expectedHtml = + "

Вот пример вложенного выделения и обычного курсива

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenOnlyUnderscoresInText_ShouldNotCreateEmphasis() + { + var markdown = "Текст с только подчерками: ___ и ___"; + var expectedHtml = "

Текст с только подчерками: ___ и ___

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } + + [Test] + public void Convert_WhenExampleFromSpecification_ShouldMatchExpected() + { + var markdown = + "За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением"; + var expectedHtml = + "

За подчерками, начинающими выделение, должен следовать непробельный символ. Иначе эти_ подчерки_ не считаются выделением

"; + + var actualHtml = Converter.Convert(markdown); + + actualHtml.Should().Be(expectedHtml); + } +} \ No newline at end of file From 95dda10ae72284317c978a10402a8d4c5ead5097 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 11:16:32 +0500 Subject: [PATCH 20/21] feat: update InlineParser --- .../Parsers/InlineParsers/Delimiter.cs | 11 + .../Parsers/InlineParsers/InlineParser.cs | 222 +++++++++++++++++- 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs diff --git a/cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs b/cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs new file mode 100644 index 000000000..675b9f44b --- /dev/null +++ b/cs/MarkupConverter/Parsers/InlineParsers/Delimiter.cs @@ -0,0 +1,11 @@ +namespace MarkupConverter.Parsers.InlineParsers; + +public class Delimiter +{ + public int NodeIndex; + public int Length; + public bool CanOpen; + public bool CanClose; + public bool IsInsideWord; + public bool Matched; +} \ No newline at end of file diff --git a/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs b/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs index 6de719925..33fe72c76 100644 --- a/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs +++ b/cs/MarkupConverter/Parsers/InlineParsers/InlineParser.cs @@ -1,3 +1,4 @@ +using System.Text; using MarkupConverter.AST.Inlines; namespace MarkupConverter.Parsers.InlineParsers; @@ -6,6 +7,225 @@ public class InlineParser : IInlineParser { public List Parse(string text) { - return new List { new Text(text) }; + var output = new List(); + var delimiters = new List(); + var sb = new StringBuilder(); + + var i = 0; + var n = text.Length; + + void FlushText() + { + if (sb.Length == 0) return; + output.Add(new Text(sb.ToString())); + sb.Clear(); + } + + while (i < n) + { + var c = text[i]; + + if (c == '\\') + { + if (i + 1 < n) + { + var nx = text[i + 1]; + if (nx == '_' || nx == '\\') + { + sb.Append(nx); + i += 2; + } + else + { + sb.Append('\\'); + i++; + } + } + else + { + sb.Append('\\'); + i++; + } + + continue; + } + + if (c == '_') + { + var start = i; + var run = 0; + while (i < n && text[i] == '_') + { + run++; + i++; + } + + var processed = 0; + while (processed < run) + { + var take = run - processed >= 2 ? 2 : 1; + var runStart = start + processed; + + var leftPos = runStart - 1; + var rightPos = runStart + take; + + var leftIsSpace = IsWhiteSpace(leftPos, text); + var rightIsSpace = IsWhiteSpace(rightPos, text); + + var leftIsDigit = IsDigit(leftPos, text); + var rightIsDigit = IsDigit(rightPos, text); + var isInsideWord = IsInsideWord(leftPos, text) && IsInsideWord(rightPos, text); + + var canOpen = !rightIsSpace && !rightIsDigit; + var canClose = !leftIsSpace && !leftIsDigit; + + if (!canOpen && !canClose) + { + sb.Append(new string('_', take)); + } + else + { + FlushText(); + delimiters.Add(new Delimiter + { + NodeIndex = output.Count, + Length = take, + CanOpen = canOpen, + CanClose = canClose, + IsInsideWord = isInsideWord, + Matched = false + }); + } + + processed += take; + } + + continue; + } + + sb.Append(c); + i++; + } + + FlushText(); + + for (var d = 0; d < delimiters.Count; d++) + { + var closer = delimiters[d]; + if (!closer.CanClose || closer.Matched) continue; + + var j = d - 1; + while (j >= 0) + { + var opener = delimiters[j]; + if (opener.Matched) + { + j--; + continue; + } + + if (!opener.CanOpen) + { + j--; + continue; + } + + if (opener.Length != closer.Length) + { + j--; + continue; + } + + var startIdx = opener.NodeIndex; + var endIdx = closer.NodeIndex; + + if (endIdx <= startIdx) + { + j--; + continue; + } + + var contentBuilder = new StringBuilder(); + for (var k = startIdx; k < endIdx; k++) + if (output[k] is Text t) contentBuilder.Append(t.Content); + else contentBuilder.Append(output[k]); + + var contentStr = contentBuilder.ToString(); + + if (opener.IsInsideWord) + if (string.IsNullOrEmpty(contentStr) || contentStr.IndexOfAny([' ', '\t', '\r', '\n']) >= 0) + { + j--; + continue; + } + + var crossing = false; + for (var m = j + 1; m < d; m++) + if (!delimiters[m].Matched && delimiters[m].Length != opener.Length) + { + crossing = true; + break; + } + + if (crossing) + { + j--; + continue; + } + + var children = new List(); + for (var k = startIdx; k < endIdx; k++) + children.Add(output[k]); + + if (opener.Length == 1) + { + output.RemoveRange(startIdx, endIdx - startIdx); + output.Insert(startIdx, new Italic(children)); + } + else + { + output.RemoveRange(startIdx, endIdx - startIdx); + output.Insert(startIdx, new Bold(children)); + } + + var removedCount = endIdx - startIdx - 1; + if (removedCount != 0) + foreach (var delimiter in delimiters) + if (delimiter.NodeIndex > startIdx) + delimiter.NodeIndex -= removedCount; + + opener.Matched = true; + closer.Matched = true; + + break; + } + } + + for (var idx = delimiters.Count - 1; idx >= 0; idx--) + { + var delim = delimiters[idx]; + if (!delim.Matched) + { + var literal = delim.Length == 1 ? "_" : "__"; + output.Insert(delim.NodeIndex, new Text(literal)); + } + } + + return output; + } + + private static bool IsWhiteSpace(int pos, string text) + { + return pos < 0 || pos >= text.Length || char.IsWhiteSpace(text[pos]); + } + + private static bool IsDigit(int pos, string text) + { + return pos >= 0 && pos < text.Length && char.IsDigit(text[pos]); + } + + private static bool IsInsideWord(int pos, string text) + { + return pos >= 0 && pos < text.Length && char.IsLetter(text[pos]); } } \ No newline at end of file From a49482ef089df7e5e562e07297d0beb2a6b9a001 Mon Sep 17 00:00:00 2001 From: Vladimir Golubev Date: Tue, 9 Dec 2025 11:16:45 +0500 Subject: [PATCH 21/21] feat: update renderer --- cs/MarkupConverter/AST/Inlines/Italic.cs | 6 +-- cs/MarkupConverter/Renderers/HtmlRenderer.cs | 43 ++++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/cs/MarkupConverter/AST/Inlines/Italic.cs b/cs/MarkupConverter/AST/Inlines/Italic.cs index 112efd5be..17029c4da 100644 --- a/cs/MarkupConverter/AST/Inlines/Italic.cs +++ b/cs/MarkupConverter/AST/Inlines/Italic.cs @@ -1,10 +1,10 @@ namespace MarkupConverter.AST.Inlines; -public class Italic : Inline +public class Italic : InlineLeaf { - public List InlineLeaves { get; } + public List InlineLeaves { get; } - public Italic(List inlineLeaves) + public Italic(List inlineLeaves) { InlineLeaves = inlineLeaves; } diff --git a/cs/MarkupConverter/Renderers/HtmlRenderer.cs b/cs/MarkupConverter/Renderers/HtmlRenderer.cs index 558142b3f..bf0c6dd47 100644 --- a/cs/MarkupConverter/Renderers/HtmlRenderer.cs +++ b/cs/MarkupConverter/Renderers/HtmlRenderer.cs @@ -1,5 +1,6 @@ using MarkupConverter.AST.Blocks; using MarkupConverter.AST.Inlines; +using System.Text; namespace MarkupConverter.Renderers; @@ -24,24 +25,50 @@ private string Render(Block block) private string RenderHeader(Header header) { var tag = $"h{header.Level}"; - var content = RenderInline(header.Inlines); + var content = RenderInlineList(header.Inlines); return $"<{tag}>{content}"; } private string RenderParagraph(Paragraph paragraph) { - var content = RenderInline(paragraph.Inlines); + var content = RenderInlineList(paragraph.Inlines); return $"

{content}

"; } + private string RenderInlineList(List inlines) + { + var builder = new StringBuilder(); + + foreach (var inline in inlines) builder.Append(RenderInline(inline)); + + return builder.ToString(); + } + + private string RenderInline(Inline inline) + { + return inline switch + { + Text text => RenderText(text), + Bold bold => RenderBold(bold), + Italic italic => RenderItalic(italic), + _ => throw new NotSupportedException($"Inline type {inline.GetType().Name} not supported") + }; + } - private string RenderInline(List inlineContent) + private string RenderText(Text text) { - var res = string.Empty; - foreach (var variabInline in inlineContent) - if (variabInline is Text text) - res = text.Content; + return System.Net.WebUtility.HtmlEncode(text.Content); + } - return res; + private string RenderBold(Bold bold) + { + var content = RenderInlineList(bold.Inlines); + return $"{content}"; + } + + private string RenderItalic(Italic italic) + { + var content = RenderInlineList(italic.InlineLeaves); + return $"{content}"; } } \ No newline at end of file