Search code examples
localizationblazorweb-componentwebassembly

Blazor Webassembly: How to insert components into a string


I have this component that displays generic messages:

<span>@message</span>

The messages are identified by an id and come from string tables in resources files (multiple languages). An example of a message would be:

"Hello {user}! Welcome to {site}!"

So in the basic case, I simply parse the string and replace {user} with, say, "John Doe" and {site} with "MySiteName". The result is set to message and is then properly (and safely) rendered.

But what I would like to do is actually replace {site} with a component that I created that displays the site name with special font and styling. I also have other cases where I want to replace special {markings} with components.

How would you approach this problem ? Is there a way to "insert" a component into a string and then insert the string "safely" to be rendered ? I say "safely" because portions of the final string may come from the DB and be inherently unsafe (like user's name) so inserting the string with something like @((MarkupString)message) does not seem safe.

EDIT: Thanks to MrC aka Shaun Curtis from whom this final solution is greatly inspired. I marked his answer as the best one.

So I finally went with a scoped service that gets the strings from the resources files, parse them and return a list of RenderFragments that it gets from a component's static table. I use dynamic objects to send specific parameters to the RenderFragments when required.

I basically now get all the text of my app through this centralized mechanism.

Here is an example of an entry in a resource file string table:

Name: "welcome"; Value: "Welcome to {site:name} {0}!"

Here is how it is used in a component:

<h3><Localizer Key="notif:welcome" Data="@(new List<string>() { NotifModel.UserNames.First })"/></h3>

You can see the simplified component and service code below. I explicitely left out the validation and error checking code for simplicity.

@using MySite.Client.Services.Localizer
@inject ILocalizerService Loc 

@foreach (var fragment in _fragments)
{
  @fragment.Renderer(fragment.Item)
}

@code
{
  private List<ILocalizerService.Fragment> _fragments;

  public enum RendererTypes
  {
    Default,
    SiteName,
    SiteLink,
  }

  public static Dictionary<RendererTypes, RenderFragment<dynamic>> Renderers = new Dictionary<RendererTypes, RenderFragment<dynamic>>()
  {
    // NOTE: For each of the following items, do NOT insert a space between the end of the markup and the closing curly brace otherwise it will be rendered !!!
    //                                                       Like here ↓↓
    { RendererTypes.Default, (model) => @<span>@(model as string)</span>},
    { RendererTypes.SiteName, (model) => @<MySiteNameComponent />},
    { RendererTypes.SiteLink, (model) => @<a href="@model.LinkUrl">@model.LinkTxt</a>}
  };

  [Parameter]
  public string Key { get; set; }

  [Parameter]
  public List<string> Data { get; set; }

  protected override void OnParametersSet()
  {
    _fragments = Loc.GetFragments(Key, Data);
  }
}

interface ILocalizerService
{
  public struct Fragment
  {
    public Fragment(RenderFragment<dynamic> renderer)
      : this(renderer, default)
    {
    }

    public Fragment(RenderFragment<dynamic> renderer, dynamic item)
    {
      Renderer = renderer;
      Item = item;
    }

    public RenderFragment<dynamic> Renderer { get; set; }
    public dynamic Item { get; set; }
  }

  List<Fragment> GetFragments(string key, List<string> parameters);
}

internal sealed class LocalizerService : ILocalizerService
{
  private readonly Dictionary<string, IStringLocalizer> _strLoc = new Dictionary<string, IStringLocalizer>();

  public LocalizerService(IStringLocalizer<MySite.Shared.Resources.App> appLoc,
                          IStringLocalizer<MySite.Shared.Resources.Connection> connLoc,
                          IStringLocalizer<MySite.Shared.Resources.Notifications> notifLoc)
  {
    // Keep string localizers
    _strLoc.Add("app", appLoc);
    _strLoc.Add("conn", connLoc);
    _strLoc.Add("notif", notifLoc);
  }

  public List<Fragment> GetFragments(string key, List<string> parameters)
  {
    var list = new List<Fragment>();

    GetFragments(list, key, parameters);

    return list;
  }

  private void GetFragments(List<Fragment> list, string key, List<string> parameters)
  {
    // First, get key tokens
    var tokens = key.Split(':');

    // Analyze first token
    switch (tokens[0])
    {
      case "site":
        // Format : {site:...}
        ProcessSite(list, tokens, parameters);
        break;

      default:
        // Format : {0|1|2|...}
        if (uint.TryParse(tokens[0], out var paramIndex))
        {
          ProcessParam(list, paramIndex, parameters);
        }
        // Format : {app|conn|notif|...}
        else if (_strLoc.ContainsKey(tokens[0]))
        {
          ProcessStringLocalizer(list, tokens, parameters);
        }
        break;
    }

  }

  private void ProcessSite(List<Fragment> list, string[] tokens, List<string> parameters)
  {
    // Analyze second token
    switch (tokens[1])
    {
      case "name":
        // Format {site:name}
        // Add name component
        list.Add(new Fragment(Shared.Localizer.Renderers[Shared.Localizer.RendererTypes.SiteName]));
        break;

      case "link":
        // Format {site:link:...}
        ProcessLink(list, tokens, parameters);
        break;
    }
  }

  private void ProcessLink(List<Fragment> list, string[] tokens, List<string> parameters)
  {
    // Analyze third token
    switch (tokens[2])
    {
      case "user":
        // Format: {site:link:user:...}
        ProcessLinkUser(list, tokens, parameters);
        break;
    }
  }

  private void ProcessLinkUser(List<Fragment> list, string[] tokens, List<string> parameters)
  {
    // Check length
    var length = tokens.Length;
    if (length >= 4)
    {
      string linkUrl;
      string linkTxt;

      // URL
      // Format: {site:link:user:0|1|2|...}
      // Retrieve handle from param
      if (!uint.TryParse(tokens[3], out var paramIndex))
      {
        throw new ApplicationException("Invalid token!");
      }
      var userHandle = GetParam(paramIndex, parameters);
      linkUrl = $"/user/{userHandle}";

      // Text
      if (length >= 5)
      {
        if (tokens[4].Equals("t"))
        {
          // Format: {site:link:user:0|1|2|...:t}
          // Use token directly as text
          linkTxt = tokens[4];
        }
        else if (uint.TryParse(tokens[4], out paramIndex))
        {
          // Format: {site:link:user:0|1|2|...:0|1|2|...}
          // Use specified param as text
          linkTxt = GetParam(paramIndex, parameters);
        }
      }
      else
      {
        // Format: {site:link:user:0|1|2|...}
        // Use handle as text
        linkTxt = userHandle;
      }

      // Add link component
      list.Add(new Fragment(Shared.Localizer.Renderers[Shared.Localizer.RendererTypes.SiteLink], new { LinkUrl = linkUrl, LinkTxt = linkTxt }));
    }
  }

  private void ProcessParam(List<Fragment> list, uint paramIndex, List<string> parameters)
  {
    // Add text component
    list.Add(new Fragment(Shared.Localizer.Renderers[Shared.Localizer.RendererTypes.Default], GetParam(paramIndex, parameters)));
  }

  private string GetParam(uint paramIndex, List<string> parameters)
  {
    // Proceed
    if (paramIndex < parameters.Length)
    {
      return parameters[paramIndex];
    }
  }

  private void ProcessStringLocalizer(List<Fragment> list, string[] tokens, List<string> parameters)
  {
    // Format {loc:str}
    // Retrieve string localizer
    var strLoc = _strLoc[tokens[0]];

    // Retrieve string
    var str = strLoc[tokens[1]].Value;

    // Split the string in parts to see if it needs formatting
    // NOTE:  str is in the form "...xxx {key0} yyy {key1} zzz...".
    //        This means that once split, the keys are always at odd indexes (even if {key} starts or ends the string)
    var strParts = str.Split('{', '}');
    for (int i = 0; i < strParts.Length; i += 2)
    {
      // Get parts
      var evenPart = strParts[i];
      var oddPart = ((i + 1) < strParts.Length) ? strParts[i + 1] : null;

      // Even parts are always regular text. If not null or empty, we add directly
      if (!string.IsNullOrEmpty(evenPart))
      {
        list.Add(new Fragment(Shared.Localizer.Renderers[Shared.Localizer.RendererTypes.Default], evenPart));
      }

      // Odd parts are always keys. If not null or empty, get fragments recursively
      if (!string.IsNullOrEmpty(oddPart))
      {
        GetFragments(list, oddPart, parameters);
      }
    }
  }
}

Solution

  • You don't necessarily need to build components. A component is a c# class that emits a RenderFragment.

    You could simply build RenderFragments for {site},... Here's a simple static class that shows two ways to do this:

    namespace StackOverflowAnswers;
    
    public static class RenderFragements
    {
        public static RenderFragment SiteName => (builder) =>
        {
            // Get the content from a service that's accessing a database and checking the culture info for language
            builder.OpenElement(0, "div");
            builder.AddAttribute(1, "class", "p-2 bg-primary text-white");
            builder.AddContent(2, "My Site");
            builder.CloseElement();
        };
    
        public static RenderFragment GetSiteName(string sitename) => (builder) =>
       {
           // parse to make sure you're happy with the string
           builder.OpenElement(0, "span");
           builder.AddAttribute(1, "class", "p-2 bg-dark text-white");
           builder.AddContent(2, sitename);
           builder.CloseElement();
       };
    }
    

    And here's an index page using them:

    @page "/"
    @using StackOverflowAnswers
    
    <PageTitle>Index</PageTitle>
    
    <h1>Hello, world!</h1>
    
    Welcome to your new app.
    
    <SurveyPrompt Title="How is Blazor working for you?" />
    
    <div class=m-2>
    The site name for this site is @(RenderFragements.GetSiteName("this site"))
    </div>
    
    @(RenderFragements.SiteName)
    

    With the RenderFragment your writing c# code. You can run a parser to check the string before rendering it.

    You could have a scoped service that gets the info from the database for the user and exposes a set of RenderFragments you then use in your pages/components.