Instructions Reference
Multiple Output Documents
xsl:result-document and xsl:output — generating multiple files and controlling serialization
#Multiple Output Documents
A single XSLT transformation can produce multiple output files. From one source document and one stylesheet, a single pass can generate an index page, individual detail pages, a sitemap, and a JSON API response.
For C# developers:
xsl:outputconfigures serialization the way aJsonSerializerOptionsorXmlWriterSettingsobject does. It does not change what data is produced, only how the serializer writes it to text.xsl:result-documentcorresponds to callingFile.WriteAllText(path, content)once for each output file, except the XSLT engine collects the results and hands them to your code. Your code decides where they go.
This C# code configures serialization settings equivalent to xsl:output:
var settings = new XmlWriterSettings
{
Indent = true,
Encoding = Encoding.UTF8,
OmitXmlDeclaration = true
};
#Contents
#xsl:output
xsl:output controls how the result tree is serialized into bytes. It is a top-level declaration, a 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 elements are not used for void elements. Boolean attributes are minimized. 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. The serializer strips all markup. Only the 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"/>
#xsl:result-document
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
The format attribute references a named xsl:output declaration. It controls 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"/>
The processor merges multiple unnamed xsl:output declarations. It combines their attributes, and a later declaration wins 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 you run a transformation through the PhoenixmlDb .NET API, the API collects secondary output documents in a dictionary. It does not write them directly to the file system. This gives you programmatic access to every generated output.
using PhoenixmlDb.Xslt;
var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, new Uri("catalog.xslt"));
// Primary result
string indexHtml = await transformer.TransformAsync(sourceXml);
File.WriteAllText("output/index.html", indexHtml);
// Secondary result documents (from xsl:result-document), read from the
// transformer AFTER TransformAsync returns — it repopulates this
// dictionary on every call.
foreach (var (href, content) in transformer.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, a database, or HTTP endpoints.
-
Post-process — modify generated documents before writing them.
-
Test — inspect secondary outputs in memory during a transformation.
-
Filter — choose which documents to write.
#Controlling the Base URI
The processor resolves href values in xsl:result-document relative to the base output URI. XsltTransformer exposes no C# property for this. There is no TransformOptions type and no base-output-uri setter on the transformer. The base output URI is instead controlled from XQuery's fn:transform(), via the "base-output-uri" entry in its options map, or implicitly by the stylesheet and its serialization. From C#, the keys in SecondaryResultDocuments are the literal href values. Resolve or rewrite them yourself if you need a different output root:
var transformer = new XsltTransformer();
await transformer.LoadStylesheetAsync(stylesheet, new Uri("catalog.xslt"));
_ = await transformer.TransformAsync(sourceXml);
// Rewrite hrefs onto a chosen output root in application code
var outputRoot = new Uri("file:///output/site/");
foreach (var (href, content) in transformer.SecondaryResultDocuments)
{
var outputPath = new Uri(outputRoot, href).LocalPath;
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, content);
}
// e.g., "products/WP-001.html", "api/catalog.json"