This document describes the structure, data flow, and design patterns of XSLTDebugX.
- High-Level Architecture
- Build Pipeline
- Module Overview
- Module Dependency Graph
- Data Flow
- Global State Management
- DOM Structure
- Key Design Patterns
- Loading Order & Initialization
- Namespace Guidelines
XSLTDebugX is a vanilla JavaScript application deployed as a static site on Cloudflare Pages. It uses no framework. For local development, source files are served directly (no build required). For production, a Vite + esbuild pipeline bundles and minifies into dist/.
┌──────────────────────────────────────────────────────────────┐
│ index.html │
│ (loads CSS + 13 JS modules + Monaco Editor + Saxon-JS + pako + Lucide Icons)│
└──────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ Vanilla ES6+ JavaScript │
│ (13 modules)
└─────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Monaco Editor (XML, XSLT, Output panes) │
│ Saxon-JS 2 (XSLT 3.0 + XPath 3.1) │
│ pako (deflate/inflate for share URLs) │
│ Lucide Icons (SVG icon library) │
│ CSS (Light & Dark themes) │
└─────────────────────────────────────────────┘
↓
┌──────────────────────────────┐
│ Browser localStorage │
│ (xdebugx-session-v1) │
└──────────────────────────────┘
npm run build runs vite build, which uses a custom two-plugin pipeline in vite.config.js:
Source files (js/*.js + css/style.css)
↓
esbuild — minifies each JS module individually (no IIFE wrapping)
↓
Concatenated in strict load order → content-hashed → dist/app.{hash}.js
↓
Vite — processes css/style.css → dist/app.{hash}.css
↓
index.html rewritten:
- Every <script src="js/..."> tag stripped
- Single <script src="app.{hash}.js"> injected
- CSS link replaced with hashed filename
↓
dist/lib/SaxonJS2.js, _headers, _redirects, favicon.svg copied as-is
Why no IIFE wrapping? The source code uses inline HTML event handlers (onclick="runTransform()") that require functions to be on window. IIFE-wrapping would make them local-scoped and break all handlers. esbuild's bundle: false mode minifies without wrapping.
Load order is defined by the JS_MODULES array in vite.config.js and must stay in sync with the <script> tag order in index.html. When adding or reordering modules, update both.
dist/ output:
dist/
├── index.html # Rewritten HTML
├── app.{hash}.js # All 13 modules, minified + concatenated
├── app.{hash}.css # Minified CSS
├── favicon.svg
├── _headers # Cloudflare cache rules
├── _redirects # SPA routing fallback
└── lib/
└── SaxonJS2.js # Vendor XSLT engine (~2.3 MB)
| Module | Responsibility | Key Functions |
|---|---|---|
| state.js | Global state, shared utilities, localStorage persistence, console | clog(), scheduleSave(), loadSavedState(), setStatus(), logError(), guardReady() |
| mode-manager.js | Centralized mode management (XSLT vs XPath) | setMode(), isXpath, isXslt, currentModel |
| validate.js | XML/XSLT validation, Monaco error markers, Saxon error parsing | validateXML(), markErrorLine(), preflight(), parseSaxonErrorLine() |
| panes.js | Word wrap, copy/clear/format, XML tokenizer | toggleWordWrap(), copyPane(), fmtEditor(), prettyXML(), _tokenizeXML(), _indentTokens() |
| transform.js | XSLT execution, CPI simulation, output rendering | runTransform(), rewriteCPICalls(), buildParamsXPath(), renderOutputKV() |
| examples-data.js | 61 built-in XSLT/XPath examples across 6 categories | CATEGORIES, EXAMPLES (data objects) |
| modal.js | Examples library UI, filtering, loading | openExModal(), loadExample(), renderExGrid(), filterExamples() |
| files.js | File upload/download, drag-and-drop | triggerUpload(), handleUpload(), downloadPane(), setupDragDrop() |
| ui.js | Console state, theme toggle, help modal, column collapse | setConsoleState(), toggleTheme(), applyConsoleSearch(), setConsoleFilter() |
| share.js | URL encoding/decoding of session state | buildSharePayload(), generateShareUrl(), loadFromShareHash() |
| xpath.js | XPath mode UI, expression evaluation, node highlighting, syntax coloring | runXPath(), toggleXPath(), _highlightXPath(), _highlightMatchedNodes() |
| themes.js | Monaco theme definitions (light + dark palettes) | MONACO_THEME_DARK, MONACO_THEME_LIGHT |
| editor.js | Monaco initialization, keyboard shortcuts, context menus | hideLoader(), setupAutoClose(), _toggleXmlComment() |
index.html
↓
[Load order matters — executed in sequence]
↓
state.js ←─────────────────────────────────────────────────────┐
↓ (provides global state, clog, scheduleSave) │
mode-manager.js (uses state) │
↓ (provides setMode, isXpath, isXslt, currentModel) │
validate.js (uses state) │
↓ (provides validateXML, markErrorLine, preflight) │
panes.js (uses state) │
↓ (provides toggleWordWrap, copyPane, fmtEditor) │
transform.js (uses state, validate) │
↓ (provides runTransform, CPI functions) │
examples-data.js │
↓ (provides CATEGORIES, EXAMPLES) │
modal.js (uses state, examples-data) │
↓ (provides openExModal, loadExample) │
files.js (uses state) │
↓ (provides triggerUpload, downloadPane) │
ui.js (uses state) │
↓ (provides toggleTheme, setConsoleState) │
share.js (uses state, transform) │
↓ (provides generateShareUrl) │
xpath.js (uses state, validate, mode-manager) │
↓ (provides runXPath, toggleXPath, node highlighting) │
themes.js │
↓ (provides MONACO_THEME_DARK / MONACO_THEME_LIGHT) │
editor.js (uses state, all above) ←────────────────────────────┘
↓ (provides eds, Monaco init, keyboard shortcuts)
[All modules must load before first user interaction]
Key Constraint: Load order is strict. Each module depends on globals defined by earlier modules.
User clicks "Run XSLT"
↓
runTransform() [transform.js]
↓
1. Read XML from eds.xml.getValue()
2. Read XSLT from eds.xslt.getValue()
↓
3. preflight(xmlSrc, xsltSrc) [validate.js]
- validateXML() on both inputs
- If errors → mark lines, log, return false
↓
4. Detect CPI calls (cpi:setHeader, etc.)
↓
5. If CPI detected → rewriteCPICalls() [transform.js]
- Replace xmlns:cpi → xmlns:js (Saxon-JS namespace)
- Replace cpi:setHeader → js:cpiSetHeader
- Inject window.cpiSetHeader, cpiSetProperty, etc. interceptors
- Capture values into cpiCaptured object
↓
6. BuildParamsXPath() [transform.js]
- Build xsl:param map from kvData.headers + kvData.properties
- Validate NCNames, warn on duplicates/invalid names
↓
7. SaxonJS.XPath.evaluate() [transform.js]
- Execute transform(map { stylesheet-text, source-node, params })
- Intercept console.log to capture xsl:message output
↓
8. Detect output language
- If starts with '<' → XML (pretty-print)
- If starts with '{' or '[' → JSON (pretty-print)
- Else → plaintext (CSV, fixed-length, EDI)
↓
9. Render output
- eds.out.setValue(formattedOutput)
- renderOutputKV(headers, properties)
- For CPI: show cpiCaptured values first, then headers/properties
↓
Complete
User types XPath expression → press Enter or click "Run XPath"
↓
runXPath() [xpath.js]
↓
1. Read expression from document.getElementById('xpathInput')
2. Read XML from eds.xml.getValue()
↓
3. validateXML(xmlSrc) [validate.js]
- If invalid → show error in results panel, return
↓
4. Push expression to history: _xpathHistoryPush(expr)
↓
5. SaxonJS.XPath.evaluate(expr, docNode, { namespaceContext: NS })
- Namespace context includes xs, fn, math, map, array
↓
6. _xpathNormalise(result) [xpath.js]
- Convert result to flat array (handles atomic values, sequences)
↓
7. _highlightMatchedNodes(items, xmlSrc) [xpath.js]
- For each matched element: find source range, calculate line numbers
- Apply Monaco decorations (amber highlights) on matched lines
- Scroll XML editor to first match
↓
8. _showXPathResults(items, null, false) [xpath.js]
- Serialize each matched item to string
- Use monaco.editor.colorize() to syntax-highlight XML nodes
- Render results panel with type badges (Node, Text, Value)
↓
Complete
User clicks theme toggle button
↓
toggleTheme() [ui.js]
↓
1. Toggle 'light' class on <body>
2. Save to localStorage ('xdebugx-theme')
↓
3. Apply CSS class to <body> for styling
↓
4. Update Monaco theme globally (applies to all editors at once):
- monaco.editor.setTheme('xdebugx-light' or 'xdebugx')
↓
5. If XPath results visible → refreshXPathColors() [xpath.js]
- Re-colorize results using new theme palette
↓
Complete
On startup:
loadSavedState() [state.js] → parse localStorage[xdebugx-session-v1]
↓
Restore to all editors and panels:
- xmlModelXslt.setValue(saved.xmlXslt)
- xmlModelXpath.setValue(saved.xmlXpath)
- eds.xslt.setValue(saved.xslt)
- kvData.headers = saved.headers
- kvData.properties = saved.properties
- modeManager.restoreFromSession() — applies saved.xpathEnabled
- Column collapse states (saved.leftCollapsed, rightCollapsed, centerCollapsed)
- XPath expression (saved.xpathExpr)
- Last example key (saved.lastExampleKey)
↓
On user edit:
Any change to XML, XSLT, headers, properties, or mode
↓
scheduleSave() [state.js]
↓
(debounced for 800ms)
↓
saveState() [state.js] → serialize to localStorage[xdebugx-session-v1]
↓
Persisted fields: xmlXslt, xmlXpath, xslt, headers, properties,
leftCollapsed, rightCollapsed, centerCollapsed, xpathExpr,
xpathEnabled, lastExampleKey, savedAt.
(Note: rendered output is NOT persisted — it's recomputed on demand.)
↓
Complete
All mutable state lives in global variables (no module system, no class-based state management).
// Three Monaco editor instances
let eds = { xml: null, xslt: null, out: null };
// Two XML models (for XSLT vs XPath mode isolation)
let xmlModelXslt = null; // Active in XSLT mode
let xmlModelXpath = null; // Active in XPath mode
// Active mode flag
let xpathEnabled = false; // XSLT mode by default
// CPI simulation data
let kvData = { headers: [], properties: [] };
let kvIdSeq = 0; // For generating unique IDs// Word wrap state per editor
let _wrapState = { xml: false, xslt: false, out: false };
// XPath history and browsing state
let _xpathHistory = []; // Most-recent-first
let _xpathHistoryCursor = -1; // -1 = not browsing
let _xpathDraftExpr = ''; // Saves text while browsing history
// Decorations/highlights
let xpathDecorations = null; // Monaco decoration collection (xpath.js)
// Note: xsltDecorations / xmlDecorations live in validate.js for error markers.
// XPath results state
let _showXPathGen = 0; // Generation counter
let _lastXPathRenderArgs = null; // For re-colorize on theme switch// clog() appends a <div class="log-line"> directly to #consoleBody — no
// in-memory message buffer is kept. Filtering reads from the DOM.
let consoleErrCount = 0; // declared in ui.js; counts both errors and warnings
// Console filters and search state managed by setConsoleFilter(), applyConsoleSearch()let xsltDebounce = null; // Debounce XSLT validation
let xmlDebounce = null; // Debounce XML validation
let _saveTimer = null; // Debounce session save (in state.js)
// Both validation and save use 800ms inline literals (no shared constant).let saxonReady = false; // Set to true after window.SaxonJS loads<body class="light|dark">
<div class="app-header">
<!-- Logo, mode toggle (XSLT|XPath), Run button, Examples, etc. -->
</div>
<div class="workspace">
<!-- Three-column layout: colLeft | colCenter | colRight -->
<div id="colLeft" class="col">
<!-- XML Input Pane + Toolbar -->
<div id="xmlWrap" class="editor-wrap">
<div id="xmlEd" class="monaco-editor"></div>
</div>
</div>
<div id="colCenter" class="col [collapsed]">
<!-- XSLT Pane + Console -->
<div id="xsltWrap" class="editor-wrap">
<div id="xsltEd" class="monaco-editor"></div>
</div>
<div id="consolePanel">
<!-- Console with search, filters, minimize -->
</div>
</div>
<div id="colRight" class="col [collapsed]">
<!-- Output Pane + Headers + Properties -->
<div id="outputSection">
<div id="outWrap" class="editor-wrap">
<div id="outEd" class="monaco-editor"></div>
</div>
<div id="hdrPanel" class="kv-panel"><!-- Headers --></div>
<div id="propPanel" class="kv-panel"><!-- Properties --></div>
</div>
<div id="xpathResultsPanel" class="[visible]">
<!-- XPath results with syntax highlighting -->
</div>
</div>
</div>
<div id="xpathBar" class="xpath-bar [hidden]">
<!-- XPath input, hints, buttons -->
</div>
<div id="consolePanel" class="console-panel">
<!-- Full-width console (in XPath mode) -->
</div>
<div id="statusBar">
<!-- File mode pill, line:col, etc. -->
</div>
</body>Pattern: Use debounce timers to avoid excessive revalidation and persistence.
// XSLT validation — debounce 800ms
editors.xslt.onDidChangeModelContent(() => {
clearTimeout(xsltDebounce);
xsltDebounce = setTimeout(() => {
const src = eds.xslt.getValue();
const result = validateXML(src);
if (!result.ok) markErrorLine(eds.xslt, result.line, result.message);
}, 800);
});
// Session save — debounce 800ms
function scheduleSave() {
clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
saveState();
}, 800);
}Why: Validation on every keystroke would freeze the editor. Debouncing batches changes and improves responsiveness.
Pattern: Two separate Monaco XML models for XSLT vs XPath mode to prevent cross-contamination.
// On startup, create both models
xmlModelXslt = monaco.editor.createModel(xmlContent, 'xml');
xmlModelXpath = monaco.editor.createModel(xmlContent, 'xml');
// Mode switches go through ModeManager — it picks the matching model and
// swaps it on the shared editor (eds.xml).
modeManager.setMode('XPATH');
// Internally:
// const target = this.isXpath ? this.models.xpath : this.models.xslt;
// _suppressNextXmlChange = true; // skip the synthetic change event
// eds.xml.setModel(target);Why: Prevents validation decorations, highlights, and cursor positions from interfering between modes.
Pattern: Rewrite XSLT namespace declarations and function calls to use Saxon-JS's extension function namespace.
// Original XSLT
<xsl:stylesheet xmlns:cpi="http://example.org/cpi">
<xsl:call-template name="set-header">
<xsl:with-param name="h1" select="cpi:setHeader('exchange', 'X-Header', concat('prefix-', $id))"/>
// Rewritten XSLT
<xsl:stylesheet xmlns:js="http://saxonica.com/ns/globalJS" exclude-result-prefixes="js">
<xsl:call-template name="set-header">
<xsl:with-param name="h1" select="js:cpiSetHeader('exchange', 'X-Header', concat('prefix-', $id))"/>
// JavaScript interceptor
window.cpiSetHeader = (exchange, name, value) => {
cpiCaptured.headers[name] = value;
return '';
};Why: Allows Saxon to evaluate complex XPath expressions (including concat(), variables, functions) before calling the interceptor with computed values. No regex extraction, 100% fidelity.
Pattern: Use flags to skip validation after programmatic edits that shouldn't trigger error checking.
// Inside ModeManager.setup() before swapping the XML model:
_suppressNextXmlChange = true; // arm the flag
eds.xml.setModel(targetModel); // synthetic onDidChangeModelContent fires
// editor.js's listener checks _suppressNextXmlChange, skips validation,
// and resets it back to false — do NOT clear it here on the same line.Why: Prevents stale error markers after loading examples or switching modes.
Pattern: Each async operation (XPath evaluation, colorization) increments a generation counter. Results check the counter before rendering.
let _showXPathGen = 0;
async function _showXPathResults(items, errorMsg, isError) {
const gen = ++_showXPathGen; // Capture this run's generation
// ... async work (awaiting monaco.editor.colorize())
if (gen !== _showXPathGen) return; // A faster run has started, bail
// ... render results
}Why: If a user runs a second XPath faster than the first completes, the slow first run won't overwrite the new results.
The order in ../../index.html is critical:
<!-- 1. CSS first -->
<link rel="stylesheet" href="css/style.css">
<!-- 2. Vendor libs (Monaco, Saxon-JS, pako) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.44.0/..."></script>
<script src="lib/SaxonJS2.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/..."></script> <!-- used by share.js -->
<!-- 3. JS modules in dependency order -->
<script src="js/state.js"></script> <!-- Global state, clog -->
<script src="js/mode-manager.js"></script> <!-- Mode management (XSLT vs XPath) -->
<script src="js/validate.js"></script> <!-- Validation, uses state -->
<script src="js/panes.js"></script> <!-- UI helpers, uses state -->
<script src="js/transform.js"></script> <!-- Transform logic, uses state, validate -->
<script src="js/examples-data.js"></script> <!-- Example definitions -->
<script src="js/modal.js"></script> <!-- Examples modal, uses examples-data -->
<script src="js/files.js"></script> <!-- File I/O, uses state -->
<script src="js/ui.js"></script> <!-- UI state, uses state -->
<script src="js/share.js"></script> <!-- URL sharing, uses state, transform -->
<script src="js/xpath.js"></script> <!-- XPath mode, uses validate, mode-manager -->
<script src="js/themes.js"></script> <!-- Monaco theme definitions -->
<script src="js/editor.js"></script> <!-- Monaco setup, uses all above (last) -->Initialization sequence:
- Browser parses HTML → loads CSS
- Vendor libs load asynchronously (Monaco, Saxon-JS)
- Module scripts execute in order
- state.js → Initializes global state, sets up
_saveTimer - mode-manager.js → Registers mode switching logic
- validate.js → Provides XML validation functions
- transform.js → Registers transform button handler
- ... (remaining modules register event handlers, await their dependencies)
- editor.js (last) → Initializes Monaco editors, restores session, connects all handlers
- All modules loaded →
saxonReadyflag triggers when Saxon-JS loaded - On first user interaction → Transform or XPath can run
// Check these before allowing user actions:
if (!saxonReady) {
clog('Saxon-JS not ready yet', 'error');
return;
}
if (!eds.xml || !eds.xslt || !eds.out) {
clog('Editors not initialized', 'error');
return;
}Public APIs (used across modules):
eds— Editor instancesxmlModelXslt,xmlModelXpath— Monaco XML modelskvData— Headers/properties datasaxonReady— Saxon-JS ready flagclog()— Console logging function- Functions run by user:
runTransform(),runXPath(),toggleXPath(), etc.
Private to module (prefixed with _):
_wrapState— word wrap state_xpathHistory— expression history_highlightXPath()— internal highlighting_suppressNextXmlChange— internal flag
Note: rewriteCPICalls() (in transform.js) is public — no leading underscore — even though it's only called from within transform.js.
With no module system, all variables are global. The _ prefix signals "this is internal; don't call from other modules". Helps prevent namespace collisions and makes intentions clear.
// ❌ DON'T: Unprefixed global function (collides with DOM API or other libs)
function format(x) { ... }
// ✅ DO: Prefix with module identifier
function _panes_format(x) { ... }
// Or use the convention in panes.js:
function prettyXML(x) { ... } // Public to other modules; no underscore
// ❌ DON'T: Create new global without tracking
let someState = 'foo';
// ✅ DO: Initialize in state.js or the module that owns it
// Add to a clear initialization section with a comment
let _myModuleState = { foo: 'bar' }; // In my-module.js, prefixedThese patterns prevent the most common bugs. See also CLAUDE.md Critical Constraints section.
Public API functions: unprefixed (runTransform(), rewriteCPICalls()). Private/internal: _ prefix (_highlightXPath()). Never add an unprefixed function without checking state.js for conflicts.
Always check modeManager.isXpath before reading/writing XML — the two modes use separate models (xmlModelXslt / xmlModelXpath). Using the wrong model corrupts content and decorations.
// ✅ Correct
const model = modeManager.isXpath ? xmlModelXpath : xmlModelXslt;
return model.getValue();Always guard Saxon calls with if (!saxonReady). Saxon loads async from CDN — buttons render before it's ready.
rewriteCPICalls() in transform.js shifts error line numbers. Don't modify it without running the full CPI test suite (npx playwright test --grep "CPI").
Validation is debounced at 800ms. When making programmatic editor changes, set _suppressNextValidation = true first to avoid spurious error markers.
URLs cap at ~2,000 chars. Large XSLT + XML silently fail to encode. No warning is shown — users discover on load.
Storage key is xdebugx-session-v1. If you change the saved-state schema, bump to v2 — old sessions are silently ignored, users get a clean state.
Mode switches trigger UI animations (~1.5s). XPath-specific DOM elements don't exist in XSLT mode. Wait for #xpathInput before interacting:
await modeManager.setMode('XPATH');
await page.waitForSelector('#xpathInput'); // in tests- No new unprefixed globals — check
state.js - Saxon calls guarded with
saxonReady - Programmatic editor changes use
_suppressNextValidation - localStorage schema unchanged, or version bumped
-
rewriteCPICalls()untouched, or CPI tests run
- ../../CONTRIBUTING.md — Code style, testing, PR process
- reference/features.md — Complete 200+ feature catalog and API reference
- TRANSFORM.md — CPI simulation deep dive, error mapping
- ../../README.md — User-facing features, getting started, keyboard shortcuts