How to handle partial markdown syntax when rendering streamed output #731

Open
opened 2026-01-29 14:44:07 +00:00 by claunia · 12 comments
Owner

Originally created by @JamesNK on GitHub (Mar 15, 2025).

It's common to return markdown in AI responses. With streaming, markdown returned will have partially complete syntax as the response is returned.

For example, the start sequence for an inline or fenced code block is returned in the stream response, but end sequence doesn't arrive for another second. Inbetween that time the start sequence is rendered as text to the HTML output, which doesn't look good.

This is some code: `I'm not complete

Is there a way to exclude partially received syntax from rendered HTML? e.g. if a backtick is received to start an inline code span, the backtick and content after it isn't rendered until the end backtick is received:

This is some code:

Alternatively, a way to implicitly complete the begun start sequence, e.g.

This is some code: I'm not complete

Originally created by @JamesNK on GitHub (Mar 15, 2025). It's common to return markdown in AI responses. With streaming, markdown returned will have partially complete syntax as the response is returned. For example, the start sequence for an inline or fenced code block is returned in the stream response, but end sequence doesn't arrive for another second. Inbetween that time the start sequence is rendered as text to the HTML output, which doesn't look good. > This is some code: `I'm not complete Is there a way to exclude partially received syntax from rendered HTML? e.g. if a backtick is received to start an inline code span, the backtick and content after it isn't rendered until the end backtick is received: > This is some code: Alternatively, a way to implicitly complete the begun start sequence, e.g. > This is some code: `I'm not complete`
claunia added the enhancementPR Welcome! labels 2026-01-29 14:44:07 +00:00
Author
Owner

@xoofx commented on GitHub (Mar 15, 2025):

Is there a way to exclude partially received syntax from rendered HTML? e.g. if a backtick is received to start an inline code span, the backtick and content after it isn't rendered until the end backtick is received:
Alternatively, a way to implicitly complete the begun start sequence, e.g.

Possibly, but it will be non-trivial. Several cases to cover (inline code, fenced code block...etc.). It would have to be configurable...etc. PR welcome by making it optional by default.

@xoofx commented on GitHub (Mar 15, 2025): > Is there a way to exclude partially received syntax from rendered HTML? e.g. if a backtick is received to start an inline code span, the backtick and content after it isn't rendered until the end backtick is received: > Alternatively, a way to implicitly complete the begun start sequence, e.g. Possibly, but it will be non-trivial. Several cases to cover (inline code, fenced code block...etc.). It would have to be configurable...etc. PR welcome by making it optional by default.
Author
Owner

@MihaZupan commented on GitHub (Mar 15, 2025):

It also very much depends on how perfect you want such logic to be.
Something like the following code will handle most? cases you might care about:

Text with `code     => <p>Text with <code>code</code></p>
*Italic             => <p><em>Italic</em></p>
**Bold              => <p><strong>Bold</strong></p>
***Both             => <p><em><strong>Both</strong></em></p>
___Both             => <p><em><strong>Both</strong></em></p>
**_italic_ and bold => <p><strong><em>italic</em> and bold</strong></p>

Unclosed fenced code blocks already work without changes as they're implicitly closed.
```c#
Console.WriteLine("foo");
var document = Markdown.Parse(source, pipeline);

if (notFinal)
{
    CompletePartialElements(document);
}

var html = document.ToHtml(pipeline);

CompletePartialElements: https://gist.github.com/MihaZupan/03eecb8c66459b87a06b82a719bd0efc

Trimming out such elements instead of manufacturing the closed variants is even simpler.

@MihaZupan commented on GitHub (Mar 15, 2025): It also very much depends on how perfect you want such logic to be. Something like the following code will handle most? cases you might care about: ```` Text with `code => <p>Text with <code>code</code></p> *Italic => <p><em>Italic</em></p> **Bold => <p><strong>Bold</strong></p> ***Both => <p><em><strong>Both</strong></em></p> ___Both => <p><em><strong>Both</strong></em></p> **_italic_ and bold => <p><strong><em>italic</em> and bold</strong></p> Unclosed fenced code blocks already work without changes as they're implicitly closed. ```c# Console.WriteLine("foo"); ```` ```c# var document = Markdown.Parse(source, pipeline); if (notFinal) { CompletePartialElements(document); } var html = document.ToHtml(pipeline); ``` `CompletePartialElements`: https://gist.github.com/MihaZupan/03eecb8c66459b87a06b82a719bd0efc Trimming out such elements instead of manufacturing the closed variants is even simpler.
Author
Owner

@JamesNK commented on GitHub (Mar 16, 2025):

That's great. It's basically what I was expecting I'd have to do.

I've noticed some things it misses: If the partial content is in a list then it isn't processed. Fixed by looking in block content for the last inline:

    private static Inline? GetLastInline(Block? lastChild)
    {
        while (lastChild is ContainerBlock containerBlock)
        {
            lastChild = containerBlock.LastChild;
        }

        if (lastChild is LeafBlock leafBlock)
        {
            return leafBlock.Inline?.LastChild;
        }

        return null;
    }

The other thing that isn't fixed, which I finally figure out what is going on, is a sublist causing a heading to be temporarly added during rendering.

Given this markdown:

1. Text
   -

Text is temporarily a <h2> header because the dash below the text is the alternative syntax for a header. It disappears as soon as the sublist item has text:

1. Text
   - Subtext

I fixed it with this hack:

// The start of a sub-list immediately below parent text can cause the markdown engine to temporarily add a heading.
// This is because is dash below text is converted into an <h2>. Fix this issue by trimming the trailing dash until
// it has additional content in incomplete documents.
if (markdown.EndsWith("  -"))
{
    markdown = markdown[..^3];
}
else if (markdown.EndsWith("  - "))
{
    markdown = markdown[..^4];
}
@JamesNK commented on GitHub (Mar 16, 2025): That's great. It's basically what I was expecting I'd have to do. I've noticed some things it misses: If the partial content is in a list then it isn't processed. Fixed by looking in block content for the last inline: ```cs private static Inline? GetLastInline(Block? lastChild) { while (lastChild is ContainerBlock containerBlock) { lastChild = containerBlock.LastChild; } if (lastChild is LeafBlock leafBlock) { return leafBlock.Inline?.LastChild; } return null; } ``` The other thing that isn't fixed, which I finally figure out what is going on, is a sublist causing a heading to be temporarly added during rendering. Given this markdown: ```markdown 1. Text - ``` `Text` is temporarily a `<h2>` header because the dash below the text is the alternative syntax for a header. It disappears as soon as the sublist item has text: ```markdown 1. Text - Subtext ``` I fixed it with this hack: ```cs // The start of a sub-list immediately below parent text can cause the markdown engine to temporarily add a heading. // This is because is dash below text is converted into an <h2>. Fix this issue by trimming the trailing dash until // it has additional content in incomplete documents. if (markdown.EndsWith(" -")) { markdown = markdown[..^3]; } else if (markdown.EndsWith(" - ")) { markdown = markdown[..^4]; } ```
Author
Owner

@MihaZupan commented on GitHub (Mar 16, 2025):

I fixed it with this hack

Try this:

// "Level: 2" means '-' was used.
if (lastChild is HeadingBlock { IsSetext: true, Level: 2, HeaderCharCount: 1 } setext)
{
    var paragraph = new ParagraphBlock();
    paragraph.Inline = new ContainerInline();
    setext.Inline?.EmbraceChildrenBy(paragraph.Inline);

    var parent = setext.Parent!;
    parent[parent.IndexOf(setext)] = paragraph;
}
@MihaZupan commented on GitHub (Mar 16, 2025): > I fixed it with this hack Try this: ```c# // "Level: 2" means '-' was used. if (lastChild is HeadingBlock { IsSetext: true, Level: 2, HeaderCharCount: 1 } setext) { var paragraph = new ParagraphBlock(); paragraph.Inline = new ContainerInline(); setext.Inline?.EmbraceChildrenBy(paragraph.Inline); var parent = setext.Parent!; parent[parent.IndexOf(setext)] = paragraph; } ```
Author
Owner

@JamesNK commented on GitHub (Mar 16, 2025):

That's much cleaner than trimming markdown text. Thanks.

I fixed another issue I saw, which was the start of bold/italics but no following text. For example, the following markdown would contain the double stars:

1. **

Updated gist that includes various improvements in your method: https://gist.github.com/JamesNK/4d4fbef86cdde2ab54df8bae8421bed6

There is one remaining issue I've seen which is incomplete links. For example,

For more information see [documentation](http://

The incomplete link markdown is visible until the link is complete:

For more information see [documentation](http://www.docs.com).
@JamesNK commented on GitHub (Mar 16, 2025): That's much cleaner than trimming markdown text. Thanks. I fixed another issue I saw, which was the start of bold/italics but no following text. For example, the following markdown would contain the double stars: ``` 1. ** ``` Updated gist that includes various improvements in your method: https://gist.github.com/JamesNK/4d4fbef86cdde2ab54df8bae8421bed6 There is one remaining issue I've seen which is incomplete links. For example, ``` For more information see [documentation](http:// ``` The incomplete link markdown is visible until the link is complete: ``` For more information see [documentation](http://www.docs.com). ```
Author
Owner

@JamesNK commented on GitHub (Mar 17, 2025):

Incomplete links are handled. gist updated: https://gist.github.com/JamesNK/4d4fbef86cdde2ab54df8bae8421bed6.

I think this is good enough.

@MihaZupan If you have time, take a look and see whether there are any mistakes, or improvements to the method.

@JamesNK commented on GitHub (Mar 17, 2025): Incomplete links are handled. gist updated: https://gist.github.com/JamesNK/4d4fbef86cdde2ab54df8bae8421bed6. I think this is good enough. @MihaZupan If you have time, take a look and see whether there are any mistakes, or improvements to the method.
Author
Owner

@MihaZupan commented on GitHub (Mar 17, 2025):

Looks good.

Maybe

-else if (content is "[")
+else if (content is "[" or "![")

to handle ![image](http:// as well.


Another example to consider could be

Some text that references [Foo].

[Foo]: 

where you can't really know if the first [Foo] is just text or a reference until you have the whole source.
Right now you're stripping out the [Foo]: , but the unpaired reference remains as [Foo] in the text.

For example you could choose to strip out the [] brackets:

CompletePartialElements(document);
FixMissingLinkReferences(document);

static void FixMissingLinkReferences(MarkdownDocument document)
{
    foreach (LiteralInline literal in document.Descendants<LiteralInline>())
    {
        // Some text that references [Foo].
        // We don't know if Foo is actually a reference until we receive the whole document.
        // For now, let's remove the brackets.
        if (literal.Content is { Length: 1, CurrentChar: '[' } &&
            literal.NextSibling is LiteralInline nextLiteral)
        {
            ref StringSlice content = ref nextLiteral.Content;

            // Does the next literal contain a closing bracket?
            int offsetOfClosingBracket = content.AsSpan().IndexOfAny('[', ']', '\\');
            if (offsetOfClosingBracket >= 0 && content.PeekChar(offsetOfClosingBracket) == ']')
            {
                // Remove the opening bracket literal, and the closing bracket from the next literal content.
                literal.Remove();
                content = new StringSlice(content.ToString().Remove(offsetOfClosingBracket, 1));
            }
        }
    }
}

Or to replace it with the underlined label:

if (offsetOfClosingBracket >= 0 && content.PeekChar(offsetOfClosingBracket) == ']')
{
    // Remove the opening bracket literal.
    literal.Remove();

    // Replace the Foo] with ++Foo++ (inserted emphasis) that renders as <ins>Foo</ins> (underlined).
    // Assumes that .UseEmphasisExtras is used (included in UseAdvancedExtensions).
    var label = new EmphasisInline();
    label.DelimiterChar = '+';
    label.DelimiterCount = 2;
    label.AppendChild(new LiteralInline(new StringSlice(content.Text, content.Start, content.Start + offsetOfClosingBracket - 1)));

    content.Start += offsetOfClosingBracket + 1;
    nextLiteral.InsertBefore(label);
}

(I don't know if models spit out markdown like that)

@MihaZupan commented on GitHub (Mar 17, 2025): Looks good. Maybe ```diff -else if (content is "[") +else if (content is "[" or "![") ``` to handle `![image](http://` as well. --- Another example to consider could be ``` Some text that references [Foo]. [Foo]: ``` where you can't really know if the first `[Foo]` is just text or a reference until you have the whole source. Right now you're stripping out the `[Foo]: `, but the unpaired reference remains as `[Foo]` in the text. For example you could choose to strip out the `[]` brackets: ```c# CompletePartialElements(document); FixMissingLinkReferences(document); static void FixMissingLinkReferences(MarkdownDocument document) { foreach (LiteralInline literal in document.Descendants<LiteralInline>()) { // Some text that references [Foo]. // We don't know if Foo is actually a reference until we receive the whole document. // For now, let's remove the brackets. if (literal.Content is { Length: 1, CurrentChar: '[' } && literal.NextSibling is LiteralInline nextLiteral) { ref StringSlice content = ref nextLiteral.Content; // Does the next literal contain a closing bracket? int offsetOfClosingBracket = content.AsSpan().IndexOfAny('[', ']', '\\'); if (offsetOfClosingBracket >= 0 && content.PeekChar(offsetOfClosingBracket) == ']') { // Remove the opening bracket literal, and the closing bracket from the next literal content. literal.Remove(); content = new StringSlice(content.ToString().Remove(offsetOfClosingBracket, 1)); } } } } ``` Or to replace it with the underlined label: ```c# if (offsetOfClosingBracket >= 0 && content.PeekChar(offsetOfClosingBracket) == ']') { // Remove the opening bracket literal. literal.Remove(); // Replace the Foo] with ++Foo++ (inserted emphasis) that renders as <ins>Foo</ins> (underlined). // Assumes that .UseEmphasisExtras is used (included in UseAdvancedExtensions). var label = new EmphasisInline(); label.DelimiterChar = '+'; label.DelimiterCount = 2; label.AppendChild(new LiteralInline(new StringSlice(content.Text, content.Start, content.Start + offsetOfClosingBracket - 1))); content.Start += offsetOfClosingBracket + 1; nextLiteral.InsertBefore(label); } ``` (I don't know if models spit out markdown like that)
Author
Owner

@MihaZupan commented on GitHub (Mar 17, 2025):

Oh also

-if (literal.Content.AsSpan().Count('`') == 1)
+if (literal.Content.AsSpan().Count('`') == 1 && !literal.IsFirstCharacterEscaped)
@MihaZupan commented on GitHub (Mar 17, 2025): Oh also ```diff -if (literal.Content.AsSpan().Count('`') == 1) +if (literal.Content.AsSpan().Count('`') == 1 && !literal.IsFirstCharacterEscaped) ```
Author
Owner

@xoofx commented on GitHub (Mar 18, 2025):

Feel free to make a PR @JamesNK if you are satisfied with the code. Keeping it as an extension method on MarkdownDocument might be the less disruptive and might be useful for others.

@xoofx commented on GitHub (Mar 18, 2025): Feel free to make a PR @JamesNK if you are satisfied with the code. Keeping it as an extension method on `MarkdownDocument` might be the less disruptive and might be useful for others.
Author
Owner

@JamesNK commented on GitHub (Mar 18, 2025):

Where would the method go?
What name do you want?
Do you have other MarkdownDocument extension methods it would sit beside, or would it be on its own? It feels kind of experimental.

I'm happy having the code sit inside my app but I can see the value in having it built in so other folks doing AI + streaming + markdown could use this..

@JamesNK commented on GitHub (Mar 18, 2025): Where would the method go? What name do you want? Do you have other MarkdownDocument extension methods it would sit beside, or would it be on its own? It feels kind of experimental. I'm happy having the code sit inside my app but I can see the value in having it built in so other folks doing AI + streaming + markdown could use this..
Author
Owner

@xoofx commented on GitHub (Mar 18, 2025):

Where would the method go?

You could add it to MarkdownObjectExtensions, apply it maybe to ContainerBlock on the signature (doesn't have to be strictly a MarkdownDocument)

What name do you want?

Maybe static void CompleteOpenElements(this ContainerBlock container);

Do you have other MarkdownDocument extension methods it would sit beside, or would it be on its own? It feels kind of experimental.

It is ok. There are other parts of Markdig that are not fully finished - even API wise.

@xoofx commented on GitHub (Mar 18, 2025): > Where would the method go? You could add it to [MarkdownObjectExtensions](https://github.com/xoofx/markdig/blob/master/src/Markdig/Syntax/MarkdownObjectExtensions.cs), apply it maybe to `ContainerBlock` on the signature (doesn't have to be strictly a `MarkdownDocument`) > What name do you want? Maybe `static void CompleteOpenElements(this ContainerBlock container);` > Do you have other MarkdownDocument extension methods it would sit beside, or would it be on its own? It feels kind of experimental. It is ok. There are other parts of Markdig that are not fully finished - even API wise.
Author
Owner

@aropb commented on GitHub (Jul 8, 2025):

If I don't want to re-process the entire text when I receive each part of it, can I do this?

@aropb commented on GitHub (Jul 8, 2025): If I don't want to re-process the entire text when I receive each part of it, can I do this?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/markdig#731