This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy under /_llms/. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

Transform the response body on every page

Implement IResponseProcessor to rewrite the final response body as a string — inject HTML before </body>, log an outgoing payload, or append a non-HTML footer.

To transform the final response body on every rendered page, implement IResponseProcessor. The processor receives the full body as a string and returns the replacement — use it to insert a pre-serialized HTML fragment before </body>, log an outgoing payload, or append a non-HTML footer. When the work is DOM-shaped (anchor rewrites, attribute additions, element injection at a CSS selector), implement IHtmlResponseRewriter instead so every rewriter shares one AngleSharp parse. See Rewrite HTML attributes after parsing.

The recipe references examples/ExtensibilityLabExample/FeedbackWidgetProcessor.cs, which injects a "Was this helpful?" aside before </body> against a bare AddPennington host.

Before you begin

  • An existing Pennington site (see Create your first Pennington site if not).
  • The response pipeline buffers the full response body before the processor runs. This is fine for HTML pages but unsuitable for large binary streams — gate those out in ShouldProcess.

Write the processor

Implement Pennington.Infrastructure.IResponseProcessor as a sealed class. Two rules carry the page:

  • ShouldProcess runs before the body is buffered. Returning false skips body capture entirely, so this is where filtering by status code, content type, or request path belongs. The example accepts only 2xx HTML responses, letting static assets, JSON endpoints, and redirects pass through untouched.
  • ProcessAsync receives the full captured body as a string and returns the replacement. The example locates the last </body> with LastIndexOf and inserts the widget HTML there, falling back to append-at-end when the tag is absent so content still reaches the browser.
csharp
namespace ExtensibilityLabExample;
  
using System.Text;
using Microsoft.AspNetCore.Http;
using Pennington.Infrastructure;
  
/// <summary>
/// Implements <see cref="IResponseProcessor"/>. Injects a
/// "Was this helpful?" footer before the closing <c>&lt;/body&gt;</c>
/// tag of every rendered HTML page.
/// <para>
/// Runs at <see cref="Order"/> 500 — after the xref/locale/base-URL HTML
/// rewriting processor (<c>HtmlResponseRewritingProcessor</c>) so the
/// injected HTML is not subject to any further pipeline passes in this
/// app, and well before the live-reload and diagnostic-overlay
/// processors at 1000+.
/// </para>
/// <para>
/// <see cref="ShouldProcess"/> gates on content type: text/html only,
/// and only for 2xx responses. Static assets and API JSON skip through.
/// </para>
/// <para>
/// Backs how-to 2.3.40 <c>/how-to/extensibility/response-processor</c>.
/// </para>
/// </summary>
public sealed class FeedbackWidgetProcessor : IResponseProcessor
{
    private const string WidgetHtml = """
        <aside class="feedback-widget" data-extensibility-lab="feedback-widget">
          <p><strong>Was this helpful?</strong>
            <button type="button" data-feedback="yes">Yes</button>
            <button type="button" data-feedback="no">No</button>
          </p>
        </aside>
        """;
  
    public int Order => 500;
  
    public bool ShouldProcess(HttpContext context)
    {
        if (context.Response.StatusCode is < 200 or >= 300)
        {
            return false;
        }
  
        var contentType = context.Response.ContentType;
        return contentType is not null
               && contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase);
    }
  
    public Task<string> ProcessAsync(string responseBody, HttpContext context)
    {
        if (string.IsNullOrEmpty(responseBody))
        {
            return Task.FromResult(responseBody);
        }
  
        var closeBodyIndex = responseBody.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase);
        if (closeBodyIndex < 0)
        {
            // No </body> — append at end. Still visible, still verifiable.
            return Task.FromResult(responseBody + WidgetHtml);
        }
  
        var sb = new StringBuilder(responseBody.Length + WidgetHtml.Length);
        sb.Append(responseBody, 0, closeBodyIndex);
        sb.Append(WidgetHtml);
        sb.Append(responseBody, closeBodyIndex, responseBody.Length - closeBodyIndex);
        return Task.FromResult(sb.ToString());
    }
}

Pick an Order value

Slot into the Order sequence so the processor sees the HTML state it expects. Anything below 10 would see un-resolved <xref:...> placeholders that HtmlResponseRewritingProcessor expands. The example uses 500 so the widget is inserted after every built-in pass has run. For the full table of shipped Order values, see Pennington.Infrastructure.IResponseProcessor.

Register the processor

Every registered IResponseProcessor is picked up and ordered by its Order value, so a single registration is the entire wiring step. Use the lifetime that matches your dependencies — AddSingleton for stateless processors, AddTransient (or AddFileWatched) when the processor captures file-watched state.

csharp
builder.Services.AddSingleton<IResponseProcessor, FeedbackWidgetProcessor>();

Result

Every text/html response carries the widget aside immediately before its closing </body> tag:

html
<aside class="feedback-widget" data-extensibility-lab="feedback-widget">
      <p><strong>Was this helpful?</strong>
        <button type="button" data-feedback="yes">Yes</button>
        <button type="button" data-feedback="no">No</button>
      </p>
    </aside>
  </body>
</html>

Non-HTML endpoints (/styles.css, /sitemap.xml) are unmodified because ShouldProcess returns false for them.

Verify

  • Run dotnet run --project examples/ExtensibilityLabExample and visit /. The rendered HTML contains <aside class="feedback-widget" data-extensibility-lab="feedback-widget"> immediately before </body>; fetch /styles.css and the aside is absent.
  • Static build: dotnet run --project examples/ExtensibilityLabExample -- build output — grep output/index.html for data-extensibility-lab="feedback-widget" to confirm the processor runs during publish as well as dev.