Sanity: Serializing custom annotations in .NET

Knut from Sanity has a guide on how to create internal and external links in Sanity Studio. He also shows how you can render these links in your front end. Unfortunately, he does not show how you can do it using .NET. I have looked for a solution to do this using Sanity LINQ, and I found two. Sort of.

Unlike custom objects added to the Portable Editor, the annotations live inside the block type. This makes it harder to add a new serializer per new annotation.

Using Sanity LINQ we can create our own serializers. By inspecting the page, we find the annotations under the type blocks and span. Each span might have a mark, with an id included. This id references a key in the markDefs array, which contains additional information about the annotation.

The NuGet package for Sanity LINQ doesn't have a way to serialize annotations inside the block type yet, without implementing a new block serializer. I was digging around the commit history and found a way to insert our annotation serializers. But this was not published as a NuGet yet. So now we have two possible solutions to serialize our annotations, although they are not ideal. We can either implement a new block serializer or build a local NuGet package from the repository.

Building a local NuGet package with the newest changes

By cloning the repository and building a NuGet package, I get to override the TrySerializeMarkDef method by creating a serializer class that inherits from SanityHtmlSerializers, as described in this commit message. I can now create an instance of my serializer class, and override the default block serializer in the HTML builder.


public class CustomSanitySerializer : SanityHtmlSerializers
{
    protected override bool TrySerializeMarkDef(JToken markDef, object context, ref StringBuilder start, ref StringBuilder end)
    {
        return base.TrySerializeMarkDef(markDef, context, ref start, ref end);
    }
}

public class SanityService
{
    private SanityOptions _sanityOptions;
    public SanityDataContext SanityDataContext { get; set; }
    public SanityService()
    {
        _sanityOptions = new SanityOptions
        {
            ProjectId = "<projectId>",
            Dataset = "<dataset>",
            Token = "<token>"
        };
        var customSerializer = new CustomSanitySerializer();

        SanityDataContext = new SanityDataContext(_sanityOptions);
        SanityDataContext.AddHtmlSerializer("block", customSerializer.SerializeDefaultBlockAsync);
}

Now we can implement our logic inside the TrySerializeMarkDef method. In the snippet below I have created a simple implementation to build an HTML string for the external URL. We can append our HTML to the string builders start and end. Sanity LINQ will take care of the rest by adding the normal text in the middle.

protected override bool TrySerializeMarkDef(JToken markDef, object context, ref StringBuilder start, ref StringBuilder end)
{
    var type = markDef["_type"]?.ToString();
    if (type == "externalLink")
    {
        var href = markDef["href"]?.ToString();
        var blank = (bool?)markDef["blank"] ?? false;
        var target = blank ? "target='_blank' rel='noopener'" : ""; 
        
        start.Append($"<a {target} href='{href}'>");
        end.Append("</a>");
        
        return true;
    }
}

Now we can do the same to the internal link annotation! First, to get the href to the internal page we need to dereference the internal link. The easiest way to do this is in our GROQ query as shown below. This is also explained in Knut's post that I linked to earlier in this post.

*[_type == "post"]{
  ...,
  body[]{
    ...,
    markDefs[]{
      ...,
      _type == "internalLink" => {
        "slug": @.reference->slug.current
      }
    }
  }
}

Now we can add this to our method as well.

if (type == "internalLink")
{
    var slug = markDef["slug"]?.ToString();
    start.Append($"<a href='posts/{slug}'>");
    end.Append("</a>");
}

And now we got our links rendering correctly!

Implementing a new block serializer

Rebuilding the block serializer from scratch would be a lot of work. Especially when Sanity LINQ already has one working. So, by digging around the source code, I found the default implementation of the block type serializer and copy-pasted the code. I can now customize the code with my logic where I need it. The code snippet below shows a part of the default serializer, where I have added a check for externalLink type.

if (jToken2 != null)
{
// check for custom annotations here, and implement your own logic
    if (jToken2["_type"]?.ToString() == "link")
    {
        stringBuilder6.Append("<a target=\"_blank\" href=\"" + jToken2["href"]?.ToString() + "\">");
        stringBuilder7.Append("</a>");
    }
    else if (jToken2["_type"]?.ToString() == "internalLink")
    {
        stringBuilder6.Append("<a href=\"" + jToken2["href"]?.ToString() + "\">");
        stringBuilder7.Append("</a>");
    }
    //I added a typecheck for externalLink here
    else if (jToken2["_type"]?.ToString() == "externalLink")
    {
    	// build HTML string for external Link
    }
}
A part of the serializer code, where we can serialize our custom annotations

To get the new block serializer to work, we have to add it to the HTML builder.

public SanityService()
{
    _sanityOptions = new SanityOptions
    {
        ProjectId = "<projectId>",
        Dataset = "<dataset>",
        Token = "<token>"
    };

    SanityDataContext = new SanityDataContext(_sanityOptions);
	//Add the new block serializer
    SanityDataContext.AddHtmlSerializer("block", SerializeDefaultBlockAsync);
}

And that's it! I have inserted the code for the default serializer below.

public Task<string> SerializeDefaultBlockAsync(JToken input, SanityOptions sanity)
{
    StringBuilder stringBuilder = new StringBuilder();
    StringBuilder stringBuilder2 = new StringBuilder();
    StringBuilder stringBuilder3 = new StringBuilder();
    StringBuilder stringBuilder4 = new StringBuilder();
    StringBuilder stringBuilder5 = new StringBuilder();
    new StringBuilder();
    string text = "";
    text = ((!(input["style"]?.ToString() == "normal")) ? (input["style"]?.ToString() ?? "span") : "p");
    JToken jToken = input["markDefs"];
    input["listItem"]?.ToString();
    if (input["listItem"]?.ToString() == "bullet")
    {
        stringBuilder5.Append("</li>");
        for (int i = 0; i < ((int?)input["level"]).GetValueOrDefault(0) - 1; i++)
        {
            stringBuilder4.Append("<ul>");
            stringBuilder5.Append("</ul>");
        }

        stringBuilder4.Append("<li>");
        if (input["firstItem"] != null && (bool)input["firstItem"])
        {
            stringBuilder2.Append("<ul>");
        }

        if (input["lastItem"] != null && (bool)input["lastItem"])
        {
            stringBuilder3.Append("</ul>");
        }
    }

    if (input["listItem"]?.ToString() == "number")
    {
        stringBuilder5.Append("</li>");
        for (int j = 0; j < ((int?)input["level"]).GetValueOrDefault(0) - 1; j++)
        {
            stringBuilder4.Append("<ol>");
            stringBuilder5.Append("</ol>");
        }

        stringBuilder4.Append("<li>");
        if ((bool?)input["firstItem"] == true)
        {
            stringBuilder2.Append("<ol>");
        }

        if ((bool?)input["lastItem"] == true)
        {
            stringBuilder3.Append("</ol>");
        }
    }

    foreach (JToken item in (IEnumerable<JToken>)input["children"])
    {
        StringBuilder stringBuilder6 = new StringBuilder();
        StringBuilder stringBuilder7 = new StringBuilder();
        if (item["marks"] != null && item["marks"].HasValues)
        {
            foreach (JToken item2 in (IEnumerable<JToken>)item["marks"])
            {
                string sMark = item2?.ToString();
                JToken jToken2 = jToken?.FirstOrDefault((JToken m) => m["_key"]?.ToString() == sMark);
                if (jToken2 != null)
                {
                	// Check for custom annotations here, and implement your own logic.
                    if (jToken2["_type"]?.ToString() == "link")
                    {
                        stringBuilder6.Append("<a target=\"_blank\" href=\"" + jToken2["href"]?.ToString() + "\">");
                        stringBuilder7.Append("</a>");
                    }
                    else if (jToken2["_type"]?.ToString() == "internalLink")
                    {
                        stringBuilder6.Append("<a href=\"" + jToken2["href"]?.ToString() + "\">");
                        stringBuilder7.Append("</a>");
                    }
                    //I added a typecheck for externalLink here
                    else if (jToken2["_type"]?.ToString() == "externalLink")
                    {
                        // build HTML string for external Link
                    }
                }
                else
                {
                    stringBuilder6.Append($"<{item2}>");
                    stringBuilder7.Append($"</{item2}>");
                }
            }
        }

        stringBuilder.Append(stringBuilder6.ToString() + item["text"]?.ToString() + stringBuilder7.ToString());
    }

    return Task.FromResult($"{stringBuilder2}{stringBuilder4}<{text}>{stringBuilder}</{text}>{stringBuilder5}{stringBuilder3}".Replace("\n", "</br>"));
}
The complete default block serializer from Sanity LINQ

Conclusion

Even though none of these solutions is ideal, it is a way to serialize our annotations for now, while we wait for a new Sanity LINQ NuGet to be published!