#Multiple Output Documents
A single XSLT transformation can produce multiple output files. This is one of XSLT's most powerful capabilities — from a single source document and stylesheet, you can generate an index page, individual detail pages, a sitemap, and a JSON API response in one pass.
#Contents
#xsl:output
Controls how the result tree is serialized into bytes. This is a top-level declaration (child of xsl:stylesheet) that configures the serializer.
#Basic Usage
<xsl:output method="html" indent="yes" encoding="utf-8"
html-version="5" include-content-type="no"/>#Serialization Parameters
|
Attribute |
Values |
Description |
|---|---|---|
|
|
|
Output format |
|
|
|
Pretty-print the output |
|
|
|
Character encoding |
|
|
|
Suppress |
|
|
|
Standalone declaration |
|
|
URI |
DOCTYPE system identifier |
|
|
public ID |
DOCTYPE public identifier |
|
|
QNames |
Elements whose text content is wrapped in CDATA |
|
|
MIME type |
Content-Type (e.g., |
|
|
|
HTML version (XSLT 3.0) |
|
|
|
Include |
|
|
QNames |
Elements where indentation should be suppressed |
|
|
string |
Separator between items in |
#Output Methods
XML — the default. Produces well-formed XML with proper escaping:
<xsl:output method="xml" indent="yes" encoding="utf-8"/>HTML — produces HTML-compatible output. Self-closing tags are not used for void elements, boolean attributes are minimized, and entity references follow HTML rules:
<xsl:output method="html" html-version="5" indent="yes"/>Key differences from XML method:
-
<br/>becomes<br>(no self-closing) -
<script></script>is preserved (not collapsed to<script/>) -
checked="checked"may be minimized tochecked -
No XML declaration
Text — produces plain text. All markup is stripped; only text content appears:
<xsl:output method="text" encoding="utf-8"/>Useful for generating CSV, plain text reports, source code, configuration files, or any non-XML format.
JSON — introduced in XSLT 3.0. Serializes XDM maps and arrays as JSON:
<xsl:output method="json" indent="yes"/>The result tree must be a map or array (built with xsl:map/xsl:array or the XPath map{} / [] constructors).
Adaptive — chooses the method based on the result. If the result is a map or array, it uses JSON. If it is a document with an html root element, it uses HTML. Otherwise, it uses XML:
<xsl:output method="adaptive" indent="yes"/>
C# parallel: xsl:output is like configuring a JsonSerializerOptions or XmlWriterSettings object — you are not changing what data is produced, just how it is written to text.
var settings = new XmlWriterSettings
{
Indent = true,
Encoding = Encoding.UTF8,
OmitXmlDeclaration = true
};#xsl:result-document
Creates a secondary output document. The primary result goes to the main output destination; each xsl:result-document writes to a separate destination identified by an href.
#Basic Usage
<xsl:template match="/">
<!-- Primary output: index page -->
<html>
<body>
<h1>Product Catalog</h1>
<ul>
<xsl:for-each select="catalog/product">
<li><a href="products/{@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="catalog/product">
<xsl:result-document href="products/{@id}.html">
<html>
<body>
<h1><xsl:value-of select="name"/></h1>
<p>Price: $<xsl:value-of select="format-number(price, '#,##0.00')"/></p>
<p>Category: <xsl:value-of select="@category"/></p>
<a href="../index.html">Back to catalog</a>
</body>
</html>
</xsl:result-document>
</xsl:for-each>
</xsl:template>#The href Attribute
href is an AVT that specifies the destination URI. It is resolved relative to the base output URI:
<!-- Static path -->
<xsl:result-document href="sitemap.xml">...</xsl:result-document>
<!-- Dynamic path from data -->
<xsl:result-document href="output/{@category}/{@id}.html">...</xsl:result-document>
<!-- Computed path -->
<xsl:result-document href="{translate(lower-case(name), ' ', '-')}.html">...</xsl:result-document>If href is omitted, the result document replaces the primary output. This is useful when a template needs to override the serialization settings for the main output.
#The format Attribute
References a named xsl:output declaration to control serialization for this specific result document:
<xsl:output name="html-output" method="html" html-version="5" indent="yes"/>
<xsl:output name="json-output" method="json" indent="yes"/>
<xsl:output name="xml-output" method="xml" indent="yes" encoding="utf-8"/>
<xsl:template match="/">
<!-- Primary output uses the unnamed xsl:output -->
<html>...</html>
<!-- JSON output -->
<xsl:result-document href="api/products.json" format="json-output">
<xsl:map>
<xsl:map-entry key="'products'">
<xsl:array>
<xsl:for-each select="catalog/product">
<xsl:map>
<xsl:map-entry key="'id'" select="string(@id)"/>
<xsl:map-entry key="'name'" select="string(name)"/>
<xsl:map-entry key="'price'" select="number(price)"/>
</xsl:map>
</xsl:for-each>
</xsl:array>
</xsl:map-entry>
</xsl:map>
</xsl:result-document>
<!-- XML sitemap -->
<xsl:result-document href="sitemap.xml" format="xml-output">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<xsl:for-each select="catalog/product">
<url>
<loc>https://example.com/products/<xsl:value-of select="@id"/>.html</loc>
<lastmod><xsl:value-of select="format-date(current-date(), '[Y]-[M01]-[D01]')"/></lastmod>
</url>
</xsl:for-each>
</urlset>
</xsl:result-document>
</xsl:template>#Inline Serialization Overrides
Instead of referencing a named output, you can override individual serialization parameters directly on xsl:result-document:
<xsl:result-document href="data.xml" method="xml" indent="yes" encoding="utf-16">
<export>...</export>
</xsl:result-document>These override the settings from the referenced (or default) xsl:output declaration.
#Validation
xsl:result-document supports validation attributes for schema-aware processing:
<xsl:result-document href="output.xml" validation="strict">
<!-- content is validated against the schema -->
</xsl:result-document>#Named Output Definitions
You can define multiple named output formats and reference them by name. The unnamed xsl:output is the default for the primary result.
<!-- Default output (primary result) -->
<xsl:output method="html" html-version="5" indent="yes"/>
<!-- Named alternatives -->
<xsl:output name="fragment" method="html" html-version="5" indent="yes"
omit-xml-declaration="yes"/>
<xsl:output name="csv" method="text" encoding="utf-8"/>
<xsl:output name="feed" method="xml" indent="yes" encoding="utf-8"
media-type="application/atom+xml"/>Multiple unnamed xsl:output declarations are merged — their attributes are combined, with later declarations winning on conflicts:
<!-- These are merged into one effective output declaration -->
<xsl:output method="html"/>
<xsl:output indent="yes"/>
<xsl:output encoding="utf-8"/>
<!-- Effective: method="html" indent="yes" encoding="utf-8" -->This is useful when importing stylesheets — the importing stylesheet can override specific serialization parameters without redeclaring everything.
#Common Patterns
#One Page Per Item
The most common multi-output pattern — generate an index page and individual detail pages:
<xsl:template match="catalog">
<!-- Index page (primary output) -->
<html>
<head><title>Product Catalog</title></head>
<body>
<h1>All Products</h1>
<table>
<thead>
<tr><th>Product</th><th>Category</th><th>Price</th></tr>
</thead>
<tbody>
<xsl:for-each select="product">
<xsl:sort select="name"/>
<tr>
<td><a href="products/{@id}.html"><xsl:value-of select="name"/></a></td>
<td><xsl:value-of select="@category"/></td>
<td>$<xsl:value-of select="format-number(price, '#,##0.00')"/></td>
</tr>
</xsl:for-each>
</tbody>
</table>
</body>
</html>
<!-- Detail pages (secondary outputs) -->
<xsl:for-each select="product">
<xsl:result-document href="products/{@id}.html">
<html>
<head><title><xsl:value-of select="name"/></title></head>
<body>
<nav><a href="../index.html">Back to catalog</a></nav>
<h1><xsl:value-of select="name"/></h1>
<dl>
<dt>Category</dt><dd><xsl:value-of select="@category"/></dd>
<dt>Price</dt><dd>$<xsl:value-of select="format-number(price, '#,##0.00')"/></dd>
<dt>Description</dt><dd><xsl:value-of select="description"/></dd>
</dl>
</body>
</html>
</xsl:result-document>
</xsl:for-each>
</xsl:template>#Multi-Format Output
Generate the same data in multiple formats — HTML for humans, JSON for APIs, XML for machine interchange:
<xsl:output method="html" html-version="5" indent="yes"/>
<xsl:output name="json" method="json" indent="yes"/>
<xsl:output name="xml-export" method="xml" indent="yes"/>
<xsl:template match="catalog">
<!-- HTML (primary) -->
<html>
<body>
<xsl:apply-templates select="product"/>
</body>
</html>
<!-- JSON API -->
<xsl:result-document href="api/catalog.json" format="json">
<xsl:map>
<xsl:map-entry key="'products'">
<xsl:array>
<xsl:for-each select="product">
<xsl:map>
<xsl:map-entry key="'id'" select="string(@id)"/>
<xsl:map-entry key="'name'" select="string(name)"/>
<xsl:map-entry key="'category'" select="string(@category)"/>
<xsl:map-entry key="'price'" select="number(price)"/>
</xsl:map>
</xsl:for-each>
</xsl:array>
</xsl:map-entry>
</xsl:map>
</xsl:result-document>
<!-- XML export -->
<xsl:result-document href="export/catalog.xml" format="xml-export">
<catalog exported="{format-dateTime(current-dateTime(), '[Y]-[M01]-[D01]T[H01]:[m01]:[s01]')}">
<xsl:copy-of select="product"/>
</catalog>
</xsl:result-document>
</xsl:template>#Category-Based Directory Structure
<xsl:template match="catalog">
<xsl:for-each-group select="product" group-by="@category">
<!-- Category index page -->
<xsl:result-document href="{current-grouping-key()}/index.html">
<html>
<head><title><xsl:value-of select="current-grouping-key()"/></title></head>
<body>
<h1><xsl:value-of select="current-grouping-key()"/></h1>
<ul>
<xsl:for-each select="current-group()">
<li><a href="{@id}.html"><xsl:value-of select="name"/></a></li>
</xsl:for-each>
</ul>
</body>
</html>
</xsl:result-document>
<!-- Individual product pages within category folder -->
<xsl:for-each select="current-group()">
<xsl:result-document href="{@category}/{@id}.html">
<html>
<head><title><xsl:value-of select="name"/></title></head>
<body>
<xsl:apply-templates select="."/>
</body>
</html>
</xsl:result-document>
</xsl:for-each>
</xsl:for-each-group>
</xsl:template>#Generating CSV with Text Method
<xsl:output name="csv" method="text" encoding="utf-8"/>
<xsl:template match="catalog">
<!-- HTML primary output -->
<html>...</html>
<!-- CSV export -->
<xsl:result-document href="export/products.csv" format="csv">
<xsl:text>id,name,category,price </xsl:text>
<xsl:for-each select="product">
<xsl:value-of select="@id"/>
<xsl:text>,</xsl:text>
<!-- Quote fields that might contain commas -->
<xsl:value-of select="concat('"', replace(name, '"', '""'), '"')"/>
<xsl:text>,</xsl:text>
<xsl:value-of select="@category"/>
<xsl:text>,</xsl:text>
<xsl:value-of select="price"/>
<xsl:text> </xsl:text>
</xsl:for-each>
</xsl:result-document>
</xsl:template>#Secondary Output in the PhoenixmlDb API
When running transformations through the PhoenixmlDb .NET API, secondary output documents are collected in a dictionary rather than written directly to the file system. This gives you programmatic access to all generated outputs.
using PhoenixmlDb.Xslt;
var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, new Uri("catalog.xslt"));
var result = await transformer.TransformAsync(sourceXml);
// Primary result
string indexHtml = result.PrimaryResult;
File.WriteAllText("output/index.html", indexHtml);
// Secondary result documents (from xsl:result-document)
foreach (var (href, content) in result.SecondaryResultDocuments)
{
var outputPath = Path.Combine("output", href);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, content);
}This design lets you:
-
Write to any storage — local files, blob storage, database, HTTP endpoints
-
Post-process generated documents before writing them
-
Test transformations by inspecting secondary outputs in memory
-
Filter which documents to actually write
C# parallel: xsl:result-document is conceptually like calling File.WriteAllText(path, content) for each output file, except the XSLT engine collects them and hands them to you. The calling code decides where they actually go.
#Controlling the Base URI
The href values in xsl:result-document are resolved relative to the base output URI. In the PhoenixmlDb API, you can set this when calling the transform:
var options = new TransformOptions
{
BaseOutputUri = new Uri("file:///output/site/")
};
var result = await transformer.TransformAsync(sourceXml, options);
// result.SecondaryResultDocuments keys are now relative to /output/site/
// e.g., "products/WP-001.html", "api/catalog.json"