SxA

Rendering Links with Scriban

Estimated reading time:

Rendering a General Link in Sitecore MVC is pretty simple using the FieldRenderer:

1
2
3
<div>
@Html.Sitecore().Field("LinkField")
</div>

It uses the attributes of the General Link field to create an <a> tag as a text link. But what if you want to use something more complex, like an image as the link or an entire promo card?

In MVC that is still pretty simple:

1
2
3
4
5
<div>
@Html.Sitecore().BeginField("LinkField")
@Html.Sitecore().Field("ImageField")
@Html.Sitecore().EndField()
</div>

or

1
2
3
<div>
@Html.Sitecore().Field("Link", new {text = @Html.Sitecore().Field("Image")})
</div>

Its tricky with Scriban

But with SXA and Scriban rendering variants, it becomes a little more tricky to do that. With Scriban we have the sc_link object along with the standard field renderer. So there are a few ways to render a standard text link:

1
2
3
4
5
6
7
8
9
10

<!-- Standard field render using the item object -->
<div class="field-promolink">
{{ i_item.PromoLink }}
</div>

<!-- Using the sc_link object -->
<div class="field-promolink">
<a href="{{ sc_link i_item }}">{{ i_item.display_name }}</a>
</div>

Using the sc_link object we can even add an image to the link:

1
2
3
<div class="field-promolink">
<a href="{{ sc_link i_item }}">{{ i_item.ImageField }}</a>
</div>

But that does limit what a content editor can do with a General Link field. It only uses the url attribute of the field and ignores things like the target, css class names, title text for ADA compliance etc… It also doesn’t allow for things like anchor links or email links etc…

What we really need is a BeginField and EndField options for Scriban. Fortunately, this is Sitecore and as with most things in Sitecore, we can add that!

Adding the Processor

For the processor, we need to add 2 new functions to scriban, sc_beginfield and sc_endfield. sc_endfield is going to be very simple, no parameters will be passed to it. But sc_beginfield will need to have the same setup as the sc_field function that is out of the box. So let’s start by looking at that processor and creating the customisation there. To add a customization we have to implement IGenerateScribanContextProcessor, but also we want to derive our class from FieldRendererBase:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class BeginEndFieldProcessor : FieldRendererBase, IGenerateScribanContextProcessor
{
protected readonly IPageMode PageMode;

private readonly IScribanRenderCache _scribanRenderCache;
private delegate string BeginRenderDelegate(Item item, object fieldName, ScriptArray parameters);
private delegate string EndRenderDelegate();

public BeginEndFieldProcessor(IPageMode pageMode, IScribanRenderCache scribanRenderCache)
{
PageMode = pageMode;
_scribanRenderCache = scribanRenderCache;
}

public void Process(GenerateScribanContextPipelineArgs args)
{
RenderingWebEditingParams = args.RenderingWebEditingParams;
args.GlobalScriptObject.Import("sc_beginfield", new BeginRenderDelegate(BeginRenderImpl));
args.GlobalScriptObject.Import("sc_endfield", new EndRenderDelegate(EndRenderImpl));
}

public string BeginRenderImpl(Item item, object field, ScriptArray parameters)
{
var parametersCollection = new NameValueCollection();
if (parameters != null)
{
foreach (object parameter in parameters)
{
if (parameter is ScriptArray scriptArray && scriptArray.Count > 1)
{
parametersCollection.Add(scriptArray[0].ToString(), scriptArray[1].ToString());
}
}
}
string fieldName = null;
switch (field)
{
case string fieldString:
fieldName = fieldString;
break;

case ScriptArray array:
using (List<object>.Enumerator enumerator = array.GetEnumerator())
{
while (enumerator.MoveNext())
{
if (enumerator.Current is string current)
{
fieldName = fieldName ?? current;
bool experienceEditorEditing = PageMode.IsExperienceEditorEditing;
Field currentField = item.Fields[current];
if (currentField != null)
{
if (experienceEditorEditing)
{
fieldName = current;
break;
}
if (!IsNullOrWhiteSpace(currentField.GetValue(true)))
{
fieldName = current;
break;
}
}
}
}
break;
}
}

RenderFieldResult fieldRenderer = CreateFieldRenderer(item, fieldName, parametersCollection).RenderField();
_scribanRenderCache.PushEndFieldStack(fieldRenderer.LastPart ?? Empty);
return fieldRenderer.FirstPart;
}

public string EndRenderImpl()
{
return _scribanRenderCache.PopEndFieldStack();
}
}

So lets break that down. First we are creating 2 functions for the implementation of the 2 scriban functions and we are registering them with the args.GlobalScriptObject.

The BeginRenderImpl implementation is mostly a copy of the AddFieldRendererFunction.RenderFieldImpl that adds the sc_field function to Scriban. This uses the item, field and parameters array to create a new FieldRenderer object:

1
RenderFieldResult fieldRenderer = CreateFieldRenderer(item, fieldName, parametersCollection).RenderField();

In the original, it just returns the .Result() of that, which contains the entire rendered out content. But we only want to return the .FirstPart in this call. But we also need to store the .LastPart so that when we call sc_endfield, it renders the correct closing tags for the field we are rendering.

To do this I added and IScribanRenderingCache that is registered with the container as a Scoped object. This internally contains a stack that we can push the results of .LastPart onto, now when we call sc_endfield, that will pop the stack and get the corresponding closing tag. Using a stack means we can also nest sc_beginfield if we needed to, the call to sc_endfield will always provide the correct closing tag.

Using the Functions in a Scriban Template

So how do we use these? Lets say we just want a simple Image Field rendered as the contents of an <a> tag:

1
2
3
{{ sc_beginrender i_item 'Link' [['text', ' ']]}}
{{ sc_field i_item 'Image' }}
{{ sc_endrender }}

will render as:

1
2
3
<a href="/url/set/in/field" title="Alt text from general link attributes">
<img src="/-/media/myimage.jpg" alt="" />
</a>

What about something more complex?

1
2
3
4
{{ sc_beginrender i_item 'Link' [['class', 'font-uppercase font-bold'], ['text', ' ']]}}
<span class="font-icon {{ sc_raw i_item.Icon.target 'Value' }}"></span>
<h5>{{ sc_link_text i_item 'Link' }} </h5>
{{ sc_endrender }}

will render as:

1
2
3
4
<a href="/url/set/in/field" class="font-uppercase font-bold" title="Alt text from general link attributes">
<span class="font-icon fa fa-box"></span>
<h5>The Link Description</h5>
</a>

Notice that we can still pass in all the attributes to the sc_beginrender that you can with sc_field. You may also notice that we have passed in ['text', ' '] as an attribute. This tells the FieldRenderer to ignore the value set in the Description attribute of the general link. It replaces it with a space. If we leave that out, the first example would render as:

1
2
3
<a href="/url/set/in/field" title="Alt text from general link attributes">Link Description
<img src="/-/media/myimage.jpg" alt="" />
</a>

This doesn’t just work with link fields either, it works with anything that can be rendered through the FieldRenderer, the actual outcome will depend on the field type used, YMMV.

tl/dr;

Get the code here: https://github.com/GuitarRich/sxa-modules

Enjoy

  • Richard Seal

References: