1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-13 08:43:16 +00:00

Competitive batch: Saxon 12.9, 2.0 tracing, saved fiddles, multi-engine story

From the August competitive analysis, the items that close real gaps:

- Saxon HE 12.5 -> 12.9 (the closest competitor already ships 12.9). The
  trace/hotspot instrumentation is reflection-based, so it was verified
  against the new jar: variables and execution counts come back intact.

- Tracing now works for XSLT 2.0. The trace infrastructure needs Saxon 10+
  APIs and cannot load inside the Saxon 9 daemon, so traced 2.0 requests run
  on Saxon 12 in backwards-compatible mode instead of silently returning an
  empty trace — a real trace with a small, labeled semantic difference.
  Untraced 2.0 runs still use Saxon 9. XSLT 1.0 now says outright that XSLTC
  has no tracing hook rather than pretending.

- Saved fiddles: POST /fiddle stores the workspace under a 7-char id with an
  append-only revision history; ?f=ID loads it (optionally &r=N). Available
  whenever DATABASE_URL is configured — unlike /history this is
  unauthenticated, because a fiddle is a thing you share with someone who has
  no account. An unreachable database degrades to "fiddles off" instead of
  crashlooping: the chart has shipped a placeholder databaseUrl for a long
  time, and opening it eagerly would have taken production down.

- The home page now tells the truth about engines: 1.0 on XSLTC, 2.0 on
  Saxon 9, 3.0 on Saxon 12.9 — three real processors was already the
  architecture, it just was never marketed.

- Four posts aimed at where the demand data points: the FreeFormatter
  shutdown audience, Chrome's XSLT removal (v158, Nov 2026), Peppol/EN-16931
  Schematron validation and ISO 20022 camt transformations. Every example was
  executed against the real backend and the published output matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
This commit is contained in:
2026-08-14 11:39:00 +00:00
parent 35cdc630fb
commit 40b81728d6
18 changed files with 889 additions and 33 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ COPY ext/ .
# Download JARs for Saxon 12 (XSLT 3.0)
RUN mkdir -p /tmp/saxon12 && \
curl -L -o /tmp/saxon12/saxon-he.jar \
https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.9/Saxon-HE-12.9.jar && \
curl -L -o /tmp/saxon12/gson.jar \
https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
@@ -70,7 +70,7 @@ RUN chmod +x start.sh
# Saxon 12 (XSLT 3.0) — port 8081
RUN mkdir -p /opt/saxon12 && \
curl -L -o /opt/saxon12/saxon-he.jar \
https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.9/Saxon-HE-12.9.jar && \
curl -L -o /opt/saxon12/xmlresolver.jar \
https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar && \
curl -L -o /opt/saxon12/gson.jar \
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"crypto/rand"
"errors"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Saved fiddles: a workspace payload stored under a short id, with an
// append-only revision history. Available whenever DATABASE_URL is set —
// unlike /history this does not require Pro mode or authentication, because a
// fiddle is by definition a thing you share with someone who has no account.
type Fiddle struct {
ID string `json:"id" gorm:"primaryKey;size:12"`
Revision int `json:"revision" gorm:"primaryKey;autoIncrement:false"`
Payload string `json:"payload"`
CreatedAt time.Time `json:"created_at"`
}
const (
fiddleIDLen = 7
fiddlePayloadMax = 200_000 // bytes; a workspace is KBs, this is sabotage headroom
fiddleMaxRevs = 200 // append-only cap per fiddle
)
// Base58: no 0/O/I/l lookalikes, so ids survive being read aloud or retyped.
const fiddleAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
func newFiddleID() (string, error) {
buf := make([]byte, fiddleIDLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
id := make([]byte, fiddleIDLen)
for i, b := range buf {
id[i] = fiddleAlphabet[int(b)%len(fiddleAlphabet)]
}
return string(id), nil
}
func validFiddleID(id string) bool {
if len(id) != fiddleIDLen {
return false
}
for _, c := range id {
ok := false
for _, a := range fiddleAlphabet {
if c == a {
ok = true
break
}
}
if !ok {
return false
}
}
return true
}
func registerFiddleRoutes(r *gin.Engine, db *gorm.DB) {
if db == nil {
// Feature off (lightweight compose has no database): answer clearly
// instead of 404, so the frontend can hide the Save button.
off := func(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "fiddle storage is not configured"})
}
r.POST("/fiddle", off)
r.GET("/fiddle/:id", off)
r.GET("/fiddle/:id/:rev", off)
return
}
if err := db.AutoMigrate(&Fiddle{}); err != nil {
log.Fatalf("fiddle migrate: %v", err)
}
r.POST("/fiddle", func(c *gin.Context) {
var req struct {
ID string `json:"id"`
Payload string `json:"payload"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Payload == "" || len(req.Payload) > fiddlePayloadMax {
c.JSON(http.StatusBadRequest, gin.H{"error": "payload missing or too large"})
return
}
revision := 1
id := req.ID
if id != "" {
// New revision of an existing fiddle.
if !validFiddleID(id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fiddle id"})
return
}
var last Fiddle
err := db.Where("id = ?", id).Order("revision desc").First(&last).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "fiddle not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
if last.Revision >= fiddleMaxRevs {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "revision limit reached"})
return
}
revision = last.Revision + 1
} else {
// Fresh fiddle: collisions are astronomically unlikely (58^7) but
// retrying costs nothing.
for attempt := 0; ; attempt++ {
candidate, err := newFiddleID()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "id generation failed"})
return
}
var count int64
db.Model(&Fiddle{}).Where("id = ?", candidate).Count(&count)
if count == 0 {
id = candidate
break
}
if attempt >= 4 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "id space exhausted"})
return
}
}
}
rec := Fiddle{ID: id, Revision: revision, Payload: req.Payload, CreatedAt: time.Now()}
if err := db.Create(&rec).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "revision": revision})
})
load := func(c *gin.Context, id string, revision int) {
if !validFiddleID(id) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fiddle id"})
return
}
q := db.Where("id = ?", id)
if revision > 0 {
q = q.Where("revision = ?", revision)
}
var rec Fiddle
if err := q.Order("revision desc").First(&rec).Error; errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "fiddle not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
var total int64
db.Model(&Fiddle{}).Where("id = ?", id).Count(&total)
c.JSON(http.StatusOK, gin.H{
"id": rec.ID,
"revision": rec.Revision,
"revisions": total,
"payload": rec.Payload,
})
}
r.GET("/fiddle/:id", func(c *gin.Context) { load(c, c.Param("id"), 0) })
r.GET("/fiddle/:id/:rev", func(c *gin.Context) {
rev := 0
if _, err := parseRev(c.Param("rev"), &rev); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid revision"})
return
}
load(c, c.Param("id"), rev)
})
}
func parseRev(s string, out *int) (int, error) {
n := 0
if s == "" {
return 0, errors.New("empty")
}
for _, c := range s {
if c < '0' || c > '9' {
return 0, errors.New("not a number")
}
n = n*10 + int(c-'0')
if n > fiddleMaxRevs {
return 0, errors.New("out of range")
}
}
*out = n
return n, nil
}
+44
View File
@@ -0,0 +1,44 @@
package main
import "testing"
func TestNewFiddleIDShapeAndUniqueness(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 500; i++ {
id, err := newFiddleID()
if err != nil {
t.Fatalf("id generation failed: %v", err)
}
if !validFiddleID(id) {
t.Fatalf("generated id %q does not validate", id)
}
if seen[id] {
t.Fatalf("duplicate id in 500 draws: %q", id)
}
seen[id] = true
}
}
func TestValidFiddleID(t *testing.T) {
for _, bad := range []string{"", "short", "toolongid", "abc-def", "abc0def", "abcOdef", "abcIdef", "abcldef", "../../x"} {
if validFiddleID(bad) {
t.Errorf("expected %q to be rejected", bad)
}
}
id, _ := newFiddleID()
if !validFiddleID(id) {
t.Errorf("expected generated id %q to validate", id)
}
}
func TestParseRev(t *testing.T) {
var n int
if _, err := parseRev("7", &n); err != nil || n != 7 {
t.Fatalf("parseRev(7) = %d, %v", n, err)
}
for _, bad := range []string{"", "abc", "-1", "1e3", "9999"} {
if _, err := parseRev(bad, &n); err == nil {
t.Errorf("expected %q to be rejected", bad)
}
}
}
+37 -3
View File
@@ -46,6 +46,7 @@ type TransformResponse struct {
Result string `json:"result"`
DurationMs int64 `json:"duration_ms"`
Trace []TraceEntry `json:"trace,omitempty"`
TraceEngine string `json:"trace_engine,omitempty"`
Hotspots []Hotspot `json:"hotspots,omitempty"`
TraceText string `json:"trace_text,omitempty"`
SecondaryResults map[string]string `json:"secondary_results,omitempty"`
@@ -334,6 +335,21 @@ func main() {
db *gorm.DB
)
// The database used to be a Pro-only concern; saved fiddles need it too,
// and unlike /history they are unauthenticated. Open it whenever a URL is
// configured, and keep Firebase/history strictly behind goPro.
if config.DatabaseURL != "" && os.Getenv("DISABLE_DATABASE") != "true" {
var err error
db, err = gorm.Open(postgres.Open(config.DatabaseURL), &gorm.Config{})
if err != nil {
// Not fatal outside Pro mode: the chart has shipped a placeholder
// databaseUrl for a long time, so an unreachable database must
// degrade to "fiddles off", not crashloop the whole backend.
log.Printf("db connect failed, fiddle storage disabled: %v", err)
db = nil
}
}
if goPro {
ctx := context.Background()
var fbOpt option.ClientOption
@@ -349,9 +365,8 @@ func main() {
log.Fatalf("auth client: %v", err)
}
db, err = gorm.Open(postgres.Open(config.DatabaseURL), &gorm.Config{})
if err != nil {
log.Fatalf("db connect: %v", err)
if db == nil {
log.Fatalf("pro mode requires DATABASE_URL")
}
if err := db.AutoMigrate(&Transformation{}); err != nil {
log.Fatalf("auto migrate: %v", err)
@@ -368,6 +383,8 @@ func main() {
r.Use(metricsMiddleware())
r.Use(corsMiddleware())
registerFiddleRoutes(r, db)
r.POST("/transform", func(c *gin.Context) {
var req TransformRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -428,11 +445,27 @@ func main() {
}
daemonPort := "8081" // Saxon 12 — XSLT 3.0 (default)
traceEngine := ""
switch req.Version {
case "1.0":
daemonPort = "8082" // XSLTC (JDK) — true XSLT 1.0
if req.Trace {
// XSLTC has no TraceListener hook; be explicit instead of
// silently returning an empty trace (which is what happened
// for every non-3.0 version until now).
traceEngine = "unavailable"
}
case "2.0":
daemonPort = "8083" // Saxon 9 — true XSLT 2.0
if req.Trace {
// The trace instrumentation lives in Runner, which needs
// Saxon 10+ APIs and cannot load inside the Saxon 9 daemon.
// Rather than returning an empty trace, run the traced request
// on Saxon 12 in 2.0 backwards-compatible mode: a real trace
// with a small, documented semantic difference.
daemonPort = "8081"
traceEngine = "saxon12-compat"
}
}
start := time.Now()
@@ -544,6 +577,7 @@ func main() {
Result: daemonResp.Result,
DurationMs: duration,
Trace: traceEntries,
TraceEngine: traceEngine,
Hotspots: hotspots,
TraceText: traceText,
SecondaryResults: daemonResp.SecondaryResults,
+7 -7
View File
@@ -59,7 +59,7 @@
},
"featureList": [
"XSLT 1.0, 2.0 and 3.0 support",
"Saxon HE 12.5 processor",
"Three real engines: XSLTC (1.0), Saxon 9 (2.0), Saxon HE 12.9 (3.0)",
"Real-time XML transformation",
"XSLT validator with detailed error messages",
"Multiple XML inputs and parameters",
@@ -89,7 +89,7 @@
"name": "Does this XSLT tester support XSLT 3.0?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. XSLT Playground is one of the very few free online tools that fully supports XSLT 3.0 via Saxon HE 12.5. You can use maps, arrays, xsl:merge, xsl:on-empty, higher-order functions, and all other XSLT 3.0 features."
"text": "Yes. XSLT Playground is one of the very few free online tools that fully supports XSLT 3.0 via Saxon HE 12.9. You can use maps, arrays, xsl:merge, xsl:on-empty, higher-order functions, and all other XSLT 3.0 features."
}
},
{
@@ -97,7 +97,7 @@
"name": "What XSLT processor does XSLT Playground use?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The backend uses Saxon HE 12.5, the open-source edition of Saxonica's Saxon processor — the industry standard for XSLT 2.0 and 3.0 processing, used in enterprise applications worldwide."
"text": "The backend uses Saxon HE 12.9, the open-source edition of Saxonica's Saxon processor — the industry standard for XSLT 2.0 and 3.0 processing, used in enterprise applications worldwide."
}
},
{
@@ -332,7 +332,7 @@
<p>
<strong>XSLT Playground</strong> is the most powerful free online XSLT tool available — an
editor, tester, viewer and validator in one. Unlike every other online XSLT tester, it supports
<strong>XSLT 1.0, XSLT 2.0, and XSLT 3.0</strong> via <strong>Saxon HE 12.5</strong> — the same
<strong>XSLT 1.0, XSLT 2.0, and XSLT 3.0</strong> via <strong>Saxon HE 12.9</strong> — the same
enterprise-grade processor used in production applications worldwide. Run any XSLT transformation
online and see the result instantly. No installation, no account, no limits.
</p>
@@ -385,7 +385,7 @@
<ul>
<li><strong>XSLT 3.0 support</strong> — maps, arrays, streaming, higher-order functions, JSON output. No other free online tool offers this.</li>
<li><strong>XSLT 2.0 support</strong> — grouping (<code>xsl:for-each-group</code>), regular expressions, multiple output documents, XPath 2.0.</li>
<li><strong>Real Saxon processor</strong> — Saxon HE 12.5 on the server, not a simplified browser implementation. Results match production exactly.</li>
<li><strong>Real processors, one per version</strong> — each XSLT version runs on the engine that actually defines it: XSLT 1.0 on the JDK's XSLTC (Xalan lineage), XSLT 2.0 on Saxon 9 (the last true 2.0 processor), and XSLT 3.0 on Saxon HE 12.9. Not a simplified browser implementation — results match production exactly, per version.</li>
<li><strong>Built-in XSLT validator &amp; viewer</strong> — validate stylesheets with exact line-number errors and view formatted output with a live HTML render preview.</li>
<li><strong>Multiple XML inputs</strong> — pass multiple documents as named parameters, just like in production pipelines.</li>
<li><strong>Execution trace</strong> — debug your stylesheet step by step, inspect variable values at runtime.</li>
@@ -396,10 +396,10 @@
<h2>XSLT Online — Frequently Asked Questions</h2>
<dl>
<dt>Does this XSLT tester support XSLT 3.0?</dt>
<dd>Yes. XSLT Playground is one of the very few free online tools that fully supports XSLT 3.0 via Saxon HE 12.5. You can use maps, arrays, <code>xsl:merge</code>, <code>xsl:on-empty</code>, higher-order functions, and all XSLT 3.0 features.</dd>
<dd>Yes. XSLT Playground is one of the very few free online tools that fully supports XSLT 3.0 via Saxon HE 12.9. You can use maps, arrays, <code>xsl:merge</code>, <code>xsl:on-empty</code>, higher-order functions, and all XSLT 3.0 features.</dd>
<dt>What XSLT processor does it use?</dt>
<dd>The backend uses <strong>Saxon HE 12.5</strong>, the open-source edition of Saxonica's Saxon processor — the industry standard for XSLT 2.0 and 3.0 processing, trusted by enterprises globally.</dd>
<dd>The backend uses <strong>Saxon HE 12.9</strong>, the open-source edition of Saxonica's Saxon processor — the industry standard for XSLT 2.0 and 3.0 processing, trusted by enterprises globally.</dd>
<dt>Can I transform XML to HTML online?</dt>
<dd>Yes. Write an XSLT stylesheet with <code>method="html"</code> and XSLT Playground shows a live rendered HTML preview alongside the source output.</dd>
+5 -5
View File
@@ -19,7 +19,7 @@
"@type": ["WebApplication", "SoftwareApplication"],
"name": "XML to JSON Converter (XSLT 3.0)",
"url": "https://xsltplayground.com/xml-to-json/",
"description": "Free online XML to JSON converter built on XSLT 3.0 and Saxon HE 12.5. Control the exact shape of the JSON output using maps, arrays, xml-to-json() and json-to-xml().",
"description": "Free online XML to JSON converter built on XSLT 3.0 and Saxon HE 12.9. Control the exact shape of the JSON output using maps, arrays, xml-to-json() and json-to-xml().",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Any",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
@@ -41,7 +41,7 @@
"name": "How do I convert XML to JSON with XSLT 3.0?",
"acceptedAnswer": {
"@type": "Answer",
"text": "There are two approaches. You can build an XPath 3.1 map or array and serialise it with xsl:output method='json', or you can build nodes in the XPath functions namespace (map, array, string, number, boolean, null) and pass them to the xml-to-json() function. Both run in XSLT Playground on Saxon HE 12.5."
"text": "There are two approaches. You can build an XPath 3.1 map or array and serialise it with xsl:output method='json', or you can build nodes in the XPath functions namespace (map, array, string, number, boolean, null) and pass them to the xml-to-json() function. Both run in XSLT Playground on Saxon HE 12.9."
}
},
{
@@ -214,7 +214,7 @@
</header>
<div class="hero">
<div class="badge">Powered by Saxon HE 12.5</div>
<div class="badge">Powered by Saxon HE 12.9</div>
<h1>XML to JSON Online</h1>
<p>Convert XML to JSON with XSLT 3.0 and decide the exact shape of the result — keys, nesting, arrays and types. Not a guess: a transformation you control.</p>
<a class="cta-btn" href="https://xsltplayground.com/?template=xml-to-json">Open the XML → JSON Converter →</a>
@@ -303,7 +303,7 @@
<h2>Example 1: Maps and Arrays with method="json"</h2>
<p>
The shortest path from XML to JSON. Build a map, let the serialiser do the rest. Every example on
this page runs on Saxon HE 12.5 — paste it into the
this page runs on Saxon HE 12.9 — paste it into the
<a href="https://xsltplayground.com/?template=xml-to-json">editor</a> to reproduce the output exactly.
</p>
<p><strong>XML input:</strong></p>
@@ -497,7 +497,7 @@
<h2>Frequently Asked Questions</h2>
<dl>
<dt>How do I convert XML to JSON with XSLT 3.0?</dt>
<dd>Two ways: build an XPath 3.1 <code>map{}</code> or <code>array{}</code> and serialise it with <code>xsl:output method="json"</code>, or build nodes in the XPath functions namespace and pass them to <code>xml-to-json()</code>. Both are shown above and both run on Saxon HE 12.5.</dd>
<dd>Two ways: build an XPath 3.1 <code>map{}</code> or <code>array{}</code> and serialise it with <code>xsl:output method="json"</code>, or build nodes in the XPath functions namespace and pass them to <code>xml-to-json()</code>. Both are shown above and both run on Saxon HE 12.9.</dd>
<dt>Why use XSLT instead of a generic XML to JSON converter?</dt>
<dd>Because there is no canonical XML-to-JSON mapping, a generic converter has to guess how attributes are named, whether a single repeated element is an array, and which strings are really numbers. XSLT 3.0 lets you state each decision explicitly, so the JSON matches the contract your consumer expects.</dd>
+5 -5
View File
@@ -19,7 +19,7 @@
"@type": ["WebApplication", "SoftwareApplication"],
"name": "XPath Tester Online",
"url": "https://xsltplayground.com/xpath-tester/",
"description": "Free online XPath tester and evaluator powered by Saxon HE 12.5. Evaluate XPath 1.0, 2.0 and 3.1 expressions against your own XML, with predicates, axes and the full function library.",
"description": "Free online XPath tester and evaluator powered by Saxon HE 12.9. Evaluate XPath 1.0, 2.0 and 3.1 expressions against your own XML, with predicates, axes and the full function library.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Any",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
@@ -49,7 +49,7 @@
"name": "Which XPath versions can I test?",
"acceptedAnswer": {
"@type": "Answer",
"text": "XPath 1.0, 2.0, 3.0 and 3.1. The XPath version is determined by the XSLT version you select: XSLT 1.0 gives XPath 1.0, XSLT 2.0 gives XPath 2.0, and XSLT 3.0 gives XPath 3.1 including maps, arrays and the arrow operator. Saxon HE 12.5 evaluates all of them."
"text": "XPath 1.0, 2.0, 3.0 and 3.1. The XPath version is determined by the XSLT version you select: XSLT 1.0 gives XPath 1.0, XSLT 2.0 gives XPath 2.0, and XSLT 3.0 gives XPath 3.1 including maps, arrays and the arrow operator. Saxon HE 12.9 evaluates all of them."
}
},
{
@@ -214,7 +214,7 @@
</header>
<div class="hero">
<div class="badge">Powered by Saxon HE 12.5</div>
<div class="badge">Powered by Saxon HE 12.9</div>
<h1>XPath Tester Online</h1>
<p>Evaluate XPath expressions against your own XML and see every match instantly. XPath 1.0, 2.0 and 3.1 on the real Saxon engine — completely free.</p>
<a class="cta-btn" href="https://xsltplayground.com/?template=xpath-tester">Open the XPath Tester →</a>
@@ -248,7 +248,7 @@
Most XPath bugs are not syntax errors — they are expressions that parse cleanly and quietly select
the wrong nodes, or nothing at all. That is why evaluating an expression against a real document,
on a real processor, is the fastest way to debug it. XSLT Playground evaluates your expression with
Saxon HE 12.5, so what you see here is exactly what your production pipeline will do.
Saxon HE 12.9, so what you see here is exactly what your production pipeline will do.
</p>
<h2>How to Test an XPath Expression Online</h2>
@@ -472,7 +472,7 @@
<dd>Open the <a href="https://xsltplayground.com/?template=xpath-tester">XPath tester template</a>, paste your XML into the input panel, and put your expression in the <code>expression</code> variable. The result panel lists every matching node with a total count.</dd>
<dt>Which XPath versions can I test?</dt>
<dd>XPath 1.0, 2.0, 3.0 and 3.1. The version follows the XSLT version you select, and Saxon HE 12.5 evaluates all of them — including maps, arrays and the arrow operator in XPath 3.1.</dd>
<dd>XPath 1.0, 2.0, 3.0 and 3.1. The version follows the XSLT version you select, and Saxon HE 12.9 evaluates all of them — including maps, arrays and the arrow operator in XPath 3.1.</dd>
<dt>Why does <code>//book[1]</code> return more than one node?</dt>
<dd>A positional predicate applies to each step, not to the whole result. <code>//book[1]</code> selects every book that is first among its own siblings. To take the first node of the entire result, parenthesise the path: <code>(//book)[1]</code>.</dd>
+3 -3
View File
@@ -49,7 +49,7 @@
"name": "Is there a free online XSLT 2.0 tester?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. XSLT Playground offers a completely free online XSLT 2.0 tester powered by Saxon HE 12.5. No installation or account required — test your XSLT 2.0 stylesheets directly in your browser."
"text": "Yes. XSLT Playground offers a completely free online XSLT 2.0 tester powered by Saxon HE 12.9. No installation or account required — test your XSLT 2.0 stylesheets directly in your browser."
}
},
{
@@ -180,7 +180,7 @@
</header>
<div class="hero">
<div class="badge">Powered by Saxon HE 12.5</div>
<div class="badge">Powered by Saxon HE 12.9</div>
<h1>XSLT 2.0 Online Tester</h1>
<p>Test and transform XML with XSLT 2.0 instantly in your browser. Full grouping, regular expressions, XPath 2.0 support — completely free.</p>
<a class="cta-btn" href="/?v=2.0">Open XSLT 2.0 Editor →</a>
@@ -264,7 +264,7 @@
<dd>XSLT 2.0 introduces <code>xsl:for-each-group</code> for grouping, regular expression functions, multiple output documents with <code>xsl:result-document</code>, XPath 2.0 with sequences and types, user-defined functions, and richer string handling.</dd>
<dt>Is there a free online XSLT 2.0 tester?</dt>
<dd>Yes. XSLT Playground offers a completely free online XSLT 2.0 tester powered by Saxon HE 12.5. No installation or account required.</dd>
<dd>Yes. XSLT Playground offers a completely free online XSLT 2.0 tester powered by Saxon HE 12.9. No installation or account required.</dd>
<dt>Can I test xsl:for-each-group online?</dt>
<dd>Yes. XSLT Playground fully supports <code>xsl:for-each-group</code> with <code>group-by</code>, <code>group-adjacent</code>, <code>group-starting-with</code>, and <code>group-ending-with</code>.</dd>
+5 -5
View File
@@ -19,7 +19,7 @@
"@type": ["WebApplication", "SoftwareApplication"],
"name": "XSLT 3.0 Online Tester",
"url": "https://xsltplayground.com/xslt-3-0/",
"description": "Free online XSLT 3.0 editor and tester powered by Saxon HE 12.5. The only free tool supporting XSLT 3.0 maps, arrays, streaming and higher-order functions.",
"description": "Free online XSLT 3.0 editor and tester powered by Saxon HE 12.9. The only free tool supporting XSLT 3.0 maps, arrays, streaming and higher-order functions.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Any",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
@@ -49,7 +49,7 @@
"name": "Is there a free online XSLT 3.0 tester?",
"acceptedAnswer": {
"@type": "Answer",
"text": "XSLT Playground is the only free online tool that fully supports XSLT 3.0 via Saxon HE 12.5. All other free online XSLT tools only support XSLT 1.0. No installation or account required."
"text": "XSLT Playground is the only free online tool that fully supports XSLT 3.0 via Saxon HE 12.9. All other free online XSLT tools only support XSLT 1.0. No installation or account required."
}
},
{
@@ -200,7 +200,7 @@
</header>
<div class="hero">
<div class="badge">Powered by Saxon HE 12.5</div>
<div class="badge">Powered by Saxon HE 12.9</div>
<h1>XSLT 3.0 Online Tester <span class="unique-badge">Only free tool</span></h1>
<p>Test XSLT 3.0 in your browser — maps, arrays, streaming, higher-order functions, JSON. Powered by the real Saxon processor. Completely free.</p>
<a class="cta-btn" href="/?v=3.0">Open XSLT 3.0 Editor →</a>
@@ -221,7 +221,7 @@
</div>
<div class="callout">
<strong>XSLT Playground is the only free online tool that supports XSLT 3.0.</strong> Every other free online XSLT tester is limited to XSLT 1.0. XSLT Playground uses Saxon HE 12.5 on the server — the reference implementation of XSLT 3.0.
<strong>XSLT Playground is the only free online tool that supports XSLT 3.0.</strong> Every other free online XSLT tester is limited to XSLT 1.0. XSLT Playground uses Saxon HE 12.9 on the server — the reference implementation of XSLT 3.0.
</div>
<h2>What is XSLT 3.0?</h2>
@@ -290,7 +290,7 @@
<dd>XSLT 3.0 adds maps and arrays, streaming for large documents, higher-order functions, JSON input/output, <code>xsl:try</code>/<code>xsl:catch</code> error handling, <code>xsl:on-empty</code>/<code>xsl:on-non-empty</code>, and <code>xsl:merge</code>.</dd>
<dt>Is there a free online XSLT 3.0 tester?</dt>
<dd>XSLT Playground is the only free online tool with full XSLT 3.0 support via Saxon HE 12.5. All other free online XSLT tools are limited to XSLT 1.0. No installation or account required.</dd>
<dd>XSLT Playground is the only free online tool with full XSLT 3.0 support via Saxon HE 12.9. All other free online XSLT tools are limited to XSLT 1.0. No installation or account required.</dd>
<dt>Can I process JSON with XSLT 3.0 online?</dt>
<dd>Yes. XSLT 3.0 supports JSON via <code>json-doc()</code> and <code>method="json"</code>. XSLT Playground's Saxon HE backend fully supports these features.</dd>
+77 -1
View File
@@ -18,7 +18,7 @@ import {
import { templateToWorkspace, findTemplate, STARTER_STYLESHEET } from "./lib/templates";
import { reviewWorkspace } from "./lib/reviewRules";
import { diffLines } from "./lib/diffUtils";
import { encodeCompact, decodeCompact, toSharePayload, fromSharePayload } from "./lib/shareLink";
import { encodeCompact, decodeCompact, toSharePayload, fromSharePayload, saveFiddle, loadFiddle } from "./lib/shareLink";
/* global __APP_VERSION__, __GIT_COMMIT__ */
@@ -490,6 +490,7 @@ function defaultWorkspaceStatus() {
isServerError: false,
notWellFormed: false,
traceEntries: [],
traceEngine: "",
hotspots: [],
traceText: "",
showRawTrace: false,
@@ -841,6 +842,8 @@ export default function App() {
const forcedTextRef = useRef(null);
const [forceRun, setForceRun] = useState(0);
const [compareOpen, setCompareOpen] = useState(false);
const [fiddleState, setFiddleState] = useState(null); // { id, revision } de la pestaña activa
const [fiddleSaving, setFiddleSaving] = useState(false);
const [xsltBeforeFormat, setXsltBeforeFormat] = useState(null);
const [resultBeforeFormat, setResultBeforeFormat] = useState(null);
const workspaceImportRef = useRef(null);
@@ -1076,6 +1079,7 @@ export default function App() {
isServerError,
notWellFormed,
traceEntries,
traceEngine,
hotspots,
traceText,
showRawTrace,
@@ -1536,6 +1540,29 @@ export default function App() {
[active, setActive, setWorkspaceStatus],
);
// ?f=ID loads a saved fiddle from the backend (with optional &r=revision).
useEffect(() => {
let cancelled = false;
let fid = null, frev = null;
try {
const p = new URLSearchParams(window.location.search);
fid = p.get("f");
frev = p.get("r");
} catch {}
if (!fid) return undefined;
loadFiddle(backendBase, fid, frev ? parseInt(frev, 10) : 0).then((res) => {
if (cancelled || !res) return;
const tab = defaultTab({ name: "Fiddle " + res.id, ...res.overrides });
setWorkspaceStatus((prev) => ({ ...prev, [tab.id]: defaultWorkspaceStatus() }));
setTabs([tab]);
setActive(tab.id);
setFiddleState({ id: res.id, revision: res.revision });
});
return () => {
cancelled = true;
};
}, [setActive, setWorkspaceStatus]);
// A compact ?c= link carries a gzipped workspace, which can only be read
// asynchronously — so unlike the legacy ?xslt= form it is applied after mount.
useEffect(() => {
@@ -1689,6 +1716,7 @@ export default function App() {
updateWorkspaceStatus(tabId, (prev) => ({
...prev,
traceEntries: newEntries,
traceEngine: traceEnabled ? (data.trace_engine || "") : "",
hotspots: traceEnabled ? (data.hotspots || []) : [],
traceText: traceEnabled ? (data.trace_text || "") : "",
}));
@@ -2420,6 +2448,42 @@ export default function App() {
<Icon name="undo" />
</button>
)}
<button
type="button"
className="fiddle-save-btn"
disabled={fiddleSaving}
title={
fiddleState
? `Save a new revision of fiddle ${fiddleState.id}`
: "Save this workspace as a short permanent link"
}
onClick={async () => {
if (!activeTab || fiddleSaving) return;
setFiddleSaving(true);
try {
const res = await saveFiddle(backendBase, activeTab, fiddleState?.id);
setFiddleState({ id: res.id, revision: res.revision });
const url = `${window.location.origin}/?f=${res.id}`;
await navigator.clipboard.writeText(url);
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2500);
window.gtag?.("event", "fiddle_saved", {
event_category: "engagement",
revision: res.revision,
});
} catch {
window.alert("Could not save the fiddle — the server may not have storage enabled.");
} finally {
setFiddleSaving(false);
}
}}
>
{fiddleSaving
? "Saving…"
: fiddleState
? `Save rev ${fiddleState.revision + 1}`
: "Save"}
</button>
<button
className="icon-button"
aria-label="Copy share link"
@@ -2550,6 +2614,18 @@ export default function App() {
</button>
)}
</div>
{!traceCollapsed && traceEngine === "unavailable" && (
<p className="trace-engine-note">
Trace isn't available for XSLT 1.0: the JDK's XSLTC
engine has no tracing hook. Switch to 2.0/3.0 to trace.
</p>
)}
{!traceCollapsed && traceEngine === "saxon12-compat" && (
<p className="trace-engine-note">
Traced runs of XSLT 2.0 execute on Saxon 12 in
2.0-compatibility mode; untraced runs use Saxon 9.
</p>
)}
{!traceCollapsed && hotspots?.length > 0 && (
<div className="hotspots">
<p className="hotspots-title">
+35
View File
@@ -108,3 +108,38 @@ export function fromSharePayload(payload) {
...(params.length ? { params } : {}),
};
}
// ── Saved fiddles ───────────────────────────────────────────────────────────
// A fiddle is the share payload persisted server-side under a short id with an
// append-only revision history — the link stays short no matter how big the
// workspace is, and re-saving the same fiddle records a new revision.
export async function saveFiddle(backendBase, tab, existingId) {
const payload = JSON.stringify({
...toSharePayload(tab),
...(tab.expected ? { e: tab.expected } : {}),
});
const res = await fetch(`${backendBase}/fiddle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...(existingId ? { id: existingId } : {}), payload }),
});
if (!res.ok) throw new Error(`fiddle save failed: ${res.status}`);
return res.json(); // { id, revision }
}
export async function loadFiddle(backendBase, id, revision) {
const path = revision ? `/fiddle/${id}/${revision}` : `/fiddle/${id}`;
const res = await fetch(`${backendBase}${path}`);
if (!res.ok) return null;
const data = await res.json();
try {
const parsed = JSON.parse(data.payload);
const overrides = fromSharePayload(parsed);
if (!overrides) return null;
if (typeof parsed.e === "string") overrides.expected = parsed.e;
return { overrides, id: data.id, revision: data.revision, revisions: data.revisions };
} catch {
return null;
}
}
+27
View File
@@ -66,3 +66,30 @@ describe.runIf(supportsCompactLinks())("compact encoding", () => {
expect(await decodeCompact("")).toBeNull();
});
});
describe("fiddles", () => {
it("round-trips a workspace payload including the expected output", async () => {
const calls = [];
global.fetch = async (url, opts) => {
calls.push({ url, opts });
if (opts?.method === "POST") {
return { ok: true, json: async () => ({ id: "AbCdEfG", revision: 1 }) };
}
const body = JSON.parse(calls[0].opts.body).payload;
return { ok: true, json: async () => ({ id: "AbCdEfG", revision: 1, revisions: 1, payload: body }) };
};
const { saveFiddle, loadFiddle } = await import("./shareLink");
const tab = { xslt: "<x/>", version: "2.0", name: "t", params: [{ name: "input", value: "<r/>", open: true }], expected: "<out/>" };
const saved = await saveFiddle("http://b", tab);
expect(saved).toEqual({ id: "AbCdEfG", revision: 1 });
const loaded = await loadFiddle("http://b", "AbCdEfG");
expect(loaded.overrides.xslt).toBe("<x/>");
expect(loaded.overrides.expected).toBe("<out/>");
});
it("returns null on a missing fiddle instead of throwing", async () => {
global.fetch = async () => ({ ok: false, status: 404 });
const { loadFiddle } = await import("./shareLink");
expect(await loadFiddle("http://b", "zzzzzzz")).toBeNull();
});
});
+36
View File
@@ -2401,3 +2401,39 @@ a:focus-visible {
:root[data-theme="dark"] .hotspots { border-color: #2b3645; }
:root[data-theme="dark"] .hotspots-title { color: #e6edf3; }
:root[data-theme="dark"] .hotspot-label { color: #c9d4e0; }
.trace-engine-note {
margin: 4px 8px;
font-size: 0.72rem;
color: #7a5800;
background: #fffbea;
border-left: 3px solid #f0c040;
padding: 3px 8px;
}
:root[data-theme="dark"] .trace-engine-note {
color: #f0d070;
background: #2a2200;
border-left-color: #a07800;
}
/* Saved fiddles */
.fiddle-save-btn {
border: 1px solid #cfdcf4;
border-radius: 6px;
background: #f0f6ff;
color: #21426c;
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.fiddle-save-btn:hover { border-color: #4a85ff; background: #e6f0ff; }
.fiddle-save-btn[disabled] { opacity: 0.6; cursor: default; }
:root[data-theme="dark"] .fiddle-save-btn {
background: #161c24;
border-color: #2b3645;
color: #e6edf3;
}
@@ -15,7 +15,7 @@ If you want full XSLT 3.0 in the browser with no install, the two free tools tha
| Tool | XSLT 3.0 | Engine | Multiple inputs / params | Validation & errors | Signup |
|------|:--------:|--------|:------------------------:|---------------------|:------:|
| [XSLT Playground](https://xsltplayground.com) | ✅ full | Saxon HE 12.5 (server) | ✅ named params + multiple XML | Line-number Saxon errors | No |
| [XSLT Playground](https://xsltplayground.com) | ✅ full | Saxon HE 12.9 (server) | ✅ named params + multiple XML | Line-number Saxon errors | No |
| XSLT Fiddle | ✅ | Saxon-JS 2 / Saxon 12 HE | Limited | Basic | No |
| LinangData XSLT Tester | ✅ | Saxon-JS (browser) | No | Basic | No |
| FreeFormatter XSL Transformer | ❌ 1.0/2.0 | Server | No | Basic | No |
@@ -34,7 +34,7 @@ If your goal is to reproduce what your **production** stylesheet will do, a tool
## The tools, one by one
### XSLT Playground
Runs XSLT 1.0, 2.0 and 3.0 on a real **Saxon HE 12.5** backend. Its main strengths are aimed at real integration work: you can pass **multiple XML inputs as named parameters**, inspect an **execution trace** to debug step by step, and get **exact line-number error messages** straight from Saxon. It keeps up to three independent workspaces and lets you export/import them as JSON to share a setup. No account, no signup. Best for: testing or debugging stylesheets the way they will run in production (SAP, MuleSoft, Tibco, IBM-style middleware). → [xsltplayground.com](https://xsltplayground.com)
Runs XSLT 1.0, 2.0 and 3.0 on a real **Saxon HE 12.9** backend. Its main strengths are aimed at real integration work: you can pass **multiple XML inputs as named parameters**, inspect an **execution trace** to debug step by step, and get **exact line-number error messages** straight from Saxon. It keeps up to three independent workspaces and lets you export/import them as JSON to share a setup. No account, no signup. Best for: testing or debugging stylesheets the way they will run in production (SAP, MuleSoft, Tibco, IBM-style middleware). → [xsltplayground.com](https://xsltplayground.com)
### XSLT Fiddle
A capable, developer-focused fiddle that supports XSLT 3.0 and lets you **choose the engine** (Saxon-JS 2, or Saxon 12 HE Java). Great when you specifically want to test against a particular Saxon build or share a minimal reproduction. The interface is deliberately bare-bones.
@@ -0,0 +1,103 @@
---
title: "Chrome is removing XSLT on November 17, 2026: what breaks and what to do"
description: "Chrome 158 drops native XSLT — XSLTProcessor and xml-stylesheet stop working. Who is actually affected, honest pros and cons of the WASM polyfill vs server-side migration paths, and how to test your stylesheets outside the browser today."
date: 2026-08-14T00:00:00Z
tags: ["xslt", "chrome", "browser", "deprecation", "migration"]
---
**Quick answer:** Chrome removes built-in XSLT support in **version 158, shipping November 17, 2026**, with deprecation warnings already appearing since Chrome 142143 ([official announcement](https://developer.chrome.com/docs/web-platform/deprecating-xslt)). Both the `XSLTProcessor` JavaScript API and `<?xml-stylesheet?>` processing instructions stop working. Firefox and WebKit have signalled they will follow. Your migration options are a WASM polyfill, server-side transformation, or build-time precompilation — and you can verify today whether your stylesheets run correctly outside the browser.
## What exactly is being removed
Two things, both part of the same removal:
1. **`XSLTProcessor`** — the JavaScript API (`importStylesheet()`, `transformToFragment()`, `transformToDocument()`). Any client-side code calling it throws once the API is gone.
2. **`<?xml-stylesheet type="text/xsl" ...?>`** — the processing instruction that made the browser auto-render an XML document (RSS feeds, sitemaps, DocBook-ish documentation, legacy intranet reports) through a stylesheet. Those URLs will render as raw XML.
## Why, and why now
Chrome's stated numbers: XSLT appears in roughly **0.02% of page loads**, and the `xml-stylesheet` processing instruction in **under 0.001%**. The driving reason is security, not usage: browsers ship XSLT via **libxslt**, a minimally-maintained C library with a history of memory-safety vulnerabilities — [CVE-2025-7425](https://nvd.nist.gov/vuln/detail/CVE-2025-7425) (use-after-free) and [CVE-2022-22834](https://nvd.nist.gov/vuln/detail/CVE-2022-22834) among them. Maintaining an interpreter for a 1999-era spec in the browser's most attacked process stopped being worth it. Since Firefox and WebKit have indicated the same direction, "wait for another browser" is not a plan.
Worth remembering: the browsers only ever implemented **XSLT 1.0**. Nothing about 2.0/3.0 changes here, because it was never in the browser to begin with.
## Who is actually affected
- **Sites rendering XML directly with `xml-stylesheet`** — styled RSS/Atom feeds, sitemap.xml viewers, legacy documentation systems. This is the biggest visible breakage.
- **Web apps calling `XSLTProcessor`** — often deep inside old admin panels and enterprise frontends that nobody has touched in years. `grep -r XSLTProcessor` your codebase.
- **Not affected:** anything doing XSLT server-side (Java/Saxon, .NET, PHP, integration middleware). That is where most production XSLT already lives.
## Your options, honestly
| Option | Pros | Cons |
|---|---|---|
| **WASM polyfill** (libxslt compiled to WebAssembly, what Chrome recommends for drop-in continuity) | Minimal code change; keeps rendering client-side; works for `XSLTProcessor` call sites | Adds a non-trivial WASM payload to first load; you now ship and patch libxslt yourself — the same library the browsers dropped for security reasons; PI-based auto-rendering needs extra glue |
| **Server-side transformation** | Real processor (Saxon opens up XSLT 2.0/3.0); output is plain HTML so nothing depends on browser support ever again; testable in CI | Needs a backend or edge function; XML URLs must be routed/proxied through it |
| **Precompile at build time** | Zero runtime cost; ideal when the XML is static (docs, feeds with static templates) | Only works for content known at build time; dynamic XML still needs one of the above |
Our take: the polyfill is a reasonable *bridge* for a large `XSLTProcessor` codebase you cannot rework before November. As a destination, server-side or build-time wins — you swap a deprecated browser dependency for a supported processor instead of vendoring the deprecated one.
## Test your stylesheets outside the browser — today
Before choosing, find out whether your stylesheets even behave the same outside the browser. Paste one into [XSLT Playground](https://xsltplayground.com/) together with a sample XML input and run it with **version = 1.0**. That executes a JAXP (Xalan-class) XSLT 1.0 processor — same spec level and very close semantics to what the browser did, so differences surface immediately (typical ones: reliance on browser-specific output quirks, `document()` calls resolving against URLs, disable-output-escaping).
Here is a browser-typical stylesheet — XML rendered as an HTML table — run exactly that way:
Input XML:
```xml
<catalog>
<book>
<title>XSLT Cookbook</title>
<price>39.95</price>
</book>
<book>
<title>XPath Essentials</title>
<price>24.50</price>
</book>
</catalog>
```
Stylesheet (run with **version 1.0**):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output method="html" indent="yes"/>
<xsl:template match="/catalog">
<table>
<tr><th>Title</th><th>Price</th></tr>
<xsl:for-each select="book">
<tr>
<td><xsl:value-of select="title"/></td>
<td><xsl:value-of select="price"/></td>
</tr>
</xsl:for-each>
</table>
</xsl:template>
</xsl:stylesheet>
```
Output (exactly as returned):
```html
<table>
<tr>
<th>Title</th><th>Price</th>
</tr>
<tr>
<td>XSLT Cookbook</td><td>39.95</td>
</tr>
<tr>
<td>XPath Essentials</td><td>24.50</td>
</tr>
</table>
```
If it runs clean at 1.0, your server-side migration is low-risk — and once you are server-side you can optionally move to 2.0/3.0 and simplify the stylesheet (grouping, regex, sequences). If it errors, the [Saxon error triage guide](https://xsltplayground.com/blog/posts/xslt-common-errors/) maps each code to its fix.
## Related
The browser removal lands the same year the venerable FreeFormatter site [shut down](https://xsltplayground.com/blog/posts/freeformatter-xsl-transformer-alternative/) — client-side and ad-supported XSLT are both winding down, while server-side XSLT keeps running payment migrations ([ISO 20022](https://xsltplayground.com/blog/posts/iso-20022-xslt-transformations/)) and e-invoicing validation ([Peppol/EN 16931](https://xsltplayground.com/blog/posts/validate-peppol-schematron-xslt-online/)) at scale. XSLT is not dying; it is relocating.
Test your stylesheets now: **[xsltplayground.com](https://xsltplayground.com/)**.
@@ -0,0 +1,82 @@
---
title: "FreeFormatter is gone: a working alternative to its XSL Transformer (2026)"
description: "FreeFormatter.com shut down in 2026. Here is what happened, a feature-by-feature mapping of its XSL Transformer to XSLT Playground, and a runnable example — including real Saxon XSLT 2.0/3.0, which FreeFormatter never had."
date: 2026-08-14T00:00:00Z
tags: ["xslt", "online", "tools", "freeformatter", "alternative"]
---
**Quick answer:** FreeFormatter.com was retired by its owner in 2026 and its XSL Transformer is offline for good, with no redirect and no designated successor. The closest free replacement for the transform workflow is **[XSLT Playground](https://xsltplayground.com/)** — paste stylesheet, paste XML, get output — with one real upgrade: it runs XSLT 1.0, 2.0 **and 3.0** on a genuine Saxon HE backend, which FreeFormatter never offered.
## What happened to FreeFormatter
If you visit FreeFormatter.com today you get a single farewell note instead of the tool list. The owner cites three reasons for pulling the plug: hosting costs, an advertising model he'd grown to dislike (his words: ad networks turned the pages into a "flickering landfill of pop-ups"), and the observation that "AI can now do most of what this site was created to do". No redirect, no handover, no archive of the tools.
Credit where due: FreeFormatter ran for well over a decade, was free the whole time, and its XSL Transformer was many developers' first contact with running a stylesheet outside an IDE. It shows up in years of Stack Overflow answers. Those links are all dead now, which is presumably why you are here.
## Feature-by-feature: XSL Transformer → XSLT Playground
| FreeFormatter XSL Transformer | In [XSLT Playground](https://xsltplayground.com/) |
|---|---|
| Paste XML + paste XSL, click Transform | Same flow: XML goes in the input panel, stylesheet in the editor, run |
| XSLT 1.0-era processing | XSLT **1.0, 2.0 and 3.0** — real Saxon HE on the server, selectable per run |
| Single input document | **Multiple XML inputs as named parameters** — see the [parameters guide](https://xsltplayground.com/blog/posts/xslt-parameters-and-multiple-inputs/) |
| Basic error message on failure | Saxon error codes with exact line numbers — decoded in the [error reference](https://xsltplayground.com/blog/posts/xslt-common-errors/) |
| No debugging | Execution **trace**: see which templates fired, with what context |
| No persistence | Up to 3 workspaces in localStorage, export/import as JSON |
| Free, no signup | Free, no signup |
The honest caveat: FreeFormatter was a Swiss-army site (JSON formatters, escapers, generators, validators). XSLT Playground only replaces the **XSLT/XML transform and validation** part — but it replaces it with something deeper than what was lost.
## The upgrade you get for free: XSLT 2.0/3.0
FreeFormatter's transformer handled everyday 1.0-style transforms but never ran a real Saxon 2.0/3.0 engine. This stylesheet, for example, would have been out of reach there — `xsl:for-each-group` is XSLT 2.0:
Input XML:
```xml
<orders>
<order id="1" region="EMEA" amount="120"/>
<order id="2" region="APAC" amount="75"/>
<order id="3" region="EMEA" amount="300"/>
</orders>
```
Stylesheet (run with **version 2.0**):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="2.0">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/orders">
<summary>
<xsl:for-each-group select="order" group-by="@region">
<region name="{current-grouping-key()}"
total="{sum(current-group()/@amount)}"/>
</xsl:for-each-group>
</summary>
</xsl:template>
</xsl:stylesheet>
```
Output (exactly as Saxon returns it):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<summary>
<region name="EMEA" total="420"/>
<region name="APAC" total="75"/>
</summary>
```
Grouping is the single most common reason people outgrow 1.0 tooling — the [for-each-group deep dive](https://xsltplayground.com/blog/posts/xslt-grouping-for-each-group/) covers the patterns.
## Other options
A broader comparison of free online testers (Saxon HE vs Saxon-JS, multi-input support, validation) is in [Best free online XSLT 3.0 testers compared](https://xsltplayground.com/blog/posts/best-online-xslt-3-testers/) — written before FreeFormatter closed, so mentally strike it from that table.
## The bigger picture
FreeFormatter's shutdown is one of two 2026 events reshaping where XSLT runs: the other is [Chrome removing native XSLT support in November 2026](https://xsltplayground.com/blog/posts/chrome-removing-xslt-what-to-do/). The direction is the same in both cases — XSLT is moving off the browser and off ad-supported utility sites, and onto server-side processors. If your workflow depended on FreeFormatter, pointing it at a real Saxon backend is the durable fix, not a sideways move.
Try your old FreeFormatter workflow now: **[xsltplayground.com](https://xsltplayground.com/)** — paste, pick a version, run.
@@ -0,0 +1,110 @@
---
title: "ISO 20022 and XSLT: transforming camt.053 bank statements after the MT sunset"
description: "SWIFT retired MT940/942/101 in November 2025, and ISO 20022 is XML — which is why payments teams are writing XSLT in 2026. A runnable camt.053-to-CSV example in XSLT 2.0, plus trace-based debugging tips."
date: 2026-08-14T00:00:00Z
tags: ["xslt", "iso20022", "camt053", "payments", "sap", "csv"]
---
**Quick answer:** ISO 20022 messages (camt, pain, pacs) are XML, and since **SWIFT stopped supporting the legacy MT formats (MT940/942/101) in November 2025**, every system that consumed MT text files needs the XML equivalents instead. The pragmatic bridge is XSLT: transform camt.053 statements into whatever your ERP, treasury system or reconciliation job expects. In SAP environments this is explicit — importing camt.052/053/054 electronic bank statements runs through XSLT transformations. Below: a realistic, runnable camt.053-to-CSV stylesheet in XSLT 2.0.
## Why payments teams are writing XSLT in 2026
The MT940 era was fixed-format text: line-oriented, position-sensitive, parsed with regex and prayer. Its camt successors are deeply nested XML with a versioned namespace per message (`urn:iso:std:iso:20022:tech:xsd:camt.053.001.02` and later revisions). That swap broke every downstream consumer that expected MT text — and created three recurring transformation jobs:
- **camt → flat/CSV** for reconciliation engines, data warehouses and anything that still thinks in rows (this post's example).
- **camt → camt** to bridge version gaps — your bank sends `camt.053.001.08`, your ERP's importer was certified against `.02`.
- **MT-lookalike output** for legacy systems that cannot be changed, generated *from* camt so the old interface survives the sunset.
XSLT is the natural tool: the input is XML, the mappings are declarative, and the same stylesheet runs identically in SAP PI/PO, MuleSoft, Tibco, or a cron job with Saxon. Use **XSLT 2.0**`xs:decimal` arithmetic, `string-join`, and `format-number` do in one line what 1.0 needed recursive templates for.
## Runnable example: camt.053 entries to CSV
A minimal but structurally faithful camt.053: two booked entries (one credit, one direct-debit) under `BkToCstmrStmt/Stmt/Ntry`.
Input XML:
```xml
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Id>STMT-2026-0812</Id>
<Ntry>
<Amt Ccy="EUR">1250.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2026-08-11</Dt></BookgDt>
<ValDt><Dt>2026-08-11</Dt></ValDt>
<NtryDtls>
<TxDtls>
<RmtInf><Ustrd>INVOICE 2026-448</Ustrd></RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
<Ntry>
<Amt Ccy="EUR">89.90</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<BookgDt><Dt>2026-08-12</Dt></BookgDt>
<ValDt><Dt>2026-08-12</Dt></ValDt>
<NtryDtls>
<TxDtls>
<RmtInf><Ustrd>SEPA DD TELECOM AUG</Ustrd></RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>
```
Stylesheet (run with **version 2.0**). Note the two traps it handles: camt puts everything in a **default namespace** you must bind to a prefix, and amounts are unsigned — the sign lives in `CdtDbtInd`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:camt="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"
exclude-result-prefixes="camt xs"
version="2.0">
<xsl:output method="text"/>
<xsl:template match="/">
<xsl:text>booking_date,value_date,amount,currency,reference&#10;</xsl:text>
<xsl:for-each select="camt:Document/camt:BkToCstmrStmt/camt:Stmt/camt:Ntry">
<xsl:variable name="sign" select="if (camt:CdtDbtInd = 'DBIT') then -1 else 1"/>
<xsl:value-of select="string-join((
camt:BookgDt/camt:Dt,
camt:ValDt/camt:Dt,
format-number($sign * xs:decimal(camt:Amt), '0.00'),
camt:Amt/@Ccy,
camt:NtryDtls/camt:TxDtls/camt:RmtInf/camt:Ustrd
), ',')"/>
<xsl:text>&#10;</xsl:text>
</xsl:for-each>
</xsl:template>
</xsl:stylesheet>
```
Output (exactly as returned):
```
booking_date,value_date,amount,currency,reference
2026-08-11,2026-08-11,1250.00,EUR,INVOICE 2026-448
2026-08-12,2026-08-12,-89.90,EUR,SEPA DD TELECOM AUG
```
Production notes: real files carry multiple `TxDtls` per entry (batch bookings) — decide whether to explode them into rows or aggregate; `RmtInf` may be `Strd` (structured) instead of `Ustrd`; and if fields can contain commas, wrap them in quotes before joining. For richer target formats, the [XML-to-JSON/CSV guide](https://xsltplayground.com/blog/posts/xslt-xml-to-json-csv/) covers the variations.
## Debugging camt stylesheets with trace
Symptoms and their causes, in order of how often they actually happen:
1. **Empty output, no error.** Almost always the namespace: `select="Document/BkToCstmrStmt"` silently matches nothing because the elements live in the camt namespace. Bind the prefix and qualify *every* step. In [XSLT Playground](https://xsltplayground.com/), enable the **trace** — if your `for-each` selected zero nodes you see it immediately, instead of staring at a blank result.
2. **Works on the sample, fails on the bank's file.** Usually a namespace *version* mismatch — the stylesheet says `.001.02`, the bank upgraded to `.001.08`. The prefix binding must match the file byte-for-byte.
3. **`FORG0001` on the amount cast.** Some entry has an empty or non-numeric `Amt` in an edge case; guard with `castable as xs:decimal`. The [Saxon error reference](https://xsltplayground.com/blog/posts/xslt-common-errors/) maps the rest.
General technique — shrink the statement to the one failing `Ntry` and iterate — is covered in the [debugging patterns guide](https://xsltplayground.com/blog/posts/xslt-debugging-patterns/).
## Related
camt processing is one of two finance workloads keeping XSLT busy in 2026; the other is e-invoicing, where [Peppol/EN 16931 validation runs as Schematron compiled to XSLT 2.0](https://xsltplayground.com/blog/posts/validate-peppol-schematron-xslt-online/) — same processor class, same debugging workflow. And with [browsers dropping XSLT](https://xsltplayground.com/blog/posts/chrome-removing-xslt-what-to-do/), server-side Saxon is unambiguously where this work lives.
Paste your camt file and stylesheet at **[xsltplayground.com](https://xsltplayground.com/)** — version 2.0, run, and use the trace when the output is not what the bank promised.
@@ -0,0 +1,106 @@
---
title: "Validate Peppol / EN 16931 invoices online: how Schematron becomes XSLT"
description: "Peppol and EN 16931 validation is Schematron compiled to XSLT 2.0 stylesheets that emit SVRL. How the multi-layer pipeline works, how to run a compiled rule against a UBL invoice in the browser, and where an online playground honestly fits."
date: 2026-08-14T00:00:00Z
tags: ["xslt", "schematron", "peppol", "en16931", "validation", "svrl"]
---
**Quick answer:** Peppol BIS and EN 16931 invoice validation is not magic — it is **Schematron rules compiled into XSLT 2.0 stylesheets** that read your UBL invoice and emit an SVRL report listing every failed assertion. Because the official artefacts require an **XSLT 2.0 processor** (Saxon-class), you cannot run them in a browser (browsers only ever had 1.0 — and are [removing even that](https://xsltplayground.com/blog/posts/chrome-removing-xslt-what-to-do/)). You *can* run them in [XSLT Playground](https://xsltplayground.com/), which executes real Saxon HE server-side: paste the compiled XSLT as the stylesheet, the invoice as the input, and read the SVRL.
## The validation pipeline, demystified
A Peppol invoice passes through **layered** validation, each layer catching a different class of problem:
| Layer | Artefact | Catches |
|---|---|---|
| 1. Structure | **XSD** (UBL 2.1 Invoice schema) | Wrong elements, wrong order, wrong types |
| 2. Semantics (EU) | **Schematron: EN 16931** (`EN16931-UBL-validation.xsl`) | Business rules `BR-*`: totals must add up, VAT category consistency, code lists |
| 3. Semantics (Peppol) | **Schematron: Peppol BIS 3.0** (`PEPPOL-EN16931-UBL.xsl`) | Rules `PEPPOL-EN16931-R*`: Peppol-specific tightenings on top of the EN |
Layers 2 and 3 are authored as Schematron (`.sch`), but what actually *executes* is the **compiled XSLT** the projects ship alongside. The compilation is mechanical: each `sch:rule` becomes a template, each `sch:assert` becomes a test that, when it fails, writes an `svrl:failed-assert` element into the output. The output format, **SVRL** (Schematron Validation Report Language), is itself just XML — which is why the whole stack is "XSLT in, XSLT out".
Key operational fact: the official EN 16931 and Peppol artefacts use XPath 2.0 constructs throughout, so they **require an XSLT 2.0 processor** — in practice Saxon. This is exactly why every Peppol validator you have ever used runs server-side Java.
## Run a Schematron-compiled XSLT online
You would not paste the full 5 MB official artefacts into a browser tab. But for **understanding the mechanics** — and for debugging *one* rule — a minimal hand-compiled Schematron is perfect. This toy stylesheet checks two invoice rules and emits genuine SVRL; the second rule mimics the real `PEPPOL-EN16931-R003` ("A buyer reference or purchase order reference MUST be provided").
Input — a deliberately incomplete UBL invoice (no `cbc:BuyerReference`):
```xml
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:ID>INV-2026-001</cbc:ID>
<cbc:IssueDate>2026-08-01</cbc:IssueDate>
</Invoice>
```
Stylesheet — structured the way real compiled Schematron is (run with **version 2.0**):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:svrl="http://purl.oclc.org/dsdl/svrl"
xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
version="2.0">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
<svrl:schematron-output title="Toy Peppol subset">
<svrl:active-pattern name="invoice-rules"/>
<xsl:apply-templates select="ubl:Invoice" mode="check"/>
</svrl:schematron-output>
</xsl:template>
<xsl:template match="ubl:Invoice" mode="check">
<svrl:fired-rule context="ubl:Invoice"/>
<xsl:if test="not(cbc:IssueDate)">
<svrl:failed-assert id="TOY-R001" flag="fatal" test="cbc:IssueDate">
<svrl:text>An invoice MUST have an invoice issue date (BT-2).</svrl:text>
</svrl:failed-assert>
</xsl:if>
<xsl:if test="not(cbc:BuyerReference)">
<svrl:failed-assert id="TOY-R003" flag="fatal" test="cbc:BuyerReference">
<svrl:text>A buyer reference MUST be provided (mimics PEPPOL-EN16931-R003).</svrl:text>
</svrl:failed-assert>
</xsl:if>
</xsl:template>
</xsl:stylesheet>
```
Output — the SVRL report, exactly as Saxon returns it:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<svrl:schematron-output xmlns:svrl="http://purl.oclc.org/dsdl/svrl"
xmlns:ubl="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
title="Toy Peppol subset">
<svrl:active-pattern name="invoice-rules"/>
<svrl:fired-rule context="ubl:Invoice"/>
<svrl:failed-assert id="TOY-R003" flag="fatal" test="cbc:BuyerReference">
<svrl:text>A buyer reference MUST be provided (mimics PEPPOL-EN16931-R003).</svrl:text>
</svrl:failed-assert>
</svrl:schematron-output>
```
Read it like a validator does: `fired-rule` says the context matched, the *absence* of a `failed-assert` for TOY-R001 means the issue-date check passed, and TOY-R003 flags the missing buyer reference. Add `<cbc:BuyerReference>PO-4711</cbc:BuyerReference>` to the input and re-run — the report drops to `fired-rule` only. That instant edit-rerun loop is the point.
## Where this is genuinely useful — and where it is not
**Useful for:**
- **Understanding why a rule fires.** Extract the one rule tormenting you from the official artefact into a minimal stylesheet like the above, shrink the invoice, iterate. Namespaces are the #1 gotcha — UBL's default namespace plus `cbc:`/`cac:` trips XPath constantly ([XPST0081 explained](https://xsltplayground.com/blog/posts/xslt-common-errors/)).
- **Developing your own Schematron.** If you write company-specific rules on top of Peppol, testing the compiled XSLT interactively beats a full pipeline round-trip every save.
- **Learning SVRL** before you write code that parses it.
**Not a replacement for:**
- **Official validation.** For compliance sign-off, run the complete artefact set with the OpenPeppol tooling or an accredited validator — full XSD layer, complete rule sets, correct artefact release versions. An online playground is a debugging microscope, not a conformance authority.
## Related
Schematron-to-XSLT is one of two places finance teams meet XSLT in 2026 — the other is [bank statement processing under ISO 20022](https://xsltplayground.com/blog/posts/iso-20022-xslt-transformations/), where camt.053 files get reshaped with the same XSLT 2.0 toolbox. For general rule-debugging technique, see the [debugging patterns guide](https://xsltplayground.com/blog/posts/xslt-debugging-patterns/).
Paste your compiled rule and an invoice at **[xsltplayground.com](https://xsltplayground.com/)** — version 2.0, run, read the SVRL.