From 9dea024504a9c2e608cba1c8f8869896190063ea Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 18 Dec 2024 11:46:38 +0100 Subject: [PATCH 1/3] Add support for code callouts --- docs/source/markup/callout.md | 23 ++ docs/source/markup/code.md | 137 ++++++++-- .../ProcessorDiagnosticExtensions.cs | 8 +- .../CallOutAwareFencedCodeBlock.cs | 244 ++++++++++++++++++ .../CallOutCodeMarkdownExtension.cs | 115 +++++++++ .../Myst/Directives/CodeBlock.cs | 31 --- .../Myst/Directives/DirectiveBlock.cs | 15 +- .../Myst/Directives/DirectiveBlockParser.cs | 31 ++- .../Myst/Directives/DirectiveHtmlRenderer.cs | 29 ++- .../Directives/DirectiveMarkdownExtension.cs | 2 +- src/Elastic.Markdown/Myst/Directives/Role.cs | 2 + src/Elastic.Markdown/Myst/MarkdownParser.cs | 3 +- .../CodeBlocks/CallOutTests.cs | 141 ++++++++++ .../{Directives => CodeBlocks}/CodeTests.cs | 12 +- .../Inline/InlineImageTest.cs | 4 +- .../Inline/InlneBaseTests.cs | 19 ++ .../Inline/SubstitutionTest.cs | 8 +- 17 files changed, 743 insertions(+), 81 deletions(-) create mode 100644 docs/source/markup/callout.md create mode 100644 src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs create mode 100644 src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs delete mode 100644 src/Elastic.Markdown/Myst/Directives/CodeBlock.cs create mode 100644 tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs rename tests/Elastic.Markdown.Tests/{Directives => CodeBlocks}/CodeTests.cs (80%) diff --git a/docs/source/markup/callout.md b/docs/source/markup/callout.md new file mode 100644 index 00000000..d310b3ae --- /dev/null +++ b/docs/source/markup/callout.md @@ -0,0 +1,23 @@ +--- +title: Callouts +--- + +You can use the regular markdown code block: + +```yaml +project: + title: MyST Markdown + github: https://github.com/jupyter-book/mystmd + license: + code: MIT + content: CC-BY-4.0 <1> + subject: MyST Markdown +``` + + +### C# + +```csharp +var apiKey = new ApiKey(""); // Set up the api key +var client = new ElasticsearchClient("", apiKey); +``` diff --git a/docs/source/markup/code.md b/docs/source/markup/code.md index d3cf2e53..8f85640a 100644 --- a/docs/source/markup/code.md +++ b/docs/source/markup/code.md @@ -6,7 +6,7 @@ You can use the regular markdown code block: ```yaml project: - title: MyST Markdown + title: MyST Markdown github: https://github.com/jupyter-book/mystmd license: code: MIT @@ -26,13 +26,10 @@ project: subject: MyST Markdown ``` -This page also documents the [code directive](https://mystmd.org/guide/directives). It mentions `code-block` and `sourcecode` as aliases of the `code` directive. But `code-block` seems to behave differently. For example the `caption` option works for `code-block`, but not for `code`. +For now we only support the `caption` option on the `{code}` or `{code-block}` ```{code-block} yaml -:linenos: :caption: How to configure `license` of a project -:name: myst.yml -:emphasize-lines: 4, 5, 6 project: title: MyST Markdown github: https://github.com/jupyter-book/mystmd @@ -42,15 +39,125 @@ project: subject: MyST Markdown ``` -```{code-block} python - :caption: Code blocks can also have sidebars. - :linenos: +## Code Callouts + +### YAML + +```yaml +project: + title: MyST Markdown #1 + github: https://github.com/jupyter-book/mystmd + license: + code: MIT + content: CC-BY-4.0 + subject: MyST Markdown +``` + +### Java + +```java +// Create the low-level client +RestClient restClient = RestClient + .builder(HttpHost.create(serverUrl)) //1 + .setDefaultHeaders(new Header[]{ + new BasicHeader("Authorization", "ApiKey " + apiKey) + }) + .build(); +``` + +### Javascript + +```javascript +const { Client } = require('@elastic/elasticsearch') +const client = new Client({ + cloud: { + id: '' //1 + }, + auth: { + username: 'elastic', + password: 'changeme' + } +}) +``` + +### Ruby + +```ruby +require 'elasticsearch' + +client = Elasticsearch::Client.new( + cloud_id: '' + user: '', #1 + password: '', +) +``` + +### Go + +```go +cfg := elasticsearch.Config{ + CloudID: "CLOUD_ID", //1 + APIKey: "API_KEY" +} +es, err := elasticsearch.NewClient(cfg) +``` + +### C# - print("one") - print("two") - print("three") - print("four") - print("five") - print("six") - print("seven") +```csharp +var apiKey = new ApiKey(""); //1 +var client = new ElasticsearchClient("", apiKey); ``` + +### PHP + +```php +$hosts = [ + '192.168.1.1:9200', //1 + '192.168.1.2', // Just IP + 'mydomain.server.com:9201', // Domain + Port + 'mydomain2.server.com', // Just Domain + 'https://localhost', // SSL to localhost + 'https://192.168.1.3:9200' // SSL to IP + Port +]; +$client = ClientBuilder::create() // Instantiate a new ClientBuilder + ->setHosts($hosts) // Set the hosts + ->build(); // Build the client object +``` + +### Perl + +```perl +my $e = Search::Elasticsearch->new( #1 + nodes => [ 'https://my-test.es.us-central1.gcp.cloud.es.io' ], + elastic_cloud_api_key => 'insert here the API Key' +); +``` +### Python + +```python +from elasticsearch import Elasticsearch + +ELASTIC_PASSWORD = "" #1 + +# Found in the 'Manage Deployment' page +CLOUD_ID = "deployment-name:dXMtZWFzdDQuZ2Nw..." + +# Create the client instance +client = Elasticsearch( + cloud_id=CLOUD_ID, + basic_auth=("elastic", ELASTIC_PASSWORD) +) + +# Successful response! +client.info() +# {'name': 'instance-0000000000', 'cluster_name': ...} +``` +### Rust + +```rust +let url = Url::parse("https://example.com")?; //1 +let conn_pool = SingleNodeConnectionPool::new(url); +let transport = TransportBuilder::new(conn_pool).disable_proxy().build()?; +let client = Elasticsearch::new(transport); +``` \ No newline at end of file diff --git a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs index c2f690ce..4fc56895 100644 --- a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs +++ b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs @@ -97,7 +97,7 @@ public static void EmitWarning(this BuildContext context, IFileInfo file, string context.Collector.Channel.Write(d); } - public static void EmitError(this DirectiveBlock block, string message, Exception? e = null) + public static void EmitError(this IBlockExtension block, string message, Exception? e = null) { if (block.SkipValidation) return; @@ -107,13 +107,13 @@ public static void EmitError(this DirectiveBlock block, string message, Exceptio File = block.CurrentFile.FullName, Line = block.Line + 1, Column = block.Column, - Length = block.Directive.Length + 5, + Length = block.OpeningLength + 5, Message = message + (e != null ? Environment.NewLine + e : string.Empty), }; block.Build.Collector.Channel.Write(d); } - public static void EmitWarning(this DirectiveBlock block, string message) + public static void EmitWarning(this IBlockExtension block, string message) { if (block.SkipValidation) return; @@ -123,7 +123,7 @@ public static void EmitWarning(this DirectiveBlock block, string message) File = block.CurrentFile.FullName, Line = block.Line + 1, Column = block.Column, - Length = block.Directive.Length + 4, + Length = block.OpeningLength + 4, Message = message }; block.Build.Collector.Channel.Write(d); diff --git a/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs b/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs new file mode 100644 index 00000000..ec123f22 --- /dev/null +++ b/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs @@ -0,0 +1,244 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.Frozen; +using System.IO.Abstractions; +using System.Text.RegularExpressions; +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst.Directives; +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Syntax; + +namespace Elastic.Markdown.Myst.CallOutCode; + +public record CallOut +{ + public required int Index { get; init; } + public required string Text { get; init; } + public required bool InlineCodeAnnotation { get; init; } + + public required int SliceStart { get; init; } + + public required int Line { get; init; } +} + +public class CodeBlockWithCallOuts(BlockParser parser, ParserContext context) + : FencedCodeBlock(parser), IBlockExtension +{ + public BuildContext Build { get; } = context.Build; + + public IFileInfo CurrentFile { get; } = context.Path; + + public bool SkipValidation { get; } = context.SkipValidation; + + public int OpeningLength => Info?.Length ?? 0 + 3; + + public List? CallOuts { get; set; } + + public bool InlineAnnotations { get; set; } + + public string Language { get; set; } = "unknown"; +} + +/// +/// Parser for a . +/// +/// +public class CallOutAwareFencedCodeBlockParser : FencedBlockParserBase +{ + private const string DefaultInfoPrefix = "language-"; + + /// + /// Initializes a new instance of the class. + /// + public CallOutAwareFencedCodeBlockParser() + { + OpeningCharacters = ['`']; + InfoPrefix = DefaultInfoPrefix; + InfoParser = RoundtripInfoParser; + } + + protected override CodeBlockWithCallOuts CreateFencedBlock(BlockProcessor processor) + { + if (processor.Context is not ParserContext context) + throw new Exception("Expected parser context to be of type ParserContext"); + + var codeBlock = new CodeBlockWithCallOuts(this, context) { IndentCount = processor.Indent }; + + if (processor.TrackTrivia) + { + // mimic what internal method LinesBefore() does + codeBlock.LinesBefore = processor.LinesBefore; + processor.LinesBefore = null; + + codeBlock.TriviaBefore = processor.UseTrivia(processor.Start - 1); + codeBlock.NewLine = processor.Line.NewLine; + } + + return codeBlock; + } + + public override BlockState TryContinue(BlockProcessor processor, Block block) + { + var result = base.TryContinue(processor, block); + if (result == BlockState.Continue && !processor.TrackTrivia) + { + var fence = (CodeBlockWithCallOuts)block; + // Remove any indent spaces + var c = processor.CurrentChar; + var indentCount = fence.IndentCount; + while (indentCount > 0 && c.IsSpace()) + { + indentCount--; + c = processor.NextChar(); + } + } + + return result; + } + + public override bool Close(BlockProcessor processor, Block block) + { + if (block is not CodeBlockWithCallOuts codeBlock) + return base.Close(processor, block); + + if (processor.Context is not ParserContext context) + throw new Exception("Expected parser context to be of type ParserContext"); + + codeBlock.Language = ( + (codeBlock.Info?.IndexOf("{") ?? -1) != -1 + ? codeBlock.Arguments + : codeBlock.Info + ) ?? "unknown"; + + var lines = codeBlock.Lines; + var callOutIndex = 0; + + var originatingLine = 0; + for (var index = 0; index < lines.Lines.Length; index++) + { + originatingLine++; + var line = lines.Lines[index]; + var span = line.Slice.AsSpan(); + + if (ReplaceSubstitutions(context, span, out var replacement)) + { + var s = new StringSlice(replacement); + lines.Lines[index] = new StringLine(ref s); + span = lines.Lines[index].Slice.AsSpan(); + } + + var matchClassicCallout = CallOutParser.CallOutNumber().EnumerateMatches(span); + var callOut = EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false); + + if (callOut is null) + { + var matchInline = CallOutParser.MathInlineAnnotation().EnumerateMatches(span); + callOut = EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, + true); + } + + if (callOut is null) + continue; + + codeBlock.CallOuts ??= []; + codeBlock.CallOuts.Add(callOut); + } + + //update string slices to ignore call outs + if (codeBlock.CallOuts is not null) + { + foreach (var callout in codeBlock.CallOuts) + lines.Lines[callout.Line - 1].Slice.End = callout.SliceStart; + } + + var inlineAnnotations = codeBlock.CallOuts?.Where(c => c.InlineCodeAnnotation).Count() ?? 0; + var classicAnnotations = codeBlock.CallOuts?.Count - inlineAnnotations ?? 0; + if (inlineAnnotations > 0 && classicAnnotations > 0) + codeBlock.EmitError("Both inline and classic callouts are not supported"); + + if (inlineAnnotations > 0) + codeBlock.InlineAnnotations = true; + + return base.Close(processor, block); + } + + private static bool ReplaceSubstitutions(ParserContext context, ReadOnlySpan span, out string? replacement) + { + replacement = null; + var substitutions = context.FrontMatter?.Properties ?? new(); + if (substitutions.Count == 0) + return false; + + var matchSubs = CallOutParser.MatchSubstitutions().EnumerateMatches(span); + + var replaced = false; + foreach (var match in matchSubs) + { + if (match.Length == 0) + continue; + + var spanMatch = span.Slice(match.Index, match.Length); + var key = spanMatch.Trim(['{', '}']); + + // TODO: alternate lookup using span in c# 9 + if (substitutions.TryGetValue(key.ToString(), out var value)) + { + replacement ??= span.ToString(); + replacement = replacement.Replace(spanMatch.ToString(), value); + replaced = true; + } + + } + + return replaced; + } + + private static CallOut? EnumerateAnnotations(Regex.ValueMatchEnumerator matches, + ref ReadOnlySpan span, + ref int callOutIndex, + int originatingLine, + bool inlineCodeAnnotation) + { + foreach (var match in matches) + { + if (match.Length == 0) continue; + + var startIndex = span.LastIndexOf("<"); + if (!inlineCodeAnnotation && startIndex <= 0) continue; + if (inlineCodeAnnotation) + { + startIndex = Math.Max(span.LastIndexOf("//"), span.LastIndexOf('#')); + if (startIndex <= 0) + continue; + } + + callOutIndex++; + var callout = span.Slice(match.Index + startIndex, match.Length - startIndex); + return new CallOut + { + Index = callOutIndex, + Text = callout.TrimStart('/').TrimStart('#').TrimStart().ToString(), + InlineCodeAnnotation = inlineCodeAnnotation, + SliceStart = startIndex, + Line = originatingLine, + }; + } + + return null; + } +} + +public static partial class CallOutParser +{ + [GeneratedRegex(@"^.+\S+.*?\s<\d+>$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex CallOutNumber(); + + [GeneratedRegex(@"^.+\S+.*?\s(?:\/\/|#)\s[^""]+$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex MathInlineAnnotation(); + + [GeneratedRegex(@"\{\{[^\r\n}]+?\}\}", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex MatchSubstitutions(); +} diff --git a/src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs b/src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs new file mode 100644 index 00000000..c2db0038 --- /dev/null +++ b/src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs @@ -0,0 +1,115 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst.Directives; +using Markdig; +using Markdig.Parsers; +using Markdig.Renderers; +using Markdig.Renderers.Html; +using Markdig.Syntax; + +namespace Elastic.Markdown.Myst.CallOutCode; + +public static class CallOutCodeBuilderExtensions +{ + public static MarkdownPipelineBuilder UseCallOutAwareCodeBlocks(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +/// +/// Extension to allow custom containers. +/// +/// +public class CallOutCodeMarkdownExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) + { + pipeline.BlockParsers.Replace(new CallOutAwareFencedCodeBlockParser()); + } + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) + { + if (!renderer.ObjectRenderers.Contains()) + { + // Must be inserted before CodeBlockRenderer + renderer.ObjectRenderers.InsertBefore(new DirectiveHtmlRenderer()); + } + + renderer.ObjectRenderers.Replace(new CallOutCodeRenderer()); + } +} + +public class CallOutCodeRenderer : HtmlObjectRenderer +{ + protected override void Write(HtmlRenderer renderer, CodeBlockWithCallOuts block) + { + var callOuts = block.CallOuts ?? []; + + renderer.WriteLine("
");
+		renderer.WriteLeafRawLines(block, true, false, false);
+		renderer.WriteLine("
"); + + if (!block.InlineAnnotations && callOuts.Count > 0) + { + var index = block.Parent!.IndexOf(block); + if (index == block.Parent!.Count - 1) + block.EmitError("Code block with annotations is not followed by any content, needs numbered list"); + else + { + var siblingBlock = block.Parent[index + 1]; + if (siblingBlock is not ListBlock) + block.EmitError("Code block with annotations is not followed by a list"); + if (siblingBlock is ListBlock l && l.Count != callOuts.Count) + { + block.EmitError( + $"Code block has {callOuts.Count} callouts but the following list only has {l.Count}"); + } + else if (siblingBlock is ListBlock listBlock) + { + block.Parent.Remove(listBlock); + renderer.WriteLine("
    "); + foreach (var child in listBlock) + { + var listItem = (ListItemBlock)child; + var previousImplicit = renderer.ImplicitParagraph; + renderer.ImplicitParagraph = !listBlock.IsLoose; + + renderer.EnsureLine(); + if (renderer.EnableHtmlForBlock) + { + renderer.Write("'); + } + + renderer.WriteChildren(listItem); + + if (renderer.EnableHtmlForBlock) + renderer.WriteLine(""); + + renderer.EnsureLine(); + renderer.ImplicitParagraph = previousImplicit; + } + renderer.WriteLine("
"); + } + } + } + else if (block.InlineAnnotations) + { + renderer.WriteLine("
    "); + foreach (var c in block.CallOuts ?? []) + { + renderer.WriteLine("
  1. "); + renderer.WriteLine(c.Text); + renderer.WriteLine("
  2. "); + } + + renderer.WriteLine("
"); + } + } +} diff --git a/src/Elastic.Markdown/Myst/Directives/CodeBlock.cs b/src/Elastic.Markdown/Myst/Directives/CodeBlock.cs deleted file mode 100644 index b76ed7e6..00000000 --- a/src/Elastic.Markdown/Myst/Directives/CodeBlock.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information -namespace Elastic.Markdown.Myst.Directives; - -public class CodeBlock( - DirectiveBlockParser parser, - string directive, - Dictionary properties, - ParserContext context) - : DirectiveBlock(parser, properties, context) -{ - public override string Directive => directive; - public string? Caption { get; private set; } - - public string Language - { - get - { - var language = (Directive is "code" or "code-block" or "sourcecode" ? Arguments : Info) ?? "unknown"; - return language; - - } - } - - public override void FinalizeAndValidate(ParserContext context) - { - Caption = Properties.GetValueOrDefault("caption"); - CrossReferenceName = Prop("name", "label"); - } -} diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs index cc4d6245..268b6419 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlock.cs @@ -12,6 +12,17 @@ namespace Elastic.Markdown.Myst.Directives; +public interface IBlockExtension : IBlock +{ + BuildContext Build { get; } + + bool SkipValidation { get; } + + IFileInfo CurrentFile { get; } + + int OpeningLength { get; } +} + /// /// A block custom container. /// @@ -28,7 +39,7 @@ public abstract class DirectiveBlock( Dictionary properties, ParserContext context ) - : ContainerBlock(parser), IFencedBlock + : ContainerBlock(parser), IFencedBlock, IBlockExtension { protected IReadOnlyDictionary Properties { get; } = properties; @@ -38,6 +49,8 @@ ParserContext context public bool SkipValidation { get; } = context.SkipValidation; + public int OpeningLength => Directive.Length; + public abstract string Directive { get; } public string? CrossReferenceName { get; protected set; } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 852937d0..e28a6f1f 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -73,9 +73,6 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (processor.Context is not ParserContext context) throw new Exception("Expected parser context to be of type ParserContext"); - if (info.IndexOf("{") == -1) - return new CodeBlock(this, "raw", _admonitionData, context); - // TODO alternate lookup .NET 9 var directive = info.ToString().Trim(['{', '}', '`']); if (_unsupportedBlocks.TryGetValue(directive, out var issueId)) @@ -129,11 +126,6 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) return new VersionBlock(this, version, _admonitionData, context); } - foreach (var code in _codeBlocks) - { - if (info.IndexOf($"{{{code}}}") > 0) - return new CodeBlock(this, code, _admonitionData, context); - } return new UnknownDirectiveBlock(this, info.ToString(), _admonitionData, context); } @@ -145,6 +137,29 @@ public override bool Close(BlockProcessor processor, Block block) return base.Close(processor, block); } + public override BlockState TryOpen(BlockProcessor processor) + { + if (processor.Context is not ParserContext context) + throw new Exception("Expected parser context to be of type ParserContext"); + + // We expect no indentation for a fenced code block. + if (processor.IsCodeIndent) + return BlockState.None; + + var line = processor.Line; + + foreach (var code in _codeBlocks) + { + if (line.IndexOf($"{{{code}}}") > 0) + return BlockState.None; + } + + if (line.IndexOf("{") == -1) + return BlockState.None; + + return base.TryOpen(processor); + } + public override BlockState TryContinue(BlockProcessor processor, Block block) { var line = processor.Line.AsSpan(); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index df88297d..c27ed170 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -6,6 +6,7 @@ // See the license.txt file in the project root for more information. using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst.CallOutCode; using Elastic.Markdown.Myst.FrontMatter; using Elastic.Markdown.Myst.Settings; using Elastic.Markdown.Myst.Substitution; @@ -53,9 +54,6 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case VersionBlock versionBlock: WriteVersion(renderer, versionBlock); return; - case CodeBlock codeBlock: - WriteCode(renderer, codeBlock); - return; case TabSetBlock tabSet: WriteTabSet(renderer, tabSet); return; @@ -164,13 +162,15 @@ private void WriteDropdown(HtmlRenderer renderer, DropdownBlock block) RenderRazorSlice(slice, renderer, block); } - private void WriteCode(HtmlRenderer renderer, CodeBlock block) + private void WriteCode(HtmlRenderer renderer, CodeBlockWithCallOuts block) { var slice = Code.Create(new CodeViewModel { - CrossReferenceName = block.CrossReferenceName, Language = block.Language, Caption = block.Caption + CrossReferenceName = string.Empty,// block.CrossReferenceName, + Language = block.Language, + Caption = string.Empty }); - RenderRazorSliceRawContent(slice, renderer, block); + //RenderRazorSliceRawContent(slice, renderer, block); } @@ -317,13 +317,20 @@ void RenderLeaf(LeafBlock p) renderer.EnableHtmlForInline = false; foreach (var oo in p.Inline ?? []) { - if (oo is SubstitutionLeaf sl) renderer.Write(sl.Replacement); - if (oo is LiteralInline li) + else if (oo is LiteralInline li) renderer.Write(li); - if (oo is LineBreakInline) + else if (oo is LineBreakInline) renderer.WriteLine(); + else if (oo is Role r) + { + renderer.Write(new string(r.DelimiterChar, r.DelimiterCount)); + renderer.WriteChildren(r); + } + + else + renderer.Write($"(LeafBlock: {oo.GetType().Name}"); } renderer.EnableHtmlForInline = true; @@ -342,6 +349,8 @@ void RenderListBlock(ListBlock l) foreach (var lll in ll) Render(lll); } + else + renderer.Write($"(ListBlock: {l.GetType().Name}"); } } @@ -351,6 +360,8 @@ void Render(Block o) RenderLeaf(p); else if (o is ListBlock l) RenderListBlock(l); + else + renderer.Write($"(Block: {o.GetType().Name}"); } } } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs index 8008c313..a5c23100 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveMarkdownExtension.cs @@ -45,7 +45,7 @@ public void Setup(MarkdownPipelineBuilder pipeline) inlineParser.TryCreateEmphasisInlineList.Add((emphasisChar, delimiterCount) => { if (delimiterCount == 2 && emphasisChar == ':') - return new Role(); + return new Role { DelimiterChar = ':', DelimiterCount = 2 }; return null; }); diff --git a/src/Elastic.Markdown/Myst/Directives/Role.cs b/src/Elastic.Markdown/Myst/Directives/Role.cs index e8f01ef8..1c7ff922 100644 --- a/src/Elastic.Markdown/Myst/Directives/Role.cs +++ b/src/Elastic.Markdown/Myst/Directives/Role.cs @@ -9,6 +9,8 @@ namespace Elastic.Markdown.Myst.Directives; + +//TODO evaluate if we need this /// /// An inline custom container /// diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index ee3ff9d6..0f36c827 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Cysharp.IO; using Elastic.Markdown.IO; +using Elastic.Markdown.Myst.CallOutCode; using Elastic.Markdown.Myst.Comments; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.FrontMatter; @@ -40,7 +41,6 @@ public class MarkdownParser( .EnableTrackTrivia() .UsePreciseSourceLocation() .UseDiagnosticLinks() - .UseGenericAttributes() .UseEmphasisExtras(EmphasisExtraOptions.Default) .UseSoftlineBreakAsHardlineBreak() .UseSubstitution() @@ -49,6 +49,7 @@ public class MarkdownParser( .UseGridTables() .UsePipeTables() .UseDirectives() + .UseCallOutAwareCodeBlocks() .DisableHtml() .Build(); diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs new file mode 100644 index 00000000..b67e9c17 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs @@ -0,0 +1,141 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Tests.Inline; +using FluentAssertions; +using JetBrains.Annotations; +using Xunit.Abstractions; + +namespace Elastic.Markdown.Tests.CodeBlocks; + +public abstract class CodeBlockCallOutTests( + ITestOutputHelper output, + string language, + [LanguageInjection("csharp")] string code, + [LanguageInjection("markdown")] string? markdown = null +) + : BlockTest(output, +$$""" +```{{language}} +{{code}} +``` +{{markdown}} +""" +) +{ + [Fact] + public void ParsesAdmonitionBlock() => Block.Should().NotBeNull(); + + [Fact] + public void SetsLanguage() => Block!.Language.Should().Be("csharp"); + +} + +public class MagicCalOuts(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +""" +var x = 1; // this is a callout +//this is not a callout +var y = x - 2; +var z = y - 2; // another callout +""" + ) +{ + [Fact] + public void ParsesMagicCallOuts() => Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(2) + .And.NotContain(c=>c.Text.Contains("not a callout")); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class ClassicCallOutsRequiresContent(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +""" +var x = 1; <1> +var y = x - 2; +var z = y - 2; <2> +""" + ) +{ + [Fact] + public void ParsesMagicCallOuts() => Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(2) + .And.OnlyContain(c=>c.Text.StartsWith("<")); + + [Fact] + public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) + .And.OnlyContain(c=> c.Message.StartsWith("Code block with annotations is not followed by any content")); +} + +public class ClassicCallOutsNotFollowedByList(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +""" +var x = 1; <1> +var y = x - 2; +var z = y - 2; <2> +""", +""" +## hello world +""" + + ) +{ + [Fact] + public void ParsesMagicCallOuts() => Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(2) + .And.OnlyContain(c=>c.Text.StartsWith("<")); + + [Fact] + public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) + .And.OnlyContain(c=> c.Message.StartsWith("Code block with annotations is not followed by a list")); +} + +public class ClassicCallOutsFollowedByListWithWrongCoung(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +""" +var x = 1; <1> +var y = x - 2; +var z = y - 2; <2> +""", +""" +1. Only marking the first callout +""" + + ) +{ + [Fact] + public void ParsesMagicCallOuts() => Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(2) + .And.OnlyContain(c=>c.Text.StartsWith("<")); + + [Fact] + public void RequiresContentToFollow() => Collector.Diagnostics.Should().HaveCount(1) + .And.OnlyContain(c=> c.Message.StartsWith("Code block has 2 callouts but the following list only has 1")); +} + +public class ClassicCallOutWithTheRightListItems(ITestOutputHelper output) : CodeBlockCallOutTests(output, "csharp", +""" +var x = 1; <1> +var y = x - 2; +var z = y - 2; <2> +""", +""" +1. First callout +2. Second callout +""" + + ) +{ + [Fact] + public void ParsesMagicCallOuts() => Block!.CallOuts + .Should().NotBeNullOrEmpty() + .And.HaveCount(2) + .And.OnlyContain(c=>c.Text.StartsWith("<")); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} diff --git a/tests/Elastic.Markdown.Tests/Directives/CodeTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs similarity index 80% rename from tests/Elastic.Markdown.Tests/Directives/CodeTests.cs rename to tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs index 3973ae83..e0920ce9 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CodeTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs @@ -1,13 +1,16 @@ // Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Markdown.Myst.Directives; + +using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Tests.Inline; using FluentAssertions; using Xunit.Abstractions; -namespace Elastic.Markdown.Tests.Directives; +namespace Elastic.Markdown.Tests.CodeBlocks; -public abstract class CodeBlockTests(ITestOutputHelper output, string directive, string? language = null) : DirectiveTest(output, +public abstract class CodeBlockTests(ITestOutputHelper output, string directive, string? language = null) + : BlockTest(output, $$""" ```{{directive}} {{language}} var x = 1; @@ -18,9 +21,6 @@ A regular paragraph. { [Fact] public void ParsesAdmonitionBlock() => Block.Should().NotBeNull(); - - [Fact] - public void SetsCorrectDirectiveType() => Block!.Directive.Should().Be(language != null ? directive.Trim('{','}') : "raw"); } public class CodeBlockDirectiveTests(ITestOutputHelper output) : CodeBlockTests(output, "{code-block}", "csharp") diff --git a/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs b/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs index 173d3ee3..977dfa02 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlineImageTest.cs @@ -9,7 +9,7 @@ namespace Elastic.Markdown.Tests.Inline; public class InlineImageTest(ITestOutputHelper output) : InlineTest(output, """ -![Elasticsearch](/_static/img/observability.png){w=350px align=center} +![Elasticsearch](/_static/img/observability.png) """ ) { @@ -20,6 +20,6 @@ public class InlineImageTest(ITestOutputHelper output) : InlineTest( public void GeneratesAttributesInHtml() => // language=html Html.Should().Contain( - """

Elasticsearch

""" + """

Elasticsearch

""" ); } diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index 876b21ab..2600991a 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -31,6 +31,25 @@ public override async Task InitializeAsync() } +public abstract class BlockTest(ITestOutputHelper output, [LanguageInjection("markdown")]string content) + : InlineTest(output, content) + where TDirective : Block +{ + protected TDirective? Block { get; private set; } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + Block = Document + .Descendants() + .FirstOrDefault(); + } + + [Fact] + public void BlockIsNotNull() => Block.Should().NotBeNull(); + +} + public abstract class InlineTest(ITestOutputHelper output, [LanguageInjection("markdown")]string content) : InlineTest(output, content) where TDirective : ContainerInline diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index 7ea4b3c1..7889c02e 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -1,6 +1,8 @@ // Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information + +using Elastic.Markdown.Myst.CallOutCode; using Elastic.Markdown.Myst.Substitution; using FluentAssertions; using Xunit.Abstractions; @@ -55,7 +57,7 @@ public void GeneratesAttributesInHtml() => .And.NotContain( """{{hello-world}}""" ) - .And.NotContain( // treated as attributes to the block + .And.Contain( // treated as attributes to the block """{substitution}""" ) .And.Contain( @@ -63,7 +65,7 @@ public void GeneratesAttributesInHtml() => ); } -public class SubstitutionInCodeBlockTest(ITestOutputHelper output) : LeafTest(output, +public class SubstitutionInCodeBlockTest(ITestOutputHelper output) : BlockTest(output, """ --- sub: @@ -81,7 +83,7 @@ cd elasticsearch-{{version}}/ <2> { [Fact] - public void GeneratesAttributesInHtml() => + public void ReplacesSubsInCode() => Html.Should().Contain("7.17.0"); } From c5105be85cedc9afc89b6aceda4ad23cb24348d0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 19 Dec 2024 18:27:42 +0100 Subject: [PATCH 2/3] stage --- .../Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs b/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs index ec123f22..cc148f95 100644 --- a/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs @@ -151,7 +151,11 @@ public override bool Close(BlockProcessor processor, Block block) if (codeBlock.CallOuts is not null) { foreach (var callout in codeBlock.CallOuts) - lines.Lines[callout.Line - 1].Slice.End = callout.SliceStart; + { + var line = lines.Lines[callout.Line - 1]; + line.Slice.End = line.Slice.Start + callout.SliceStart; + + } } var inlineAnnotations = codeBlock.CallOuts?.Where(c => c.InlineCodeAnnotation).Count() ?? 0; From d0a5982806ee7ad08aa0b4ef889f4788be05fe07 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 19 Dec 2024 19:05:35 +0100 Subject: [PATCH 3/3] Quick naming refactoring --- .../Myst/CodeBlocks/CallOut.cs | 16 +++++ .../Myst/CodeBlocks/CallOutParser.cs | 19 +++++ .../Myst/CodeBlocks/EnhancedCodeBlock.cs | 28 ++++++++ .../EnhancedCodeBlockHtmlRenderer.cs} | 56 +++++---------- .../EnhancedCodeBlockParser.cs} | 69 ++++--------------- .../EnhancedCodeMarkdownExtensions.cs | 33 +++++++++ .../Myst/Directives/DirectiveHtmlRenderer.cs | 4 +- src/Elastic.Markdown/Myst/MarkdownParser.cs | 4 +- .../CodeBlocks/CallOutTests.cs | 4 +- .../CodeBlocks/CodeTests.cs | 4 +- .../Inline/SubstitutionTest.cs | 4 +- 11 files changed, 137 insertions(+), 104 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/CodeBlocks/CallOut.cs create mode 100644 src/Elastic.Markdown/Myst/CodeBlocks/CallOutParser.cs create mode 100644 src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs rename src/Elastic.Markdown/Myst/{CallOutCode/CallOutCodeMarkdownExtension.cs => CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs} (63%) rename src/Elastic.Markdown/Myst/{CallOutCode/CallOutAwareFencedCodeBlock.cs => CodeBlocks/EnhancedCodeBlockParser.cs} (72%) create mode 100644 src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeMarkdownExtensions.cs diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/CallOut.cs b/src/Elastic.Markdown/Myst/CodeBlocks/CallOut.cs new file mode 100644 index 00000000..dcc841c3 --- /dev/null +++ b/src/Elastic.Markdown/Myst/CodeBlocks/CallOut.cs @@ -0,0 +1,16 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Markdown.Myst.CodeBlocks; + +public record CallOut +{ + public required int Index { get; init; } + public required string Text { get; init; } + public required bool InlineCodeAnnotation { get; init; } + + public required int SliceStart { get; init; } + + public required int Line { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/CallOutParser.cs b/src/Elastic.Markdown/Myst/CodeBlocks/CallOutParser.cs new file mode 100644 index 00000000..66b740a3 --- /dev/null +++ b/src/Elastic.Markdown/Myst/CodeBlocks/CallOutParser.cs @@ -0,0 +1,19 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.RegularExpressions; + +namespace Elastic.Markdown.Myst.CodeBlocks; + +public static partial class CallOutParser +{ + [GeneratedRegex(@"^.+\S+.*?\s<\d+>$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex CallOutNumber(); + + [GeneratedRegex(@"^.+\S+.*?\s(?:\/\/|#)\s[^""]+$", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex MathInlineAnnotation(); + + [GeneratedRegex(@"\{\{[^\r\n}]+?\}\}", RegexOptions.IgnoreCase, "en-US")] + public static partial Regex MatchSubstitutions(); +} diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs new file mode 100644 index 00000000..b46b50b4 --- /dev/null +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs @@ -0,0 +1,28 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Markdown.Myst.Directives; +using Markdig.Parsers; +using Markdig.Syntax; + +namespace Elastic.Markdown.Myst.CodeBlocks; + +public class EnhancedCodeBlock(BlockParser parser, ParserContext context) + : FencedCodeBlock(parser), IBlockExtension +{ + public BuildContext Build { get; } = context.Build; + + public IFileInfo CurrentFile { get; } = context.Path; + + public bool SkipValidation { get; } = context.SkipValidation; + + public int OpeningLength => Info?.Length ?? 0 + 3; + + public List? CallOuts { get; set; } + + public bool InlineAnnotations { get; set; } + + public string Language { get; set; } = "unknown"; +} diff --git a/src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs similarity index 63% rename from src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs rename to src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index c2db0038..c3b724c9 100644 --- a/src/Elastic.Markdown/Myst/CallOutCode/CallOutCodeMarkdownExtension.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -4,55 +4,37 @@ using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst.Directives; -using Markdig; -using Markdig.Parsers; +using Elastic.Markdown.Slices.Directives; using Markdig.Renderers; using Markdig.Renderers.Html; using Markdig.Syntax; +using RazorSlices; -namespace Elastic.Markdown.Myst.CallOutCode; +namespace Elastic.Markdown.Myst.CodeBlocks; -public static class CallOutCodeBuilderExtensions +public class EnhancedCodeBlockHtmlRenderer : HtmlObjectRenderer { - public static MarkdownPipelineBuilder UseCallOutAwareCodeBlocks(this MarkdownPipelineBuilder pipeline) - { - pipeline.Extensions.AddIfNotAlready(); - return pipeline; - } -} -/// -/// Extension to allow custom containers. -/// -/// -public class CallOutCodeMarkdownExtension : IMarkdownExtension -{ - public void Setup(MarkdownPipelineBuilder pipeline) + private static void RenderRazorSlice(RazorSlice slice, HtmlRenderer renderer, EnhancedCodeBlock block) { - pipeline.BlockParsers.Replace(new CallOutAwareFencedCodeBlockParser()); - } - - public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) - { - if (!renderer.ObjectRenderers.Contains()) - { - // Must be inserted before CodeBlockRenderer - renderer.ObjectRenderers.InsertBefore(new DirectiveHtmlRenderer()); - } - - renderer.ObjectRenderers.Replace(new CallOutCodeRenderer()); + var html = slice.RenderAsync().GetAwaiter().GetResult(); + var blocks = html.Split("[CONTENT]", 2, StringSplitOptions.RemoveEmptyEntries); + renderer.Write(blocks[0]); + renderer.WriteLeafRawLines(block, true, false, false); + renderer.Write(blocks[1]); } -} - -public class CallOutCodeRenderer : HtmlObjectRenderer -{ - protected override void Write(HtmlRenderer renderer, CodeBlockWithCallOuts block) + protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) { var callOuts = block.CallOuts ?? []; - renderer.WriteLine("
");
-		renderer.WriteLeafRawLines(block, true, false, false);
-		renderer.WriteLine("
"); + var slice = Code.Create(new CodeViewModel + { + CrossReferenceName = string.Empty,// block.CrossReferenceName, + Language = block.Language, + Caption = string.Empty + }); + + RenderRazorSlice(slice, renderer, block); if (!block.InlineAnnotations && callOuts.Count > 0) { diff --git a/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs similarity index 72% rename from src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs rename to src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs index cc148f95..ae774735 100644 --- a/src/Elastic.Markdown/Myst/CallOutCode/CallOutAwareFencedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs @@ -2,70 +2,34 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Collections.Frozen; -using System.IO.Abstractions; using System.Text.RegularExpressions; using Elastic.Markdown.Diagnostics; -using Elastic.Markdown.Myst.Directives; using Markdig.Helpers; using Markdig.Parsers; using Markdig.Syntax; -namespace Elastic.Markdown.Myst.CallOutCode; +namespace Elastic.Markdown.Myst.CodeBlocks; -public record CallOut -{ - public required int Index { get; init; } - public required string Text { get; init; } - public required bool InlineCodeAnnotation { get; init; } - - public required int SliceStart { get; init; } - - public required int Line { get; init; } -} - -public class CodeBlockWithCallOuts(BlockParser parser, ParserContext context) - : FencedCodeBlock(parser), IBlockExtension -{ - public BuildContext Build { get; } = context.Build; - - public IFileInfo CurrentFile { get; } = context.Path; - - public bool SkipValidation { get; } = context.SkipValidation; - - public int OpeningLength => Info?.Length ?? 0 + 3; - - public List? CallOuts { get; set; } - - public bool InlineAnnotations { get; set; } - - public string Language { get; set; } = "unknown"; -} - -/// -/// Parser for a . -/// -/// -public class CallOutAwareFencedCodeBlockParser : FencedBlockParserBase +public class EnhancedCodeBlockParser : FencedBlockParserBase { private const string DefaultInfoPrefix = "language-"; /// /// Initializes a new instance of the class. /// - public CallOutAwareFencedCodeBlockParser() + public EnhancedCodeBlockParser() { OpeningCharacters = ['`']; InfoPrefix = DefaultInfoPrefix; InfoParser = RoundtripInfoParser; } - protected override CodeBlockWithCallOuts CreateFencedBlock(BlockProcessor processor) + protected override EnhancedCodeBlock CreateFencedBlock(BlockProcessor processor) { if (processor.Context is not ParserContext context) throw new Exception("Expected parser context to be of type ParserContext"); - var codeBlock = new CodeBlockWithCallOuts(this, context) { IndentCount = processor.Indent }; + var codeBlock = new EnhancedCodeBlock(this, context) { IndentCount = processor.Indent }; if (processor.TrackTrivia) { @@ -85,7 +49,7 @@ public override BlockState TryContinue(BlockProcessor processor, Block block) var result = base.TryContinue(processor, block); if (result == BlockState.Continue && !processor.TrackTrivia) { - var fence = (CodeBlockWithCallOuts)block; + var fence = (EnhancedCodeBlock)block; // Remove any indent spaces var c = processor.CurrentChar; var indentCount = fence.IndentCount; @@ -101,7 +65,7 @@ public override BlockState TryContinue(BlockProcessor processor, Block block) public override bool Close(BlockProcessor processor, Block block) { - if (block is not CodeBlockWithCallOuts codeBlock) + if (block is not EnhancedCodeBlock codeBlock) return base.Close(processor, block); if (processor.Context is not ParserContext context) @@ -111,7 +75,7 @@ public override bool Close(BlockProcessor processor, Block block) (codeBlock.Info?.IndexOf("{") ?? -1) != -1 ? codeBlock.Arguments : codeBlock.Info - ) ?? "unknown"; + ) ?? "unknown"; var lines = codeBlock.Lines; var callOutIndex = 0; @@ -153,7 +117,10 @@ public override bool Close(BlockProcessor processor, Block block) foreach (var callout in codeBlock.CallOuts) { var line = lines.Lines[callout.Line - 1]; - line.Slice.End = line.Slice.Start + callout.SliceStart; + + var newSpan = line.Slice.AsSpan()[..callout.SliceStart]; + var s = new StringSlice(newSpan.ToString()); + lines.Lines[callout.Line -1 ] = new StringLine(ref s); } } @@ -234,15 +201,3 @@ private static bool ReplaceSubstitutions(ParserContext context, ReadOnlySpan$", RegexOptions.IgnoreCase, "en-US")] - public static partial Regex CallOutNumber(); - - [GeneratedRegex(@"^.+\S+.*?\s(?:\/\/|#)\s[^""]+$", RegexOptions.IgnoreCase, "en-US")] - public static partial Regex MathInlineAnnotation(); - - [GeneratedRegex(@"\{\{[^\r\n}]+?\}\}", RegexOptions.IgnoreCase, "en-US")] - public static partial Regex MatchSubstitutions(); -} diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeMarkdownExtensions.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeMarkdownExtensions.cs new file mode 100644 index 00000000..9c6f0037 --- /dev/null +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeMarkdownExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Myst.Directives; +using Markdig; +using Markdig.Parsers; +using Markdig.Renderers; +using Markdig.Renderers.Html; + +namespace Elastic.Markdown.Myst.CodeBlocks; + +public static class EnhancedCodeBuilderExtensions +{ + public static MarkdownPipelineBuilder UseEnhancedCodeBlocks(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +/// +/// Extension to allow custom containers. +/// +/// +public class EnhancedCodeBlockExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) => + pipeline.BlockParsers.Replace(new EnhancedCodeBlockParser()); + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => + renderer.ObjectRenderers.Replace(new EnhancedCodeBlockHtmlRenderer()); +} diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index c27ed170..4e68d17b 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -6,7 +6,7 @@ // See the license.txt file in the project root for more information. using Elastic.Markdown.Diagnostics; -using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.FrontMatter; using Elastic.Markdown.Myst.Settings; using Elastic.Markdown.Myst.Substitution; @@ -162,7 +162,7 @@ private void WriteDropdown(HtmlRenderer renderer, DropdownBlock block) RenderRazorSlice(slice, renderer, block); } - private void WriteCode(HtmlRenderer renderer, CodeBlockWithCallOuts block) + private void WriteCode(HtmlRenderer renderer, EnhancedCodeBlock block) { var slice = Code.Create(new CodeViewModel { diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 0f36c827..aed329a9 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -5,7 +5,7 @@ using System.IO.Abstractions; using Cysharp.IO; using Elastic.Markdown.IO; -using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.Comments; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.FrontMatter; @@ -49,7 +49,7 @@ public class MarkdownParser( .UseGridTables() .UsePipeTables() .UseDirectives() - .UseCallOutAwareCodeBlocks() + .UseEnhancedCodeBlocks() .DisableHtml() .Build(); diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs index b67e9c17..550aed32 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CallOutTests.cs @@ -2,7 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; using FluentAssertions; using JetBrains.Annotations; @@ -16,7 +16,7 @@ public abstract class CodeBlockCallOutTests( [LanguageInjection("csharp")] string code, [LanguageInjection("markdown")] string? markdown = null ) - : BlockTest(output, + : BlockTest(output, $$""" ```{{language}} {{code}} diff --git a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs index e0920ce9..f5f07079 100644 --- a/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs +++ b/tests/Elastic.Markdown.Tests/CodeBlocks/CodeTests.cs @@ -2,7 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Tests.Inline; using FluentAssertions; using Xunit.Abstractions; @@ -10,7 +10,7 @@ namespace Elastic.Markdown.Tests.CodeBlocks; public abstract class CodeBlockTests(ITestOutputHelper output, string directive, string? language = null) - : BlockTest(output, + : BlockTest(output, $$""" ```{{directive}} {{language}} var x = 1; diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index 7889c02e..e82debbb 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -2,7 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using Elastic.Markdown.Myst.CallOutCode; +using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.Substitution; using FluentAssertions; using Xunit.Abstractions; @@ -65,7 +65,7 @@ public void GeneratesAttributesInHtml() => ); } -public class SubstitutionInCodeBlockTest(ITestOutputHelper output) : BlockTest(output, +public class SubstitutionInCodeBlockTest(ITestOutputHelper output) : BlockTest(output, """ --- sub: