1
\$\begingroup\$

I needed to substitute variables of the syntax {FIRST_NAME} in a HTML template. The problem is that the template comes from a WYSIWYG system, and so the placeholders may be intermixed with HTML tags. For example in the following input, the {SECOND_NAME} placeholder should also be substituted, and the formatting tags in-between should be retained.

{FIRST_NAME}

<style>
  span.second-name {
    color: #f00;
  }
</style>

{</span><span style="style01">SECOND_NAME}

I chose to implement a state machine that scans the template character-by-character, differentiating between text content, tag code and quoted attribute value, and used that to move the HTML tags before the variable value. The transform retains placeholders with unknown variables.

For the example above, the result will be

John

<style>
  span.second-name {
    color: #f00;
  }
</style>

</span><span style="style01">Doe
^ notice the HTML tag lands before the value

assuming FIRST_NAME is John and SECOND_NAME is Doe.

The code

using System.Collections.Generic;
using System.Text;

namespace Foo
{
    // Scans for variables in the {FIRST_NAME} syntax while taking care of HTML tags. For example,
    // if FIRST_NAME is “Jan”, then {</span><span>FIRST_NAME} gets translated to </span><span>Jan.
    // HTML tags are moved to the beginning of the placeholder. The scanner probably does not handle 
    // exotic HTML syntax, but idc.
    //
    // This class is not multithreading safe, and an instance should not be shared between threads.
    //
    // Usage: var output = new HtmlScanner().Walk(input, new Dictionary<string, string> { ... });
    // Remember to encode your variables!
    internal sealed class HtmlVariableParser
    {
        // The scanner basically works like a state machine. For each input character, the
        // machine can change to a different state, or change the current state unchanged.
        // There are three possible states: TEXT, TAG and QUOTE. TEXT is usual text content,
        // TAG is inside an attribute and QUOTE is inside a quoted attribute value.
        private enum HtmlScannerState
        {
            Text,
            Tag,
            Quote,
        }

        // Are we inside braces?
        private bool fInsideBraces;

        // The output string.
        private StringBuilder fResult = new StringBuilder();

        // While scanning characters inside { }, we separate input fragments that are HTML tags
        // from fragments that are normal text. The former is stored in fTagsCollector and the
        // latter is stored inside fVariableKey.
        private string fTagsCollector = "";
        private string fVariableKey = "";

        // The original string between { and }, with order retained.
        private string fOriginalBraceString = "";

        // User-provided variables.
        private IDictionary<string, string> fVariables;

        private char fLastQuoteChar;

        public string Substitute(string html, IDictionary<string, string> variables)
        {
            fResult = new StringBuilder();
            fOriginalBraceString = fVariableKey = fTagsCollector = "";
            fVariables = variables;

            var state = HtmlScannerState.Text;
            for (var cursor = 0; cursor < html.Length; cursor++)
            {
                var c = html[cursor];
                var previous = state;
                state = Transition(state, c);
                Step(previous, state, c);
            }

            return fResult.ToString();
        }  

        private void Step(HtmlScannerState previous, HtmlScannerState state, char c)
        {
            var wasInsideBraces = fInsideBraces;
            RecheckInsideBraces(state, c);

            if (fInsideBraces && wasInsideBraces)
            {
                AppendToBuffer(state, c);
            }
            // We are at the end of a placeholder.
            else if (wasInsideBraces && !fInsideBraces)
            {
                Commit();
            }
            else if (!fInsideBraces)
            {
                fResult.Append(c);
            }
        }

        // If the variable is found, the substitute it; otherwise insert back the braces and the
        // text between them.
        private void Commit()
        {
            bool variableExists = fVariables.ContainsKey(fVariableKey);
            if (variableExists)
            {
                // Insert the collected tag fragments before the variable value.
                fResult.Append(fTagsCollector);
                fResult.Append(fVariables[fVariableKey]);
            }
            else
            {
                // If the variable was not found, do not replace anything. This is the desired
                // behavior, because braces frequently occur in <style> or <script> tags, and
                // we should not touch them.
                fResult.Append("{" + fOriginalBraceString + "}");
            }

            Reset();
        }

        private void Reset()
        {
            fTagsCollector = "";
            fVariableKey = "";
            fOriginalBraceString = "";
        }

        private void AppendToBuffer(HtmlScannerState state, char c)
        {
            // Is the current char a part of a tag?
            var partOfTag =
                state == HtmlScannerState.Tag
                || state == HtmlScannerState.Quote
                || state == HtmlScannerState.Text && c == '>';

            if (partOfTag && fInsideBraces)
            {
                fTagsCollector += c;
            }
            else
            {
                fVariableKey += c;
            }

            fOriginalBraceString += c;
        }

        private void RecheckInsideBraces(HtmlScannerState state, char c)
        {
            // Update fInsideBraces.
            if (state == HtmlScannerState.Text && c == '{')
            {
                fInsideBraces = true;
            }

            if (state == HtmlScannerState.Text && c == '}')
            {
                fInsideBraces = false;
            }
        }

        private HtmlScannerState Transition(HtmlScannerState state, char c)
        {
            switch (state)
            {
                // If we are in the TEXT state, and the character is a left angle bracket, then
                // we're at the beginning of a tag, and transition to the TAG state.
                case HtmlScannerState.Text:
                    if (c == '<') state = HtmlScannerState.Tag;
                    break;

                // If we are inside a TAG and encounter a right angle bracket, this means the tag
                // is being closed.
                case HtmlScannerState.Tag:
                    if (c == '>') state = HtmlScannerState.Text;
                    if (c == '"' || c == '\'')
                    {
                        // Save the kind of quote we encountered, so that <img title="Kant Can't"> 
                        // doesn't blow up the scanner, and transition to QUOTE.
                        fLastQuoteChar = c;
                        state = HtmlScannerState.Quote;
                    }
                    break;

                case HtmlScannerState.Quote:
                    // A quoting character ends the string, but only if it is the same as 
                    if (c == fLastQuoteChar) state = HtmlScannerState.Tag;
                    break;
            }

            return state;
        }
    }
}

```
\$\endgroup\$
  • \$\begingroup\$ What is the f prefix for? \$\endgroup\$ – t3chb0t Mar 15 at 14:23
  • \$\begingroup\$ Fields. I see _ is more popular, but it doesn't work too good with my fingers. \$\endgroup\$ – Logarytm Całkowy Mar 15 at 14:28
  • \$\begingroup\$ It's hard to imagine that any WYSYWIG is creating code like this {</span><span style="style01">SECOND_NAME} the opening { is completely off. Shouldn't it be <span style="style01">{SECOND_NAME}</span> - it otherwise doesn't make any sense to use the {} for enclosing parameter names as their position seem to be pretty random :-\ \$\endgroup\$ – t3chb0t Apr 24 at 13:59
2
\$\begingroup\$

Seems like a lot of work. Can you do something more simple like finding the position of the placeholder in the text, and just removing the nearest 2 brackets?

public string ReplaceToken(string inputText, string token, string value)
{
    var tokenIndex = inputText.IndexOf(token);
    int? openingIndex = null, closingIndex = null;

    for (int i = 0, o = tokenIndex - 1, c = tokenIndex + 1; i < 500; i++, o--, c++)
    {   
        if (!openingIndex.HasValue && inputText[o] == '{')
            openingIndex = o;

        if (!closingIndex.HasValue && inputText[c] == '}')
            closingIndex = c;

        if (openingIndex.HasValue && closingIndex.HasValue)
            break;
    }

    return new StringBuilder(inputText)
        .Remove(closingIndex.Value, 1)
        .Remove(tokenIndex, token.Length)
        .Insert(tokenIndex, value)
        .Remove(openingIndex.Value, 1)
        .ToString();
}
\$\endgroup\$
0
\$\begingroup\$

I agree with @Matt-Cole here. This seems pretty complicated to solve a WYSIWYG output problem. I would make sure this is the right solution to the problem and the one that adds the least amount of complexity to your system.

I don't know which WYSIWYG editor/system is being used. But, if you have access to it you might want to look into solving it there. This posts has variable representation suggestions for WYSIWYG editors. Most common seems to be to using double brackets like [[FIRST_NAME]] or {{FIRST_NAME}}.

If you absolutely must solve the problem by modifying the string then use @Matt-Cole's code to write your solution. Though I don't feel comfortable to set max iteration to 500 not knowing the bounds of the string.

\$\endgroup\$
  • 1
    \$\begingroup\$ Welcome to Code Review and thank you for contributing! A link to a solution is welcome, but please ensure your answer is useful without it: add context around the link so your fellow users will have some idea what it is and why it’s there, then quote the most relevant part of the page you're linking to in case the target page is unavailable. \$\endgroup\$ – Sᴀᴍ Onᴇᴌᴀ Apr 20 at 21:57
  • \$\begingroup\$ @SᴀᴍOnᴇᴌᴀ I added a bit more clarity. Is the answer now useful without the link? I up voted a previous answer because I think that solves complexity withing the solution. But, I wanted to point out that using this approach to solve what interpret as the problem may not be the best solution. If I'm missing the mark, do you have a suggestion on how I can communicate that better? \$\endgroup\$ – Santiago Apr 21 at 0:26
  • \$\begingroup\$ That WYSIWYG system is MS Word. Support for Word documents is required by the business. \$\endgroup\$ – Logarytm Całkowy Aug 23 at 9:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.