xoofx / markdig

A fast, powerful, CommonMark compliant, extensible Markdown processor for .NET
BSD 2-Clause "Simplified" License
4.41k stars 456 forks source link

Is there a "current execution context" available from extensions? #821

Closed deanebarker closed 1 month ago

deanebarker commented 1 month ago

Does Markdig have a concept of a "current execution context," meaning a common scope available to extensions in which you can store data that can be accessed for that transformation only? (Meaning, the boundaries of a single call to Markdown.ToHtml)

I wrote an extension which allows for the identification of "tokens" (I call them "AtRefs", because of the syntax). They sort of allow the calling of very simple (one parameter) "functions."

For example:

This was defined in a previous article: @article:/path/to/article

In this case, the AtRef is "article" and the parameter is "/path/to/article". It will search the database for that path, and render a hyperlink using the title of the article it finds. I statically register a function on a static class in my extension to handle "article," because this a global scenario that I use all over my site -- every execution of Markdown.ToHtml should have access to this. My extension rendered can get access to that static class and method, so we're good.

But sometimes I want an AtRef handler to only work for a specific execution. This is a handler that is specific to a single block of Markdown -- a "macro," if you will, for that scenario. So, what I need is some data construct accessible to my extension that lives and dies in the context of the single call to Markdown.ToHtml.

This block of Markdown -- this block _only_ -- will do something special with @foo:bar and @foo:baz and @foo:whatever.

Inside my extension renderer (extending from HtmlObjectRenderer<AtRef>), I cannot find anything exposed on which I could bring a custom function in. All the Write method has available is:

I thought about somehow passing it in on that last one -- adding it to the AtRef when I parse it. But, that would require the macro to somehow be defined inside the parsed Markdown, which has the same problem: the Match method of InlineParser doesn't seem to have an execution context either.

This is kind of what I want:

var markdownContext = new MarkdownContext() {

  ["AtRef-Foo"] = (a) => { return "Do something..." }

};
var result = Markdown.ToHtml(document, markdownContext);

// markdownContext means nothing, at this point

Then, inside any extension , the MarkdownContext would be available for... whatever. It would only live in the context of that execution.

Either that, or I'd like to be able to extend MarkdownDocument itself:

public class DeanesMarkdownDocument : MarkdownDocument
{
   public Dictionary<string, Func<AtRef, string>> AtRefRenderers = new();
}

var document = Markdown.Parse<DeanesMarkdownDocument>(input, pipeline)
document.AtRefRenders["AtRef-Foo"] =  (a) => { return "Do something..." }

var result = document.ToHtml();

(Of course, right now, extensions don't seem to have access to the larger document, so I'm not sure how this would help me. But it seems somehow... "correct"?)

Does some solution to my problem already exist? Am I overthinking this?

xoofx commented 1 month ago

There is indeed no current execution context available across the board.

There is currently one MarkdownParserContext available from parsers.

Otherwise for renderers, each renderer can have its own data/properties (e.g could contain mapping for your functions).

A workaround would be to use a ThreadStatic in your case to unblock if using properties on a custom renderer is not a good solution.

deanebarker commented 1 month ago

each renderer can have its own data/properties

I like this, but is a renderer instance specific to a single execution? And, if so, how do I get access to it? Where is it "born" in relation to the execution in which it's used?

I traced it back to a collection called ObjectRenderers on RendererBase, but I don't understand how that gets populated in relation to a single execution.

(Note: I also like the ThreadStatic option, but let's pull on the above thread (ha!) first...)

deanebarker commented 1 month ago

Note that I did get it working using the ThreadStatic method (see image), but it feels... wrong.

Also, ASP.NET will thread switch in the same request, but I don't know when, so it could conceivably switch between me defining the macro and MarkDig rendering. That's probably unlikely, but it could happen.

The "macros" are defined from sections appended to the same document the Markdown appears in (disregard the format; it's a proprietary thing). So @deane:whatever in the document finds the _macro:deane section, parses the content as a Liquid template, then passes whatever to it, and renders it.

Again, this works and proves the theory, but I'd love something more elegant.

image

xoofx commented 1 month ago

I like this, but is a renderer instance specific to a single execution? And, if so, how do I get access to it? Where is it "born" in relation to the execution in which it's used?

Renderers are configured via pipeline and pipeline builders. There are plenty of examples in the code base and provide custom rendering. For example https://github.com/xoofx/markdig/tree/master/src/Markdig/Extensions/Alerts has a renderer that can define an action per alert kind.

deanebarker commented 1 month ago

Ah, okay. I got it working, but I just want to confirm that I'm doing this right.

I created a new "Use" method, so in my pipeline builder, I do this:

var macros = GetMyMacros();
var builder = new MarkdownPipelineBuilder()
    .UseAtRefs(macros)

I created a local field in AtRefsExtension to hold them. So, at this point, the extension itself is holding on to them.

Then, in both the Setup methods, I pass them into the AtRefsInlineParser and AtRefsRenderer instances that those two methods generate and return.

Is that basically right?

xoofx commented 1 month ago

Is that basically right?

Yep 😊