#Extensibility

XSLT stylesheets rarely exist in isolation. Real transforms need domain-specific logic, access to external data, and integration with the host application. XSLT's extensibility model provides multiple mechanisms for this: user-defined functions, packages, extension functions registered from the host environment, and import/include hierarchies.

If you have worked with C# middleware, Razor tag helpers, or plugin architectures with IServiceCollection, you already understand the pattern: extend the framework's built-in capabilities with application-specific behavior.

#Contents


#Why Extensibility Matters

Consider a real XSLT transform for generating invoices. You need to:

  • Format currency according to the customer's locale

  • Calculate tax based on jurisdiction-specific rules that change quarterly

  • Look up the current exchange rate from an API

  • Generate a unique invoice number from a database sequence

  • Send the rendered invoice to a print queue or email service

Standard XSLT can handle the XML transformation. Extensibility handles everything else — the parts where the stylesheet needs to talk to the outside world.

C# parallel:

// ASP.NET Core: the framework handles HTTP, you extend with middleware and services
builder.Services.AddScoped<IInvoiceFormatter, LocaleAwareFormatter>();
builder.Services.AddScoped<ITaxCalculator, JurisdictionTaxCalculator>();
builder.Services.AddHttpClient<IExchangeRateService, OpenExchangeRateService>();

#xsl:function — User-Defined Functions

This section provides a brief recap. For comprehensive coverage, see User-Defined Functions.

xsl:function defines custom functions callable from any XPath expression:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                 xmlns:app="http://example.com/app"
                 version="3.0">
  <xsl:function name="app:format-price" as="xs:string">
    <xsl:param name="amount" as="xs:decimal"/>
    <xsl:param name="currency" as="xs:string"/>
    <xsl:choose>
      <xsl:when test="$currency = 'USD'">
        <xsl:value-of select="concat('$', format-number($amount, '#,##0.00'))"/>
      </xsl:when>
      <xsl:when test="$currency = 'EUR'">
        <xsl:value-of select="concat(format-number($amount, '#.##0,00'), ' EUR')"/>
      </xsl:when>
      <xsl:otherwise>
        <xsl:value-of select="concat(format-number($amount, '#,##0.00'), ' ', $currency)"/>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:function>
  <!-- Use it in any XPath expression -->
  <xsl:template match="order">
    <div class="total">
      <xsl:value-of select="app:format-price(total, @currency)"/>
    </div>
  </xsl:template>
</xsl:stylesheet>

Functions can be recursive, accept other functions as arguments (higher-order functions), and return any XPath type — strings, numbers, nodes, maps, arrays, or sequences.


#Packages — Reusable Stylesheet Libraries

This section provides a brief recap. For comprehensive coverage, see Packages.

XSLT 3.0 packages bundle stylesheets into reusable libraries with controlled visibility — like NuGet packages for XSLT.

#Defining a Package

<xsl:package name="http://example.com/formatting" version="2.0"
             xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
             xmlns:fmt="http://example.com/formatting"
             version="3.0">
  <!-- Public: consumers can call this -->
  <xsl:expose component="function" names="fmt:format-date" visibility="public"/>
  <xsl:expose component="function" names="fmt:format-currency" visibility="public"/>
  <!-- Private: internal helper, not accessible to consumers -->
  <xsl:expose component="function" names="fmt:*-helper" visibility="private"/>
  <!-- Abstract: consumers MUST override this -->
  <xsl:expose component="function" names="fmt:get-locale" visibility="abstract"/>
  <xsl:function name="fmt:format-date" as="xs:string">
    <xsl:param name="date" as="xs:date"/>
    <xsl:value-of select="format-date($date, '[MNn] [D], [Y]')"/>
  </xsl:function>
  <xsl:function name="fmt:format-currency" as="xs:string" visibility="public">
    <xsl:param name="amount" as="xs:decimal"/>
    <xsl:value-of select="concat('$', format-number($amount, '#,##0.00'))"/>
  </xsl:function>
</xsl:package>

#Using a Package

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <xsl:use-package name="http://example.com/formatting" version="2.0">
    <!-- Override the abstract function -->
    <xsl:override>
      <xsl:function name="fmt:get-locale" as="xs:string"
                    xmlns:fmt="http://example.com/formatting">
        <xsl:sequence select="'en-US'"/>
      </xsl:function>
    </xsl:override>
  </xsl:use-package>
</xsl:stylesheet>

Visibility controls:

XSLT Visibility

C# Equivalent

Meaning

public

public

Accessible to consumers

private

private / internal

Hidden from consumers

final

sealed / non-virtual

Public but cannot be overridden

abstract

abstract

Must be overridden by consumer


#Extension Functions from .NET

PhoenixmlDb allows you to register .NET methods that become callable from XPath expressions within XSLT stylesheets. This is the primary mechanism for connecting XSLT to the outside world.

#Setting Stylesheet Parameters from C#

The simplest form of .NET integration — pass values into the stylesheet:

var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, baseUri);
// Set parameters that the stylesheet declares with xsl:param
transformer.SetParameter("api-key", apiKey);
transformer.SetParameter("base-url", "https://api.example.com");
transformer.SetParameter("report-date", DateTime.Now.ToString("yyyy-MM-dd"));
transformer.SetParameter("environment", "production");
transformer.SetParameter("user-name", currentUser.DisplayName);
var result = await transformer.TransformAsync(inputXml);

The stylesheet declares matching parameters:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <xsl:param name="api-key" as="xs:string"/>
  <xsl:param name="base-url" as="xs:string" select="'https://localhost'"/>
  <xsl:param name="report-date" as="xs:date" select="current-date()"/>
  <xsl:param name="environment" as="xs:string" select="'development'"/>
  <xsl:param name="user-name" as="xs:string" select="'anonymous'"/>
  <xsl:template match="/">
    <report generated-by="{$user-name}"
            date="{format-date($report-date, '[Y]-[M01]-[D01]')}"
            env="{$environment}">
      <xsl:apply-templates/>
    </report>
  </xsl:template>
</xsl:stylesheet>

C# parallel:

// Parameters are like constructor arguments or configuration values
public class ReportGenerator
{
    private readonly string _apiKey;
    private readonly string _baseUrl;
    private readonly string _environment;
    public ReportGenerator(IConfiguration config)
    {
        _apiKey = config["ApiKey"];
        _baseUrl = config["BaseUrl"];
        _environment = config["Environment"] ?? "development";
    }
}

#Registering Extension Functions

Register C# methods that XSLT can call from XPath expressions:

var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, baseUri);
// Register a function that returns a value
transformer.RegisterFunction(
    "http://example.com/ext",   // namespace URI
    "get-exchange-rate",         // local name
    (string from, string to) =>
    {
        using var client = new HttpClient();
        var response = client.GetStringAsync(
            $"https://api.rates.com/latest?base={from}&symbols={to}"
        ).Result;
        var data = JsonSerializer.Deserialize<RateResponse>(response);
        return data?.Rates[to] ?? 1.0m;
    }
);
// Register a function that formats values
transformer.RegisterFunction(
    "http://example.com/ext",
    "format-phone",
    (string phone) =>
    {
        if (phone.Length == 10)
            return $"({phone[..3]}) {phone[3..6]}-{phone[6..]}";
        return phone;
    }
);
// Register a function that logs
transformer.RegisterFunction(
    "http://example.com/ext",
    "log-message",
    (string level, string message) =>
    {
        var logger = LoggerFactory.Create(b => b.AddConsole())
            .CreateLogger("XSLT");
        logger.Log(level == "error" ? LogLevel.Error : LogLevel.Information, message);
        return true;
    }
);

The stylesheet calls these functions:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                 xmlns:ext="http://example.com/ext"
                 version="3.0">
  <xsl:template match="price">
    <xsl:variable name="rate" select="ext:get-exchange-rate('USD', 'EUR')"/>
    <price-eur>
      <xsl:value-of select="format-number(. * $rate, '#,##0.00')"/>
    </price-eur>
  </xsl:template>
  <xsl:template match="phone">
    <formatted-phone>
      <xsl:value-of select="ext:format-phone(.)"/>
    </formatted-phone>
  </xsl:template>
</xsl:stylesheet>

#Handling Secondary Result Documents

XSLT's xsl:result-document produces multiple output files from a single transform. The .NET API captures all of them:

<!-- Stylesheet that produces multiple files -->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <xsl:template match="/">
    <!-- Primary output: index.html -->
    <html>
      <body>
        <h1>Product Catalog</h1>
        <ul>
          <xsl:for-each select="//product">
            <li><a href="{@id}.html"><xsl:value-of select="name"/></a></li>
          </xsl:for-each>
        </ul>
      </body>
    </html>
    <!-- Secondary outputs: one page per product -->
    <xsl:for-each select="//product">
      <xsl:result-document href="{@id}.html" method="html" html-version="5">
        <html>
          <body>
            <h1><xsl:value-of select="name"/></h1>
            <p class="price"><xsl:value-of select="format-number(price, '$#,##0.00')"/></p>
            <div class="description"><xsl:value-of select="description"/></div>
          </body>
        </html>
      </xsl:result-document>
    </xsl:for-each>
  </xsl:template>
</xsl:stylesheet>
var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, baseUri);
var primaryResult = await transformer.TransformAsync(inputXml);
// Write primary result
string outputDir = "/output/catalog";
Directory.CreateDirectory(outputDir);
await File.WriteAllTextAsync(Path.Combine(outputDir, "index.html"), primaryResult);
// Write all secondary result documents
foreach (var (href, content) in transformer.SecondaryResultDocuments)
{
    string filePath = Path.Combine(outputDir, href);
    Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
    await File.WriteAllTextAsync(filePath, content);
}
Console.WriteLine($"Generated {1 + transformer.SecondaryResultDocuments.Count} files");

#Extension Instructions and xsl:fallback

#Extension Instructions

Extension elements allow processors to support custom XSLT instructions beyond the standard set. You declare extension namespaces with extension-element-prefixes:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                 xmlns:sql="http://example.com/sql-extension"
                 extension-element-prefixes="sql"
                 version="3.0">
  <xsl:template match="report">
    <sql:query connection="main" var="rows">
      SELECT name, total FROM orders WHERE date = current_date
    </sql:query>
    <table>
      <xsl:for-each select="$rows/row">
        <tr>
          <td><xsl:value-of select="name"/></td>
          <td><xsl:value-of select="total"/></td>
        </tr>
      </xsl:for-each>
    </table>
  </xsl:template>
</xsl:stylesheet>

#xsl:fallback — Graceful Degradation

When an extension element is not available (because the stylesheet runs on a different processor), xsl:fallback provides a safe alternative:

<xsl:template match="data">
  <sql:query connection="main" var="rows">
    SELECT * FROM products
    <xsl:fallback>
      <!-- If sql:query isn't supported, use static data instead -->
      <xsl:message>SQL extension not available. Using static data.</xsl:message>
      <xsl:apply-templates select="document('static-products.xml')//product"/>
    </xsl:fallback>
  </sql:query>
</xsl:template>

This pattern lets you write stylesheets that work across processors — using advanced features where available and degrading gracefully elsewhere.

C# parallel:

// Similar to optional dependency injection with fallbacks
public class ProductService
{
    private readonly ISqlExtension? _sql;
    private readonly IStaticDataProvider _fallback;
    public IEnumerable<Product> GetProducts()
    {
        if (_sql != null)
            return _sql.Query<Product>("SELECT * FROM products");
        else
            return _fallback.LoadProducts("static-products.xml");
    }
}

#use-when — Compile-Time Conditional Inclusion

For compile-time decisions about which code to include:

<!-- Only include debug templates when running in development -->
<xsl:template match="*" mode="debug"
              use-when="system-property('xsl:product-name') = 'PhoenixmlDb'">
  <div class="debug">
    <pre><xsl:copy-of select="."/></pre>
  </div>
</xsl:template>

#Import and Include

#xsl:import — Lower Precedence

Imported templates and functions have lower precedence than those in the importing stylesheet. This lets you define defaults that the importer can override:

<!-- base-styles.xsl — provides default formatting -->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <xsl:template match="heading">
    <h2><xsl:value-of select="."/></h2>
  </xsl:template>
  <xsl:template match="paragraph">
    <p><xsl:value-of select="."/></p>
  </xsl:template>
</xsl:stylesheet>
<!-- custom-styles.xsl — overrides specific templates -->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <xsl:import href="base-styles.xsl"/>
  <!-- Override: headings get a class attribute -->
  <xsl:template match="heading">
    <h2 class="custom-heading"><xsl:value-of select="."/></h2>
  </xsl:template>
  <!-- paragraph template is inherited from base-styles.xsl -->
</xsl:stylesheet>

You can call the overridden (imported) template with xsl:next-match or xsl:apply-imports:

<xsl:template match="heading">
  <div class="heading-wrapper">
    <!-- Delegate to the imported template for the actual rendering -->
    <xsl:next-match/>
  </div>
</xsl:template>

C# parallel:

// Like virtual method override with base call
public class CustomRenderer : BaseRenderer
{
    public override string RenderHeading(string text)
    {
        // Wrap the base implementation
        return $"<div class='heading-wrapper'>{base.RenderHeading(text)}</div>";
    }
}

#xsl:include — Same Precedence

Included stylesheets are treated as if their content were copy-pasted into the including stylesheet. There is no precedence difference:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <!-- Pull in shared templates at the same precedence level -->
  <xsl:include href="common-templates.xsl"/>
  <xsl:include href="utility-functions.xsl"/>
  <xsl:template match="/">
    <!-- Can use templates and functions from included files -->
    <xsl:apply-templates/>
  </xsl:template>
</xsl:stylesheet>

#Building Stylesheet Hierarchies

Larger projects use a layered architecture:

stylesheets/
  base/
    typography.xsl        (: base text formatting :)
    layout.xsl            (: page structure :)
    utilities.xsl         (: helper functions :)
  themes/
    corporate.xsl         (: imports base, overrides with corporate styles :)
    marketing.xsl         (: imports base, overrides with marketing styles :)
  transforms/
    invoice.xsl           (: includes corporate theme + invoice-specific logic :)
    report.xsl            (: includes corporate theme + report-specific logic :)
    email-template.xsl    (: includes marketing theme + email layout :)
<!-- transforms/invoice.xsl -->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <!-- Import the corporate theme (which imports base) -->
  <xsl:import href="../themes/corporate.xsl"/>
  <!-- Include invoice-specific utilities at same precedence -->
  <xsl:include href="../base/utilities.xsl"/>
  <!-- Invoice-specific templates (highest precedence) -->
  <xsl:template match="invoice">
    <!-- ... -->
  </xsl:template>
</xsl:stylesheet>

Import vs Include — when to use each:

Aspect

xsl:import

xsl:include

Precedence

Lower than importing stylesheet

Same as including stylesheet

Can override

Yes — importer's templates win

No — name conflicts are errors

Use case

Base/default templates you want to customize

Shared utilities you want at the same level

C# parallel

Inheriting from a base class

partial classes or global using static


#Practical Patterns

#Custom Output Formatting Functions

Build a library of formatting functions for consistent output:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                 xmlns:fmt="http://example.com/format"
                 version="3.0">
  <xsl:function name="fmt:date-human" as="xs:string">
    <xsl:param name="date" as="xs:date"/>
    <xsl:value-of select="format-date($date, '[MNn] [D], [Y]')"/>
  </xsl:function>
  <xsl:function name="fmt:date-iso" as="xs:string">
    <xsl:param name="date" as="xs:date"/>
    <xsl:value-of select="format-date($date, '[Y]-[M01]-[D01]')"/>
  </xsl:function>
  <xsl:function name="fmt:bytes" as="xs:string">
    <xsl:param name="bytes" as="xs:integer"/>
    <xsl:choose>
      <xsl:when test="$bytes ge 1073741824">
        <xsl:value-of select="concat(format-number($bytes div 1073741824, '#,##0.0'), ' GB')"/>
      </xsl:when>
      <xsl:when test="$bytes ge 1048576">
        <xsl:value-of select="concat(format-number($bytes div 1048576, '#,##0.0'), ' MB')"/>
      </xsl:when>
      <xsl:when test="$bytes ge 1024">
        <xsl:value-of select="concat(format-number($bytes div 1024, '#,##0.0'), ' KB')"/>
      </xsl:when>
      <xsl:otherwise>
        <xsl:value-of select="concat($bytes, ' bytes')"/>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:function>
  <xsl:function name="fmt:pluralize" as="xs:string">
    <xsl:param name="count" as="xs:integer"/>
    <xsl:param name="singular" as="xs:string"/>
    <xsl:param name="plural" as="xs:string"/>
    <xsl:value-of select="concat($count, ' ', if ($count = 1) then $singular else $plural)"/>
  </xsl:function>
</xsl:stylesheet>

#Conditional Processing Based on Environment

Use parameters to vary behavior between development and production:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="3.0">
  <xsl:param name="environment" as="xs:string" select="'development'"/>
  <xsl:param name="debug" as="xs:boolean" select="false()"/>
  <xsl:param name="cdn-url" as="xs:string" select="''"/>
  <xsl:template match="/">
    <html>
      <head>
        <!-- Use CDN in production, local files in development -->
        <xsl:choose>
          <xsl:when test="$environment = 'production'">
            <link rel="stylesheet" href="{$cdn-url}/styles.min.css"/>
            <script src="{$cdn-url}/app.min.js"/>
          </xsl:when>
          <xsl:otherwise>
            <link rel="stylesheet" href="/css/styles.css"/>
            <script src="/js/app.js"/>
          </xsl:otherwise>
        </xsl:choose>
      </head>
      <body>
        <xsl:apply-templates/>
        <!-- Debug panel only in development -->
        <xsl:if test="$debug">
          <div id="debug-panel" style="background: #ffffcc; padding: 1em;">
            <h3>Debug Info</h3>
            <p>Environment: <xsl:value-of select="$environment"/></p>
            <p>Node count: <xsl:value-of select="count(//*)"/></p>
            <p>Transform time: <xsl:value-of select="current-dateTime()"/></p>
          </div>
        </xsl:if>
      </body>
    </html>
  </xsl:template>
</xsl:stylesheet>
// C# — set environment-specific parameters
var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, baseUri);
if (env.IsProduction())
{
    transformer.SetParameter("environment", "production");
    transformer.SetParameter("debug", false);
    transformer.SetParameter("cdn-url", "https://cdn.example.com/v3");
}
else
{
    transformer.SetParameter("environment", "development");
    transformer.SetParameter("debug", true);
}
var html = await transformer.TransformAsync(inputXml);

#Multi-Pass Transforms

Feed the output of one transform into another:

var transformer = new XsltTransformer();
// Pass 1: Normalize the data
await transformer.LoadStylesheetAsync(normalizeStylesheet, baseUri);
var normalized = await transformer.TransformAsync(rawInput);
// Pass 2: Enrich with computed fields
await transformer.LoadStylesheetAsync(enrichStylesheet, baseUri);
transformer.SetParameter("enrichment-date", DateTime.Now.ToString("yyyy-MM-dd"));
var enriched = await transformer.TransformAsync(normalized);
// Pass 3: Render final output
await transformer.LoadStylesheetAsync(renderStylesheet, baseUri);
transformer.SetParameter("output-format", "html");
var finalOutput = await transformer.TransformAsync(enriched);
await File.WriteAllTextAsync("output/report.html", finalOutput);

#Data Access via Extension Functions

Query a database from within an XSLT transform by registering data access functions:

transformer.RegisterFunction(
    "http://example.com/db",
    "lookup-customer",
    (string customerId) =>
    {
        using var conn = new SqlConnection(connectionString);
        conn.Open();
        using var cmd = new SqlCommand("SELECT Name, Email FROM Customers WHERE Id = @id", conn);
        cmd.Parameters.AddWithValue("@id", customerId);
        using var reader = cmd.ExecuteReader();
        if (reader.Read())
        {
            return $"<customer name='{SecurityElement.Escape(reader.GetString(0))}' " +
                   $"email='{SecurityElement.Escape(reader.GetString(1))}'/>";
        }
        return "<customer/>";
    }
);
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
                 xmlns:db="http://example.com/db"
                 version="3.0">
  <xsl:template match="order">
    <xsl:variable name="customer"
                  select="parse-xml-fragment(db:lookup-customer(@customer-id))/*"/>
    <div class="order">
      <h2>Order #<xsl:value-of select="@id"/></h2>
      <p>Customer: <xsl:value-of select="$customer/@name"/></p>
      <p>Email: <xsl:value-of select="$customer/@email"/></p>
      <xsl:apply-templates select="items"/>
    </div>
  </xsl:template>
</xsl:stylesheet>

#Integration Examples

#ASP.NET Core — XSLT Transforms as Middleware

Use XSLT to transform XML API responses into HTML:

public class XsltMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _stylesheetPath;
    public XsltMiddleware(RequestDelegate next, string stylesheetPath)
    {
        _next = next;
        _stylesheetPath = stylesheetPath;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        // Only transform if the client accepts HTML and the response is XML
        if (!context.Request.Headers.Accept.ToString().Contains("text/html"))
        {
            await _next(context);
            return;
        }
        // Capture the response
        var originalBody = context.Response.Body;
        using var buffer = new MemoryStream();
        context.Response.Body = buffer;
        await _next(context);
        buffer.Seek(0, SeekOrigin.Begin);
        var xmlContent = await new StreamReader(buffer).ReadToEndAsync();
        if (context.Response.ContentType?.Contains("xml") == true)
        {
            // Transform XML to HTML
            var transformer = new XsltTransformer();
            await transformer.LoadStylesheetAsync(
                await File.ReadAllTextAsync(_stylesheetPath),
                new Uri(_stylesheetPath).AbsoluteUri);
            transformer.SetParameter("request-path", context.Request.Path.Value ?? "/");
            transformer.SetParameter("timestamp", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            var html = await transformer.TransformAsync(xmlContent);
            context.Response.Body = originalBody;
            context.Response.ContentType = "text/html; charset=utf-8";
            await context.Response.WriteAsync(html);
        }
        else
        {
            buffer.Seek(0, SeekOrigin.Begin);
            context.Response.Body = originalBody;
            await buffer.CopyToAsync(originalBody);
        }
    }
}
// Registration in Program.cs
app.UseMiddleware<XsltMiddleware>("stylesheets/api-to-html.xsl");

#CLI Tools — Batch Transforms

Build a command-line tool that applies XSLT transforms, similar to PhoenixmlDb's xslt CLI:

public class XsltCli
{
    public static async Task<int> Main(string[] args)
    {
        string stylesheetPath = args[0];
        string inputPath = args[1];
        string outputPath = args.Length > 2 ? args[2] : "-"; // "-" means stdout
        var transformer = new XsltTransformer();
        var stylesheet = await File.ReadAllTextAsync(stylesheetPath);
        await transformer.LoadStylesheetAsync(stylesheet, new Uri(stylesheetPath).AbsoluteUri);
        // Pass environment variables as parameters
        transformer.SetParameter("build-date", DateTime.Now.ToString("yyyy-MM-dd"));
        transformer.SetParameter("hostname", Environment.MachineName);
        // Set any NAME=VALUE arguments as parameters
        foreach (var arg in args.Skip(3))
        {
            var parts = arg.Split('=', 2);
            if (parts.Length == 2)
                transformer.SetParameter(parts[0], parts[1]);
        }
        var input = await File.ReadAllTextAsync(inputPath);
        var result = await transformer.TransformAsync(input);
        if (outputPath == "-")
            Console.Write(result);
        else
            await File.WriteAllTextAsync(outputPath, result);
        // Handle multi-file output
        foreach (var (href, content) in transformer.SecondaryResultDocuments)
        {
            string dir = Path.GetDirectoryName(outputPath) ?? ".";
            string filePath = Path.Combine(dir, href);
            Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
            await File.WriteAllTextAsync(filePath, content);
            Console.Error.WriteLine($"  Created: {filePath}");
        }
        return 0;
    }
}

Usage:

# Basic transform
dotnet run -- stylesheet.xsl input.xml output.html
# With parameters
dotnet run -- invoice.xsl order.xml invoice.html customer-name="Acme Corp" currency=EUR
# Multi-file output (catalog with per-product pages)
dotnet run -- catalog.xsl products.xml output/index.html
#   Created: output/product-001.html
#   Created: output/product-002.html
#   Created: output/product-003.html

#CI/CD — Generating Documentation and Reports

Use XSLT in build pipelines to generate documentation, configuration files, or test reports:

// Build step: generate API documentation from XML doc comments
public class DocGenerationTask
{
    public async Task GenerateApiDocsAsync(string projectDir, string outputDir)
    {
        // 1. Collect all XML doc comment files
        var xmlDocs = Directory.GetFiles(projectDir, "*.xml", SearchOption.AllDirectories)
            .Where(f => Path.GetDirectoryName(f)?.Contains("bin") == true);
        // 2. Merge into a single XML document
        var merged = new XElement("documentation",
            xmlDocs.Select(f => XElement.Load(f)));
        // 3. Transform to HTML documentation
        var transformer = new XsltTransformer();
        await transformer.LoadStylesheetAsync(
            await File.ReadAllTextAsync("build/api-docs.xsl"),
            "file:///build/api-docs.xsl");
        transformer.SetParameter("project-name", "MyProject");
        transformer.SetParameter("version", GetVersionFromCsproj());
        transformer.SetParameter("build-date", DateTime.Now.ToString("yyyy-MM-dd"));
        var html = await transformer.TransformAsync(merged.ToString());
        Directory.CreateDirectory(outputDir);
        await File.WriteAllTextAsync(Path.Combine(outputDir, "api.html"), html);
        // Write per-namespace pages from secondary results
        foreach (var (href, content) in transformer.SecondaryResultDocuments)
        {
            await File.WriteAllTextAsync(Path.Combine(outputDir, href), content);
        }
    }
}
# GitHub Actions workflow step
- name: Generate API Documentation
  run: dotnet run --project tools/DocGen -- src/ docs/api/

The XSLT stylesheet handles all the formatting logic — converting raw XML doc comments into navigable, styled HTML pages — while the .NET code handles file I/O, parameter passing, and build integration.