Render Item to String (MVC)

No matter what i do, somehow it always goes back to rendering Sitecore items to strings. For some reason having this function available opens up a world of possibilities. I’ve spent quite a while researching how to render an entire item (with layout) to a string. Below is my findings.

Note that this is all for sitecore MVC layouts not webforms.

Challenge 1

There is a static and unchangeable page definition context item that sitecore utilizes in the rendering process.  This gets in the way if you want to render an item in the middle of rendering another item.

Solution

Create a new context stack that you can push new PageRenderItemDefinitionContext items to, then override the render pipeline to use the PageRenderItemDefinitionContext stack if it exists.

    ///<summary>
    /// A context stack for the current page defintion.  By default many of the MVC pipeline
    /// processes use a static singular page context PageContext.  This is problematic if
    /// you want to render from a completely seperate context.  This is set up as a context
    /// stack to be used on top of the already used PageContext.
    /// </summary>

	public class PageRenderItemDefinitionContext
	{
		public static PageRenderItemDefinitionContext Current => ContextService.Get().GetCurrent<PageRenderItemDefinitionContext>();

		public static PageRenderItemDefinitionContext CurrentOrNull => ContextService.Get().GetCurrentOrDefault<PageRenderItemDefinitionContext>();

		public PageDefinition Definition { get; private set; }
		public Item Item { get; private set; }
		public DisplayMode PageMode { get; set; }

		public PageRenderItemDefinitionContext(PageDefinition pageDefinition, Item item, DisplayMode exteriorDisplayMode)
		{
			Assert.ArgumentNotNull(pageDefinition, nameof(pageDefinition));
			Assert.ArgumentNotNull(item, nameof(item));

			Definition = pageDefinition;
			Item = item;
			PageMode = exteriorDisplayMode;
		}

		public static IDisposable Enter(PageRenderItemDefinitionContext context)
		{
			Assert.ArgumentNotNull(context, "context");
			return ContextService.Get().Push(context);
		}
	}

The second part of this is to override the render method in the PerformRendering processor of the RenderPlaceholder pipeline to use our new PageRenderItemDefinitionContext class.

    public class PerformItemRendering : PerformRendering
	{
		public static readonly string ItemRenderingKey = Guid.NewGuid().ToString();

		/// <summary>
		/// Render step, except it temporarily abandons the placeholder context to render a seperate item, after which it puts the context back
		/// </summary>
		/// <param name="placeholderName">Placeholder to render</param>
		/// <param name="writer">writer to render to</param>
		/// <param name="args"></param>
		protected override void Render(string placeholderName, TextWriter writer, RenderPlaceholderArgs args)
		{
			if (PageRenderItemDefinitionContext.CurrentOrNull != null)
				args.PageContext.PageDefinition = PageRenderItemDefinitionContext.Current.Definition;

			if (placeholderName != ItemRenderingKey)
			{
				base.Render(placeholderName, writer, args);
				return;
			}

			Stack<PlaceholderContext> previousContext = new Stack<PlaceholderContext>();
			while (PlaceholderContext.CurrentOrNull != null)
			{
				previousContext.Push(PlaceholderContext.Current);
				PlaceholderContext.Exit();
			}

			try
			{
				PipelineService.Get().RunPipeline("mvc.renderRendering", new RenderRenderingArgs(args.PageContext.PageDefinition.Renderings.First(x => x.Placeholder.IsWhiteSpaceOrNull()), writer));
			}
			finally
			{
				while (PlaceholderContext.CurrentOrNull != null)
					PlaceholderContext.Exit();

				while (previousContext.Any())
				{
					PlaceholderContext.Enter(previousContext.Pop());
				}
			}
		}
	}

Challenge 2

There are quite a few moving parts involved in rendering an item, we need something to assemble all of the pipelines and extract the values that we’ll need to render the item.

Solution

Create a new class to gather what we need and run the needed pipelines

  1. Gather all renderings for the item
    1. Parse the layout field as XML and use to extract all renderings
  2. Identify the layout rendering or page level rendering
    1. Build a PageDefinition object using the renderings from step 1
    2. Pass PageDefinition into mvc.getPageRendering pipeline
  3. Render the placeholders of the page
    1. Using the page level rendering build a new PageContext
    2. Pass a special GUID that effectively tells the processor to render all placeholders
    3. Run mvc.renderPlaceholder pipeline and output the results

I’ve created a new class that wraps around an item to provide the above functionality.

	/// <summary>
	/// Renders an item's layout to a string or TextWriter.
	/// </summary>
	public class ItemRenderer
	{
		public Item Item { get; set; }

		public ItemRenderer(Item item)
		{
			Item = item;
		}

		/// <summary>
		/// Renders an item with a layout defined to a string for MVC
		/// </summary>
		/// <returns>HTML of item</returns>
		public virtual string Render()
		{
			using (TextWriter tw = new StringWriter())
			{
				Render(tw);

				return tw.ToString();
			}
		}

		/// <summary>
		/// Renders an item with a layout defined to a string for MVC
		/// </summary>
		/// <returns>HTML of item</returns>
		public virtual void Render(TextWriter writer)
		{
			var originalDisplayMode = Context.Site.DisplayMode;

			// keep a copy of the renderings we start with.
			// running the renderPlaceholder pipeline (which runs renderRendering) will overwrite these
			// and we need to set them back how they were when we're done rendering the xBlock
			var originalRenderingDefinitionContext = RenderingContext.CurrentOrNull?.PageContext?.PageDefinition;

			try
			{
				// prevents editing the snippet in context, so you cannot mistakenly change something shared by mistake
				if (Context.PageMode.IsExperienceEditorEditing)
					Context.Site.SetDisplayMode(DisplayMode.Preview, DisplayModeDuration.Temporary);

				var pageDef = new PageDefinition
				{
					Renderings = new List<Rendering>()
				};

				//Extracts the item's layout XML, then parses all of the renderings out of it.
				pageDef.Renderings.AddRange(GetRenderings(GetLayoutFromItem()));

				// Uncovers the main layout rendering
				var pageRenderingArgs = new GetPageRenderingArgs(pageDef);
				PipelineService.Get().RunPipeline("mvc.getPageRendering", pageRenderingArgs);

				//Renders all placeholders for the layout rendering, which would be the entire page
				var renderPlaceholderArgs = new RenderPlaceholderArgs(PerformItemRendering.ItemRenderingKey, writer, pageRenderingArgs.Result)
				{
					PageContext = new PageContext
					{
						PageDefinition = pageDef
					}
				};

				using (PageRenderItemDefinitionContext.Enter(new PageRenderItemDefinitionContext(pageDef, Item, originalDisplayMode)))
				{
					PipelineService.Get().RunPipeline("mvc.renderPlaceholder", renderPlaceholderArgs);
				}
			}
			catch (Exception e)
			{
				Log.Error("There was a problem rendering an item to string", e, this);
				if (originalDisplayMode == DisplayMode.Edit || originalDisplayMode == DisplayMode.Preview)
				{
					writer.Write($"<p class=\"edit-only\">Error occurred while rendering {Item.Paths.FullPath}: {e.Message}<br>For error details, <a href=\"{LinkManager.GetItemUrl(Item)}\" onclick=\"window.open(this.href); return false;\">visit the target page</a></p>");
				}
			}
			finally
			{
				// replace the renderings in the current context with the ones that existed before we ran our sideline renderPlaceholder
				// because they have been overwritten with the xBlock's renderings at this point
				if (originalRenderingDefinitionContext != null)
				{
					RenderingContext.CurrentOrNull.PageContext.PageDefinition = originalRenderingDefinitionContext;
				}

				Context.Site.SetDisplayMode(originalDisplayMode, DisplayModeDuration.Temporary);
			}
		}

		/// <summary>
		/// Gets the layout XML from the item
		/// </summary>
		/// <returns>xml of the layout definition</returns>
		protected virtual XElement GetLayoutFromItem()
		{
			Field innerField = new LayoutField(Item).InnerField;

			if (innerField == null)
				return null;

			string fieldValue = LayoutField.GetFieldValue(innerField);

			if (fieldValue.IsWhiteSpaceOrNull())
				return null;

			return XDocument.Parse(fieldValue).Root;
		}

		/// <summary>
		/// Gets the rendering out of the xml node and injects some values in
		/// </summary>
		/// <param name="renderingNode"></param>
		/// <param name="deviceId"></param>
		/// <param name="layoutId"></param>
		/// <param name="renderingType"></param>
		/// <param name="parser"></param>
		/// <returns>MVC rendering</returns>
		protected virtual Rendering GetRendering(XElement renderingNode, Guid deviceId, Guid layoutId, string renderingType, XmlBasedRenderingParser parser)
		{
			Rendering rendering = parser.Parse(renderingNode, false);
			rendering.DeviceId = deviceId;
			rendering.LayoutId = layoutId;
			if (renderingType != null)
				rendering.RenderingType = renderingType;

			// if the xBlock is rendering in the context of another page, renderings with no data source should be repointed to the xBlock page item
			// as opposed to the context page item
			if (string.IsNullOrWhiteSpace(rendering.DataSource)) rendering.DataSource = Item.ID.ToString();

			return rendering;
		}

		/// <summary>
		/// Get all renderings out of the layout definition
		/// </summary>
		/// <param name="layoutDefinition">xml of the layout definition</param>
		/// <returns>list of renderings</returns>
		protected virtual IEnumerable<Rendering> GetRenderings(XElement layoutDefinition)
		{
			XmlBasedRenderingParser parser = MvcSettings.GetRegisteredObject<XmlBasedRenderingParser>();
			foreach (XElement xelement in layoutDefinition.Elements("d"))
			{
				Guid deviceId = xelement.GetAttributeValueOrEmpty("id").ToGuid();
				Guid layoutId = xelement.GetAttributeValueOrEmpty("l").ToGuid();

				yield return GetRendering(xelement, deviceId, layoutId, "Layout", parser);

				foreach (XElement renderingNode in xelement.Elements("r"))
					yield return GetRendering(renderingNode, deviceId, layoutId, renderingNode.Name.LocalName, parser);
			}
		}
	}

At which point we can add an extension method to your collection (you already have a collection, right?)

        /// <summary>
        /// Renders an item with a layout definition to a string
        /// </summary>
        /// <param name="item"></param>
        /// <returns>Rendered output for the item</returns>
	    public static string RenderToString(this Item item)
	    {
	        return new ItemRenderer(item).Render();
	    }

Don’t forget to wire up the pipeline processor we overrode.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <mvc.renderPlaceholder>
        <processor patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderPlaceholder.PerformRendering, Sitecore.Mvc']" type="Debut.Pipelines.Mvc.RenderPlaceholder.PerformRendering, Debut.Core" />
      </mvc.renderPlaceholder>
    </pipelines>
  </sitecore>
</configuration>

Pro tip

Now you can use this to create a reusable collection of components. This is particularly useful for things like site navigation.

  1. Create a new cshtml for a layout
  2. Create a new rendering that uses the same cshtml
  3. Leverage the fact that CurrentRendering.RenderingType will be “r” when it’s rendered as a component and “Layout” when it’s rendered as a page
  4. When the item is rendered as a component take the datasource item and render the entire item as a page to a string and drop it on the page

Now you have a site navigation that can be added to all pages and is managed from one location.