2

My goal is to create an analog to the razor @section Scripts {...} syntax that will work equally well in Views and ViewComponents.

I can do this via helper methods if I convert the JavaScript to a windows string. However, this destroys intellisense, puts you into character escaping hell and doesn't allow you to de-dup and order the scripts just prior to rendering.

I'd like to make this work in a way that allows the Visual Studio Editor to edit the JavaScript as JavaScript. It seems like I should be able to do something like this:

<div class="StatsDisplay">
    label id="@labelId">@Model.DisplayFormat</label>
</div>
@using (Html.BeginNamedScript($"StatDisplay{Model.UniqueId}"))
{
    <script>
        $.ajax({
            url: "@Model.ResultUrl",
            method:"POST"
        })
            .done(function (value) {
                var statText = "@Model.DisplayFormat".replace(/\{\s * 0\s *\}/, value);
                $("#@labelId").text(statText);
            });
    </script>
}

HtmlHelperExtension:

public static NamedScript BeginNamedScript(this IHtmlHelper htmlHelper, string name, params string[] dependancies)
    {
        return new NamedScript(htmlHelper.ViewContext, name, htmlHelper, dependancies);
    }

And class NamedScript:

using System;
using System.Diagnostics;
using System.IO;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Mvc.ViewFeatures;

namespace WebUIB8.Helpers
{
    public class NamedScript : IDisposable
    {
        private bool _disposed;
        private readonly FormContext _originalFormContext;
        private readonly ViewContext _viewContext;
        private readonly TextWriter _writer;
        private readonly string _name;
        private readonly HtmlHelper _helper;
        private readonly string[] _dependsOn;

        public NamedScript(ViewContext viewContext, string name, params string[] dependsOn):this(viewContext, name, null, dependsOn)
        {
        }

        internal NamedScript(ViewContext viewContext, string name, IHtmlHelper helper, params string[] dependsOn)
        {
            if (viewContext == null)
            {
                throw new ArgumentNullException(nameof(viewContext));
            }
            _name = name;
            _dependsOn = dependsOn;
            _helper = helper as HtmlHelper;
            _viewContext = viewContext;
            _writer = viewContext.Writer;
            Debug.WriteLine("Beginning:\r\n" + _viewContext);
            _originalFormContext = viewContext.FormContext;
            viewContext.FormContext = new FormContext();

            Begin();
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        public void Begin()
        {
            //No beginning action needed
        }

        private void End()
        {
            Debug.WriteLine("Ending:\r\n" + _writer);
            //NOTE: This chunk doesn't work
            //This is supposed to render the script to a string and
            // pass it to the helper method that accumulates them, orders
            // them, dedups them, and renders them at the proper location
            // in the _Layout file so JavaScript loads last, and in dependancy order.
            _helper?.AddJavaScript(_name, _writer.ToString(), _dependsOn);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!_disposed)
            {
                _disposed = true;
                End();

                if (_viewContext != null)
                    //NOTE: This chunk doesn't work either.
                    //This is supposed to prevent the code from rendering here.
                    _viewContext.FormContext = _originalFormContext;
            }
        }

        public void EndForm()
        {
            Dispose(true);
        }
    }
}

I've tried the below to render the script to string, but it throws an exception inside the .RenderAsync call and aborts the page with a 503.2 error:

private async Task<string> RenderView(ViewContext viewContext)
{
    using (var sw = new StringWriter())
    {
        var newViewContext = new ViewContext(viewContext, viewContext.View, viewContext.ViewData, sw);
        var razorView = newViewContext.View as RazorView;
        razorView.RenderAsync(newViewContext).Wait();
        sw.Flush();

        return sw.ToString();
    }
}
  1. Am I missing a simpler solution? Is there an easier way to render the result of Razor markup and pass it into an html helper method?
  2. How can I render the ViewContext of inside the @using block into text?
  3. How can I prevent that ViewContext from rendering with the rest of it's view? (So that I can render it later on the page)
Community
  • 1
  • 1
SvdSinner
  • 951
  • 1
  • 11
  • 23
  • Have you considered making partial views of your scripts and just using `@Html.Partial("_myscript1")` where you want them? – JamieD77 Oct 26 '15 at 18:54
  • This looks like a good candidate for a tag helper. Your tag helper would get its contents, store them and suppress the output. Another tag helper could render all those contents at the end of the layout – Daniel J.G. Oct 26 '15 at 22:03
  • @SvdSinner, have you been able to check the solution I proposed? – Daniel J.G. Oct 28 '15 at 09:37
  • @DanielJ.G. It looks brilliant. I'm fleshing it out now. – SvdSinner Oct 28 '15 at 14:34
  • cool, I think you will find it useful :) – Daniel J.G. Oct 28 '15 at 14:46
  • @DanielJ.G. I'm thinking of releasing the fleshed out version via github/nuget. It includes a full dependancy and deduplicating system, and I may add optional minification (Not sure if dynamic minifying would help or hinder performance). If that interests you, I'd love to give you some credit and would gladly accept your input into the final code. – SvdSinner Nov 03 '15 at 14:26
  • That sounds like a great idea, ping me whenever you have it! I understand that the answer was useful, you could mark it as accepted. (I will edit it pointing to your fully implemented solution once you have it) – Daniel J.G. Nov 03 '15 at 15:20
  • @DanielJ.G. The mostly complete code is at:https://github.com/SvdSinner/ScriptManagerPlus.git As I get time, I'll be converting it into a Nuget Package. Current functionality handles deduplication and dependency ordering. I will be adding automatic culling of dependencies that are not needed on the page, and optional dynamic minification. I'd be happy to add you as a collaborator if you'd like. – SvdSinner Nov 04 '15 at 17:28
  • Not sure I would handle scripts with the same name that way. My idea was allowing a script to be defined in multiple places, so you could have multiple ` – Daniel J.G. Nov 04 '15 at 19:55

1 Answers1

1

You can implement this behavior using tag helpers.

Let's say you create a tag helper InlineScriptConcatenatorTagHelper targetting the <script> tag, where you basically remove its contents from the output but keep them in memory for later use:

[HtmlTargetElement("script", Attributes = "inline-bundle-add")]
public class InlineScriptConcatenatorTagHelper: TagHelper
{
    private IHttpContextAccessor httpContextAccessor;

    public InlineScriptConcatenatorTagHelper(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }

    [HtmlAttributeName("inline-bundle-add")]
    public string BundleName { get; set; }

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        //Get the script contents
        var contents = await context.GetChildContentAsync();
        var scriptContent = contents.GetContent();

        //Save them into the http Context
        if (httpContextAccessor.HttpContext.Items.ContainsKey(BundleName))
        {
            var scripts = httpContextAccessor.HttpContext.Items[BundleName] as ICollection<string>;
            scripts.Add(scriptContent);
        }
        else
        {
            httpContextAccessor.HttpContext.Items[BundleName] = new List<string> { scriptContent };
        }

        //suppress any output
        output.SuppressOutput();
    }
}

You can then create a similar tag helper InlineScriptTagHelper where you will basically concatenate and render all the contents you collected from the previous helper:

[HtmlTargetElement("script", Attributes = "inline-bundle-render")]
public class InlineScriptTagHelper : TagHelper
{
    private IHttpContextAccessor httpContextAccessor;

    public InlineScriptTagHelper(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }

    [HtmlAttributeName("inline-bundle-render")]
    public string BundleName { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        //if no scripts were added, suppress the contents
        if (!httpContextAccessor.HttpContext.Items.ContainsKey(BundleName))
        {
            output.SuppressOutput();
            return;
        }

        //Otherwise get all the scripts for the bundle 
        var scripts = httpContextAccessor.HttpContext.Items[BundleName] as ICollection<string>;

        //Concatenate all of them as set them as the contents of this tag
        output.Content.SetContentEncoded(String.Join("", scripts));
    }
}

With this in place, you could add as many script blocks in your views and assign them an inline bundle name:

<script inline-bundle-add="myInlineBundle">
    var title = '@ViewData["Title"]';
    var greet = function (message) {
        console.log(message);
    }
</script>

...

<script inline-bundle-add="myInlineBundle">
    greet(title);
</script>

Then add a single script element in your _Layout.cshtml that will render the concatenated output of all the inline scripts with the same bundle name:

    ...

    <script inline-bundle-render="myInlineBundle"></script>
</body>

The rendered output will contain a single script element concatenating all the scripts you included in the inline bundle:

    ...
    <script>
        var title = 'Home Page';
        var greet = function (message) {
            console.log(message);
        }

        greet(title);
    </script>
</body>

Don´t forget to register the tag helpers in your assembly by adding a @addTagHelper directive to the _ViewImports.cshtml file

EDIT

Check out the github project created by @SvdSinner. It has taken the approach described here and created a tag helper that supports deduplication and dependency ordering. (With the aim of supporting minification and provide a nuget package)

Daniel J.G.
  • 34,266
  • 9
  • 112
  • 112