From 3abc71c8b8ca45725eab56407f9193c02a903f16 Mon Sep 17 00:00:00 2001 From: Tomasz Malik Date: Wed, 29 May 2024 15:00:13 +0200 Subject: [PATCH] feat: simple markdown structure, document header parser --- .editorconfig | 48 +++++++ .github/workflows/build-and-test.yml | 19 +++ .github/workflows/publish-preview.yml | 21 +++ .github/workflows/publish.yml | 21 +++ .gitignore | 14 ++ Directory.Build.props | 62 +++++++++ Directory.Build.targets | 21 +++ LICENSE | 21 +++ Package.props | 17 +++ README.md | 69 ++++++++++ TagBites.Text.Markdown.sln | 36 +++++ Version.props | 23 ++++ .../MarkdownCheckList.cs | 15 ++ .../MarkdownCheckListElement.cs | 20 +++ src/TagBites.Text.Markdown/MarkdownCode.cs | 31 +++++ .../MarkdownContentElement.cs | 59 ++++++++ .../MarkdownDocument.cs | 20 +++ .../MarkdownDocumentHeader.cs | 17 +++ .../MarkdownDocumentParser.cs | 81 +++++++++++ src/TagBites.Text.Markdown/MarkdownElement.cs | 15 ++ src/TagBites.Text.Markdown/MarkdownHeader.cs | 46 +++++++ src/TagBites.Text.Markdown/MarkdownList.cs | 36 +++++ .../MarkdownListElement.cs | 20 +++ .../MarkdownOrderedList.cs | 7 + .../MarkdownParagraph.cs | 18 +++ src/TagBites.Text.Markdown/MarkdownQuote.cs | 17 +++ .../MarkdownStringBuilder.cs | 88 ++++++++++++ src/TagBites.Text.Markdown/MarkdownSyntax.cs | 25 ++++ src/TagBites.Text.Markdown/MarkdownTable.cs | 128 ++++++++++++++++++ .../MarkdownUnitExtensions.cs | 93 +++++++++++++ .../TagBites.Text.Markdown.csproj | 21 +++ 31 files changed, 1129 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/build-and-test.yml create mode 100644 .github/workflows/publish-preview.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 LICENSE create mode 100644 Package.props create mode 100644 README.md create mode 100644 TagBites.Text.Markdown.sln create mode 100644 Version.props create mode 100644 src/TagBites.Text.Markdown/MarkdownCheckList.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownCheckListElement.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownCode.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownContentElement.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownDocument.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownDocumentHeader.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownDocumentParser.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownElement.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownHeader.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownList.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownListElement.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownOrderedList.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownParagraph.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownQuote.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownStringBuilder.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownSyntax.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownTable.cs create mode 100644 src/TagBites.Text.Markdown/MarkdownUnitExtensions.cs create mode 100644 src/TagBites.Text.Markdown/TagBites.Text.Markdown.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a90e948 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,48 @@ +# top-most EditorConfig file +root = true + +# charset +[*] +charset = utf-8 + +# lines +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = off + +# tabs +[*] +indent_style = space +indent_size = 4 +tab_width = 4 + +[*.{csproj,props,resx,targets}] +indent_style = space +indent_size = 2 +tab_width = 2 + +# rules +# CA2225: Operator overloads have named alternates +dotnet_diagnostic.CA2225.severity = none +# CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = none +# CA1054: URI-like parameters should not be strings +dotnet_diagnostic.CA1054.severity = none +# CA1056: URI-like properties should not be strings +dotnet_diagnostic.CA1056.severity = none +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = none +# CA1033: Interface methods should be callable by child types +dotnet_diagnostic.CA1033.severity = none +# CA1034: Nested types should not be visible +dotnet_diagnostic.CA1034.severity = none +# CA1308: Normalize strings to uppercase +dotnet_diagnostic.CA1308.severity = none +# CA1805: Do not initialize unnecessarily +dotnet_diagnostic.CA1805.severity = none +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none +# CA1714: Flags enums should have plural names +dotnet_diagnostic.CA1714.severity = none diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..510ee4b --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,19 @@ +name: build & test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v2 + - uses: TagBites/actions/dotnet-build@master + with: + solution: TagBites.Text.Markdown.sln + #- uses: TagBites/actions/dotnet-test@master diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml new file mode 100644 index 0000000..de19722 --- /dev/null +++ b/.github/workflows/publish-preview.yml @@ -0,0 +1,21 @@ +name: publish preview + +on: + push: + tags: + - "v?[0-9]+.[0-9]+.[0-9]+-preview.[0-9]+" + +jobs: + publish-preview: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v2 + - uses: TagBites/actions/dotnet-build@master + with: + solution: TagBites.Text.Markdown.sln + - uses: TagBites/actions/nuget-publish@master + with: + nuget-source: "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + nuget-key: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0033f5f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: publish + +on: + push: + tags: + - "v?[0-9]+.[0-9]+.[0-9]+" + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v2 + - uses: TagBites/actions/dotnet-build@master + with: + solution: TagBites.Text.Markdown.sln + - uses: TagBites/actions/nuget-publish@master + with: + nuget-source: "https://api.nuget.org/v3/index.json" + nuget-key: "${{ secrets.NUGET_KEY }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cd5166 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# User-specific files +*.DotSettings +*.user + +# Folders +/Build/keys +/Build/*.exe +/Build/*.dll +/Build/*.nupkg +/TestResults +.vs/ +packages/ +[Bb]in/ +[Oo]bj/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ec981ac --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,62 @@ + + + + + Tag Bites sp. z o.o. + Tag Bites sp. z o.o. + 2012 + + © $(CopyrightSinceYear)-$([System.DateTime]::Today.ToString(`yyyy`)) $(Company) + + + + + $(SolutionDir)bin\$(MSBuildProjectName)\ + $(SolutionDir)bin\ + $(SolutionDir)bin\obj\$(MSBuildProjectName)\ + + + + + latest + true + true + enable + + + + + true + + + + + 1701;1702;1591;NU5048;NU5125;IDE0290 + + + + + en-US + + + + + $(DefaultItemExcludes);*.csproj.DotSettings + + + + + + + + + + + + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..945f6a5 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,21 @@ + + + + + All + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80e4179 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tag Bites + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.props b/Package.props new file mode 100644 index 0000000..88d1cd8 --- /dev/null +++ b/Package.props @@ -0,0 +1,17 @@ + + + C# library for programmatically building Markdown documents. + markdown; markdown builder; markdown syntax + + https://github.com/TagBites/TagBites.Text.Markdown + + + false + MIT + + git + https://github.com/TagBites/TagBites.Text.Markdown.git + + README.md + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6743c9b --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# TagBites.Text.Markdown + +[![Nuget](https://img.shields.io/nuget/v/TagBites.Text.Markdown.svg)](https://www.nuget.org/packages/TagBites.Text.Markdown/) +[![License](http://img.shields.io/github/license/TagBites/TagBites.Text.Markdown)](https://github.com/TagBites/TagBites.Text.Markdown/blob/master/LICENSE) + +C# library for programmatically building Markdown documents. Easily add headers, tables, checklists, and more with a simple, object-oriented API. + +Supported elements: +- headers +- paragraphs +- code blocks +- quotes +- unordered lists +- ordered lists +- checklists +- tables + +Inline syntax generated by `MarkdownSyntax`: +- bold +- italic +- strike +- code +- link +- image +- html escape + +## Example + +```csharp +var doc = new MarkdownDocument(); +doc.AddHeader(1, "My Document"); +doc.AddHeader(2, "Some section"); + +doc.AddParagraph("Some table below."); + +var table = doc.AddTable(); +table.WithHeaders("col1", "col2", "col3"); +table.WithRow("a", "b", "c"); +table.WithRow("1", "2", "3"); + +doc.AddParagraph("Some check list below."); + +var list = doc.AddCheckList(); +list.AddElement(true, "task 1"); +list.AddElement(true, "task 2"); +list.AddElement(false, "task 3"); +list.AddElement(false, "task 4"); +``` + +Output: +```markdown +# My Document + +## Some section + +Some table below. + +| col1 | col2 | col3 | +| ---- | ---- | ---- | +| a | b | c | +| 1 | 2 | 3 | + +Some check list below. + +- [x] task 1 +- [x] task 2 +- [ ] task 3 +- [ ] task 4 +``` diff --git a/TagBites.Text.Markdown.sln b/TagBites.Text.Markdown.sln new file mode 100644 index 0000000..7016dbe --- /dev/null +++ b/TagBites.Text.Markdown.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33205.214 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagBites.Text.Markdown", "src\TagBites.Text.Markdown\TagBites.Text.Markdown.csproj", "{1035955E-2C67-450B-B6A3-90EA111F637B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{62CB84CB-ABBE-4834-BBD3-9084940552FE}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + Package.props = Package.props + README.md = README.md + Version.props = Version.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1035955E-2C67-450B-B6A3-90EA111F637B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1035955E-2C67-450B-B6A3-90EA111F637B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1035955E-2C67-450B-B6A3-90EA111F637B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1035955E-2C67-450B-B6A3-90EA111F637B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4C4ED72A-E0F0-44B5-8F52-EBB27C7E235B} + EndGlobalSection +EndGlobal diff --git a/Version.props b/Version.props new file mode 100644 index 0000000..f84611c --- /dev/null +++ b/Version.props @@ -0,0 +1,23 @@ + + + preview + + + + + $(MinVerMajor).$(MinVerMinor).$(MinVerPatch) + $(MinVerPreRelease) + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix) + + $(VersionFull) + $(VersionFull) + $(VersionPrefix).0 + $(VersionPrefix).0 + $(VersionFull) + + + + + + diff --git a/src/TagBites.Text.Markdown/MarkdownCheckList.cs b/src/TagBites.Text.Markdown/MarkdownCheckList.cs new file mode 100644 index 0000000..baf81c1 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownCheckList.cs @@ -0,0 +1,15 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownCheckList : MarkdownList + { + protected override bool IsCheckList => true; + + + public MarkdownListElement AddElement(bool isChecked, string text) + { + var element = new MarkdownCheckListElement(isChecked, text); + Elements.Add(element); + return element; + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownCheckListElement.cs b/src/TagBites.Text.Markdown/MarkdownCheckListElement.cs new file mode 100644 index 0000000..6d34d26 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownCheckListElement.cs @@ -0,0 +1,20 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownCheckListElement : MarkdownListElement + { + public bool IsChecked { get; } + + public MarkdownCheckListElement(bool isChecked, string text) + : base(text) + { + IsChecked = isChecked; + } + + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + builder.Append(IsChecked ? "[x] " : "[ ] "); + base.Resolve(builder); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownCode.cs b/src/TagBites.Text.Markdown/MarkdownCode.cs new file mode 100644 index 0000000..2c582ef --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownCode.cs @@ -0,0 +1,31 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownCode : MarkdownElement + { + public string? Language { get; } + public string Code { get; } + + public MarkdownCode(string? language, string code) + { + Language = language; + Code = code; + } + + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + if (builder.CleanTextMode) + builder.Append(Code); + else + { + builder.Append("```"); + if (!string.IsNullOrEmpty(Language)) + builder.Append(Language); + builder.AppendLine(); + builder.Append(Code); + builder.AppendLine(); + builder.Append("```"); + } + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownContentElement.cs b/src/TagBites.Text.Markdown/MarkdownContentElement.cs new file mode 100644 index 0000000..3a2fd02 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownContentElement.cs @@ -0,0 +1,59 @@ +namespace TagBites.Text.Markdown +{ + public abstract class MarkdownContentElement : MarkdownElement + { + private IList? _content; + + protected IList Content => _content ??= new List(); + + + public MarkdownParagraph AddParagraph(string text) => AddCore(new MarkdownParagraph(text)); + public MarkdownCode AddCode(string code) => AddCore(new MarkdownCode(null, code)); + public MarkdownCode AddCode(string language, string code) => AddCore(new MarkdownCode(language, code)); + public MarkdownQuote AddQuote(string quote) => AddCore(new MarkdownQuote(quote)); + public MarkdownList AddList() => AddCore(new MarkdownList()); + public MarkdownOrderedList AddOrderedList() => AddCore(new MarkdownOrderedList()); + public MarkdownCheckList AddCheckList() => AddCore(new MarkdownCheckList()); + public MarkdownTable AddTable() => AddCore(new MarkdownTable()); + + protected internal T AddCore(T element) where T : MarkdownElement + { + Content.Add(element); + return element; + } + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + if (_content == null) + return; + + if (builder.Length > 0 && !(this is MarkdownListElement)) + { + builder.AppendLine(); + builder.AppendLine(); + } + + for (var i = 0; i < _content.Count; i++) + { + var element = _content[i]; + + if (i > 0) + { + var previous = _content[i - 1]; + if (element is MarkdownHeader + || (element is MarkdownParagraph || element is MarkdownCode) == (previous is MarkdownParagraph || previous is MarkdownCode) + || previous is MarkdownHeader + || previous is MarkdownTable + || previous is MarkdownCode) + { + builder.AppendLine(); + } + + builder.AppendLine(); + } + + element.Resolve(builder); + } + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownDocument.cs b/src/TagBites.Text.Markdown/MarkdownDocument.cs new file mode 100644 index 0000000..40c8f42 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownDocument.cs @@ -0,0 +1,20 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownDocument : MarkdownContentElement + { + public new IList Content => base.Content; + + + public MarkdownDocument WithHeader(int level, string text) + { + AddHeader(level, text); + return this; + } + public MarkdownDocument WithHeader(int level, string text, string customId) + { + AddHeader(level, text).WithCustomId(customId); + return this; + } + public MarkdownHeader AddHeader(int level, string text) => AddCore(new MarkdownHeader(level, text)); + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownDocumentHeader.cs b/src/TagBites.Text.Markdown/MarkdownDocumentHeader.cs new file mode 100644 index 0000000..2306f67 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownDocumentHeader.cs @@ -0,0 +1,17 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownDocumentHeader + { + private readonly IDictionary _metadata; + + public string? Uid => this["uid"]; + public string? Title => this["title"]; + + public string? this[string name] => _metadata.TryGetValue(name, out var v) ? v : null; + + public MarkdownDocumentHeader(IDictionary metadata) + { + _metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownDocumentParser.cs b/src/TagBites.Text.Markdown/MarkdownDocumentParser.cs new file mode 100644 index 0000000..8ca5483 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownDocumentParser.cs @@ -0,0 +1,81 @@ +namespace TagBites.Text.Markdown +{ + public static class MarkdownDocumentParser + { + private static readonly MarkdownDocumentHeader s_empty = new(new Dictionary()); + + + public static MarkdownDocumentHeader ExtractHeader(ref string documentText) + { + if (string.IsNullOrEmpty(documentText)) + return s_empty; + + // Start '---' + var startIndex = -1; + + if (documentText.StartsWith("---")) + { + for (var i = 3; i < documentText.Length; i++) + if (documentText[i] == '\n') + { + startIndex = i + 1; + break; + } + else if (!char.IsWhiteSpace(documentText[i])) + break; + } + + if (startIndex < 0) + return s_empty; + + // End '---' + var endIndex = documentText.IndexOf("---", startIndex, StringComparison.Ordinal); + var docStartIndex = -1; + + if (endIndex > startIndex && documentText[endIndex - 1] == '\n') + { + for (var i = endIndex + 3; i < documentText.Length; i++) + if (documentText[i] == '\n') + { + docStartIndex = i + 1; + break; + } + else if (!char.IsWhiteSpace(documentText[i])) + break; + } + + if (docStartIndex < 0) + return s_empty; + + // Metadata + var headerText = documentText.Substring(startIndex, endIndex - startIndex); + documentText = documentText.Substring(docStartIndex); + + Dictionary? metadata = null; + var lines = headerText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + var index = line.IndexOf(':'); + if (index <= 0 || index + 1 == line.Length) + continue; + + var name = line.Substring(0, index).Trim(); + var value = line.Substring(index + 1).Trim(); + + if (name.Length > 0 && value.Length > 0) + { + if (metadata == null) + metadata = new Dictionary(); + + if (!metadata.ContainsKey(name)) + metadata.Add(name, value); + } + } + + return metadata == null + ? s_empty + : new MarkdownDocumentHeader(metadata); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownElement.cs b/src/TagBites.Text.Markdown/MarkdownElement.cs new file mode 100644 index 0000000..0cf6a5b --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownElement.cs @@ -0,0 +1,15 @@ +namespace TagBites.Text.Markdown +{ + public abstract class MarkdownElement + { + protected internal abstract void Resolve(MarkdownStringBuilder builder); + + public override string ToString() => ToString(false); + public string ToString(bool cleanTextMode) + { + var sb = new MarkdownStringBuilder(cleanTextMode); + Resolve(sb); + return sb.ToString(); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownHeader.cs b/src/TagBites.Text.Markdown/MarkdownHeader.cs new file mode 100644 index 0000000..4a7eaa2 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownHeader.cs @@ -0,0 +1,46 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownHeader : MarkdownElement + { + public int Level { get; } + public string Text { get; } + + public string? CustomId { get; set; } + + public MarkdownHeader(int level, string text) + { + Level = level; + Text = text; + } + + + public MarkdownHeader WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + if (builder.CleanTextMode) + builder.Append(Text); + else + { + for (var i = 0; i < Level; i++) + builder.Append('#'); + + builder.Append(' '); + builder.Append(Text); + + if (!string.IsNullOrEmpty(CustomId)) + { + builder.Append('{'); + if (CustomId[0] != '#') + builder.Append('#'); + builder.Append(CustomId); + builder.Append('}'); + } + } + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownList.cs b/src/TagBites.Text.Markdown/MarkdownList.cs new file mode 100644 index 0000000..2ea12ef --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownList.cs @@ -0,0 +1,36 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownList : MarkdownElement + { + protected virtual bool IsOrdered => false; + protected virtual bool IsCheckList => false; + + public List Elements { get; } = new(); + + + public MarkdownListElement AddElement(string text) + { + var element = new MarkdownListElement(text); + Elements.Add(element); + return element; + } + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + for (var i = 0; i < Elements.Count; i++) + { + var element = Elements[i]; + + if (IsOrdered) + { + builder.Append((i + 1).ToString()); + builder.Append(". "); + } + else + builder.Append("- "); + + element.Resolve(builder); + } + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownListElement.cs b/src/TagBites.Text.Markdown/MarkdownListElement.cs new file mode 100644 index 0000000..285c319 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownListElement.cs @@ -0,0 +1,20 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownListElement : MarkdownContentElement + { + public string Text { get; } + + public MarkdownListElement(string text) => Text = text; + + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + builder.Append(Text); + builder.AppendLine(); + + builder.PushIndent(builder.Indent + 1); + base.Resolve(builder); + builder.PopIndent(); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownOrderedList.cs b/src/TagBites.Text.Markdown/MarkdownOrderedList.cs new file mode 100644 index 0000000..d6a8abf --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownOrderedList.cs @@ -0,0 +1,7 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownOrderedList : MarkdownList + { + protected override bool IsOrdered => true; + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownParagraph.cs b/src/TagBites.Text.Markdown/MarkdownParagraph.cs new file mode 100644 index 0000000..fc271b4 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownParagraph.cs @@ -0,0 +1,18 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownParagraph : MarkdownElement + { + public string Text { get; } + + public MarkdownParagraph(string text) + { + Text = text ?? throw new ArgumentNullException(nameof(text)); + } + + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + builder.Append(Text); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownQuote.cs b/src/TagBites.Text.Markdown/MarkdownQuote.cs new file mode 100644 index 0000000..64ee2ac --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownQuote.cs @@ -0,0 +1,17 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownQuote : MarkdownElement + { + public string Text { get; } + + public MarkdownQuote(string text) => Text = text; + + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + if (!builder.CleanTextMode) + builder.Append("> "); + builder.Append(Text); + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownStringBuilder.cs b/src/TagBites.Text.Markdown/MarkdownStringBuilder.cs new file mode 100644 index 0000000..b3be6df --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownStringBuilder.cs @@ -0,0 +1,88 @@ +using System.Text; + +namespace TagBites.Text.Markdown +{ + public class MarkdownStringBuilder + { + private readonly Stack _indents = new(); + + private StringBuilder StringBuilder { get; } + public int Indent { get; private set; } + public int Length => StringBuilder.Length; + + public bool CleanTextMode { get; } + + public MarkdownStringBuilder(bool cleanTextMode) + : this(new StringBuilder(), cleanTextMode) + { } + public MarkdownStringBuilder(StringBuilder stringBuilder, bool cleanTextMode) + { + StringBuilder = stringBuilder; + CleanTextMode = cleanTextMode; + } + + + public void PushIndent() => PushIndent(Indent + 1); + public void PushIndent(int indent) + { + if (indent < 0) + throw new ArgumentOutOfRangeException(nameof(indent)); + + _indents.Push(Indent); + Indent = indent; + } + public void PopIndent() => Indent = _indents.Pop(); + + public void Append(char value) + { + if (value == '\n') + AppendLine(); + else + { + AppendIndent(); + StringBuilder.Append(value); + } + } + public void Append(string value) + { + var lines = value.Split('\n'); + + for (var i = 0; i < lines.Length; i++) + { + if (i > 0) + AppendLine(); + + AppendIndent(); + StringBuilder.Append(lines[i]); + } + } + public void Append(char value, int count) + { + if (value == '\n') + { + for (var i = 0; i < count; i++) + AppendLine(); + } + else + { + AppendIndent(); + + for (var i = 0; i < count; i++) + StringBuilder.Append(value); + } + } + public void AppendSpaces(int count) => Append(' ', count); + public void AppendLine() + { + StringBuilder.Append("\n"); + } + private void AppendIndent() + { + if (StringBuilder.Length == 0 || StringBuilder[StringBuilder.Length - 1] == '\n') + for (var i = 0; i < Indent * 4; i++) + StringBuilder.Append(' '); + } + + public override string ToString() => StringBuilder.ToString(); + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownSyntax.cs b/src/TagBites.Text.Markdown/MarkdownSyntax.cs new file mode 100644 index 0000000..24440c7 --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownSyntax.cs @@ -0,0 +1,25 @@ +namespace TagBites.Text.Markdown +{ + public static class MarkdownSyntax + { + public static string Bold(string text) => string.IsNullOrEmpty(text) ? string.Empty : $"**{text}**"; + public static string Italic(string text) => string.IsNullOrEmpty(text) ? string.Empty : $"_{text}_"; + public static string Strikethrough(string text) => string.IsNullOrEmpty(text) ? string.Empty : $"~~{text}~~"; + public static string Code(string code) => string.IsNullOrEmpty(code) ? string.Empty : $"`{code}`"; + public static string Link(string name, string address) + { + if (string.IsNullOrEmpty(address)) + throw new ArgumentException("Address can not be null or empty.", nameof(address)); + + return $"[{name}]({address})"; + } + public static string Image(string name, string address) + { + if (string.IsNullOrEmpty(address)) + throw new ArgumentException("Address can not be null or empty.", nameof(address)); + + return $"![{name}]({address})"; + } + public static string EscapeHtml(string html) => string.IsNullOrEmpty(html) ? string.Empty : html.Replace("<", "<").Replace(">", ">"); + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownTable.cs b/src/TagBites.Text.Markdown/MarkdownTable.cs new file mode 100644 index 0000000..d9b4a9f --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownTable.cs @@ -0,0 +1,128 @@ +namespace TagBites.Text.Markdown +{ + public class MarkdownTable : MarkdownElement + { + public List Headers { get; } = new(); + public List> Rows { get; } = new(); + + + public MarkdownTable WithHeader(string text) + { + Headers.Add(text); + return this; + } + public MarkdownTable WithHeaders(params string[] columns) + { + Headers.AddRange(columns); + return this; + } + + public MarkdownTable WithRow(params string[] textCells) + { + Rows.Add(textCells); + return this; + } + public MarkdownTable WithRow(IList textCells) + { + if (textCells == null) + throw new ArgumentNullException(nameof(textCells)); + + Rows.Add(textCells); + return this; + } + + protected internal override void Resolve(MarkdownStringBuilder builder) + { + builder.AppendLine(); + + if (builder.CleanTextMode) + { + for (var i = 0; i < Rows.Count; i++) + { + if (i >= 0) + builder.AppendLine(); + + WriteRow(builder, null, Rows[i]); + } + } + else + { + // Widths + var widths = new List(); + + foreach (var column in Headers) + widths.Add(column?.Length ?? 0); + + foreach (var row in Rows) + { + for (var i = 0; i < row.Count; i++) + if (widths.Count <= i) + widths.Add(row[i]?.Length ?? 0); + else + widths[i] = Math.Max(widths[i], row[i]?.Length ?? 0); + } + + for (var i = 0; i < widths.Count; i++) + if (widths[i] == 0) + widths[i] = 1; + + // Headers + WriteRow(builder, widths, Headers); + builder.AppendLine(); + + // Line + builder.Append("| "); + for (var i = 0; i < widths.Count; i++) + { + if (i > 0) + builder.Append(" | "); + + builder.Append('-', widths[i]); + } + builder.Append(" |"); + + // Rows + for (var i = 0; i < Rows.Count; i++) + { + if (i >= 0) + builder.AppendLine(); + + WriteRow(builder, widths, Rows[i]); + } + } + } + + private void WriteRow(MarkdownStringBuilder builder, IList? widths, IList cells) + { + if (builder.CleanTextMode) + { + for (var i = 0; i < cells.Count; i++) + { + if (i > 0) + builder.Append(" "); + + var data = cells[i]; + if (data != null) + builder.Append(data); + } + } + else + { + builder.Append("| "); + for (var i = 0; i < widths?.Count; i++) + { + if (i > 0) + builder.Append(" | "); + + var data = i < cells.Count ? cells[i] : null; + if (data != null) + builder.Append(data); + + var spaces = widths[i] - (data?.Length ?? 0); + builder.AppendSpaces(spaces); + } + builder.Append(" |"); + } + } + } +} diff --git a/src/TagBites.Text.Markdown/MarkdownUnitExtensions.cs b/src/TagBites.Text.Markdown/MarkdownUnitExtensions.cs new file mode 100644 index 0000000..38d2f3b --- /dev/null +++ b/src/TagBites.Text.Markdown/MarkdownUnitExtensions.cs @@ -0,0 +1,93 @@ +namespace TagBites.Text.Markdown +{ + public static class MarkdownUnitExtensions + { + public static T WithParagraph(this T content, string text) where T : MarkdownContentElement + { + content.AddParagraph(text); + return content; + } + public static T WithCode(this T content, string code) where T : MarkdownContentElement + { + content.AddCode(code); + return content; + } + public static T WithCode(this T content, string language, string code) where T : MarkdownContentElement + { + content.AddCode(language, code); + return content; + } + public static T WithQuote(this T content, string quote) where T : MarkdownContentElement + { + content.AddQuote(quote); + return content; + } + + public static T With(this T content, MarkdownParagraph paragraph) where T : MarkdownContentElement + { + content.AddCore(paragraph); + return content; + } + public static T With(this T content, MarkdownCode code) where T : MarkdownContentElement + { + content.AddCore(code); + return content; + } + public static T With(this T content, MarkdownQuote quote) where T : MarkdownContentElement + { + content.AddCore(quote); + return content; + } + public static T With(this T content, MarkdownList list) where T : MarkdownContentElement + { + content.AddCore(list); + return content; + } + public static T With(this T content, MarkdownOrderedList list) where T : MarkdownContentElement + { + content.AddCore(list); + return content; + } + public static T With(this T content, MarkdownCheckList list) where T : MarkdownContentElement + { + content.AddCore(list); + return content; + } + public static T With(this T content, MarkdownTable table) where T : MarkdownContentElement + { + content.AddCore(table); + return content; + } + + public static T WithElement(this T content, string text) where T : MarkdownList + { + content.AddElement(text); + return content; + } + public static T WithElement(this T content, bool isChecked, string text) where T : MarkdownCheckList + { + content.AddElement(isChecked, text); + return content; + } + public static T WithElement(this T content, MarkdownListElement element) where T : MarkdownList + { + content.Elements.Add(element); + return content; + } + public static T WithChildElement(this T content, string text) where T : MarkdownListElement + { + content.AddCore(new MarkdownListElement(text)); + return content; + } + public static T WithChildElement(this T content, bool isChecked, string text) where T : MarkdownCheckListElement + { + content.AddCore(new MarkdownCheckListElement(isChecked, text)); + return content; + } + public static T WithChildElement(this T content, MarkdownListElement element) where T : MarkdownListElement + { + content.AddCore(element); + return content; + } + } +} diff --git a/src/TagBites.Text.Markdown/TagBites.Text.Markdown.csproj b/src/TagBites.Text.Markdown/TagBites.Text.Markdown.csproj new file mode 100644 index 0000000..7576ba1 --- /dev/null +++ b/src/TagBites.Text.Markdown/TagBites.Text.Markdown.csproj @@ -0,0 +1,21 @@ + + + + + netstandard2.0 + + + + + true + + + + + + True + \ + + + +