Development
How to set up a working copy of @tradik/xslt-processor, run the tests, lint
and build the bundles (locally, with the Makefile or in Docker) and publish a
release to npm. Contribution guidelines are in
CONTRIBUTORS.md.
Prerequisites
- Node.js 22.8+ (native test runner with coverage thresholds; CI runs 22, 24 and 26).
npm testfails below 100% line and function coverage or 96% branch coverage. - Docker (optional, for containerized testing)
Setup
git clone https://github.com/spagu/XSLT-Processor.git
cd XSLT-Processor
npm install
Commands
# Run tests
npm test
# Run tests with watch mode
npm run test:watch
# Build bundles
npm run build
# Lint code
npm run lint
# Format code / check formatting
npm run format
npm run format:check
The Makefile wraps the same commands (make test, make build, make lint,
make docker-test, ...; make help lists them).
Repository layout
The repository is an npm workspace. The root is the published
@tradik/xslt-processor (XSLT 1.0; src/, bin/), and packages/ holds
further packages, built and tested on their own:
| Directory | Package | Tests |
|---|---|---|
. | @tradik/xslt-processor | npm test |
packages/xslt3 | @tradik/xslt3 (XSLT 3.0 / XPath 3.1, design) | npm run test:xslt3 |
packages/migrate-check | xslt-migrate-check (npx xslt-migrate-check .: finds the XSLT a project depends on in the browser and prints the risk and the one-line migration, reference) | npm run test:migrate-check |
npm ci at the root installs every package. A workspace never becomes a
dependency of @tradik/xslt-processor: its tarball holds only dist/,
src/ and bin/.
Continuous integration
Each workflow runs only when the files it checks change (paths filters on
push and pull request), so a website or documentation change does not run the
library's tests, and a library change does not redeploy the site unless the
site is built from it:
| Workflow | Runs when these change |
|---|---|
test.yml (lint, format, unit tests of every package, DOM matrix, package managers, build) | src/, bin/, packages/, scripts/, tests/, package files, ESLint/Prettier config |
browser.yml (Playwright: Chromium, Firefox, WebKit, Chromium without XSLT) | the same paths |
conformance.yml (libxslt corpus) | src/, the conformance runner and its tests, package files |
site.yml (site tests, docs links, build, deploy from main) | site/, docs/, README.md, CHANGELOG.md, and the code the site is built from (src/, packages/xslt3/src/, packages/migrate-check/, scripts/build.js); also by hand (workflow_dispatch) |
release.yml | a published GitHub release only |
Each workflow also runs when its own file changes. SonarCloud is a GitHub App, not a workflow, and analyses every pull request.
Docker
# Run tests in container
docker compose run --rm test
# Development with tests in watch mode
docker compose run --rm dev
# Build bundles into ./dist
docker compose run --rm build
Publishing to npm
The Release workflow runs when a GitHub release is published (not on
a tag push alone): it tests, builds the bundles and the standalone binaries,
attaches them to the release and publishes to npm. Publishing uses npm
Trusted Publishing (OIDC): no token secret and no OTP are involved, and
every release carries provenance attestations. Tokens are not an option:
npm stops accepting 2FA-bypass granular tokens for publishing around January
2027 (GitHub changelog),
and a token that enforces 2FA fails in CI with EOTP.
One-time prerequisite (npmjs.com -> package @tradik/xslt-processor ->
Settings -> Trusted Publisher): provider GitHub Actions, owner spagu,
repository XSLT-Processor, workflow release.yml, environment left empty.
Until that is configured the publish job fails with E404.
Publishing by hand (scripts/publish.sh): check out the release tag and
run make publish with the one-time password from your npm authenticator:
git checkout v1.3.0
npm login # once
make publish-dry # every check, then npm publish --dry-run
make publish # the same checks, then asks for the one-time password
xslt-migrate-check is released from the same tag too: make publish-migrate-check-dry
and make publish-migrate-check (npm run test:migrate-check runs its tests).
@tradik/xslt3 is released from the same tag: make publish-xslt3-dry and
make publish-xslt3 run its tests and both W3C suites instead.
Its first version has to be published this way; afterwards add a Trusted
Publisher for @tradik/xslt3 on npmjs.com (same settings as above), and the
Release workflow publishes both packages, skipping a version npm already has.
It refuses unless the working tree is clean, HEAD is the tag v<version> of
package.json, that version is not on npm yet and you are logged in; then it
installs from the lockfile, builds, runs the tests and the libxslt
conformance suite, and asks for the one-time password only then (a code is valid for about 30
seconds, the checks take minutes); OTP=123456 skips the question. Press Enter without a
code to publish with npm's approval in the browser instead (two-factor authentication with a
passkey or security key, or an authenticator out of reach). A manual release has no provenance
attestation (only CI can sign one).
Release process:
# 1. Bump the version in package.json, package-lock.json, src/index.js and
# date the CHANGELOG.md section, then merge to main (a release PR).
# 2. Tag main and push the tag.
git tag v1.3.1
git push origin v1.3.1
# 3. Publish the GitHub release; this is what starts the Release workflow,
# which refuses to publish if the tag does not match package.json. The
# notes are the CHANGELOG section of the version.
gh release create v1.3.1 --title "v1.3.1 — @tradik/xslt-processor 1.3.1" --notes-file notes.md
# 4. When the workflow's publish job fails on the npm one-time password,
# publish by hand from the tag (see above).
git checkout v1.3.1 && make publish
Automated workflow (.github/workflows/release.yml):
test: lint, formatting check and tests on Node.js 22, 24 and 26build: builds the distribution bundles and verifies the package contents (npm pack --dry-run)build: checks that the tag matches thepackage.jsonversionbuild: uploads thedist/build artifacts to GitHubpublish(tags only):npm publish --provenance --access public
Browser tests
The built bundles are tested in real browsers with Playwright (Chromium, Firefox, WebKit):
npx playwright install --with-deps chromium firefox webkit # once
npm run build && npm run test:browser # or: make test-browser
npm run test:browser -- --project=chromium # one engine
The tests cover the ESM and IIFE/CDN bundles, installGlobal(),
transformToFragment into the page document (real HTMLElements),
transformToDocument, transformToString, parameters and the stylesheet and
document loaders. Where the browser still ships a native XSLTProcessor, a
differential test compares its output with this library's and prints a summary
table; it is informational and never fails the run. BROWSER_DIFF_CORPUS=1
adds the libxslt conformance corpus (npm run conformance:fetch), and
BROWSER_DIFF_OUT=<dir> writes the differences as JSON.
Website
The project website (documentation, changelog and playground) lives in
site/ and is built with spagu/ssg. Pages are
generated from README.md, CHANGELOG.md and docs/*.md, so documentation is
edited in those files only.
make site # build into site/public and check links, meta tags and contrast
make site SSG=/path/to/ssg # use a specific ssg binary
make site-serve # preview on http://localhost:8888 with rebuilds
make site-test # unit tests of the content generator and checks
make site builds site/public for the published address,
https://xslt-processor.tradik.com/, whose canonical URLs, sitemap and Open
Graph tags point there. For a local preview use make site-serve, which builds
site/public-local for http://localhost:8888/ from site/ssg.local.yaml; if
port 8888 is taken, ssg serves on the next free port and prints it.
The site is hosted on Cloudflare Pages, project xslt-processor
(wrangler.toml). .github/workflows/site.yml builds and checks it on every
pull request and deploys it on every push to main; it needs the repository
secrets CLOUDFLARE_API_TOKEN (permission Account > Cloudflare Pages > Edit)
and CLOUDFLARE_ACCOUNT_ID. make site-deploy deploys by hand after
npx wrangler login. The first deployment creates the Pages project in the account of
CLOUDFLARE_ACCOUNT_ID if it does not exist yet. The domain is attached to the project in the Cloudflare
dashboard (Workers & Pages > xslt-processor > Custom domains).
The old address, https://spagu.github.io/XSLT-Processor/, keeps working: the
same workflow publishes site/redirect/index.html to GitHub Pages as
index.html and 404.html, which sends every old URL to the same path on
the new domain (Settings > Pages > Source stays "GitHub Actions").
The blog lives in site/posts/: one Markdown file per article, with
frontmatter title, description (also the summary in the list and the
feeds), slug, status: publish, type: post and date (ISO 8601; posts on
the same day are ordered by time). build-content.mjs copies the posts into
the ssg content, ssg publishes them at /blog/<slug>/, lists them on /blog/
(site/pages/blog.md, layout layouts/blog.html) and writes the feeds
/blog/rss.xml (RSS 2.0) and /blog/feed.xml (Atom). Charts for articles go
in site/templates/xslt-site/images/blog/ as SVG with light and dark colours,
referenced relative to the post (../../images/blog/chart.svg). Don't add
tags: to posts: the theme has no tag archive template.
Every post gets a "Listen" player (post.html, js/listen.js and
js/listen-blocks.js) that reads it aloud with the browser's speech synthesis.
Mark an element data-listen-skip to leave it out; give images an alt
text, which is what the player says for them.
References (/references/ and the home page's "Used by" band) come from
site/references/references.json: a project is published only with
approved: true, which means it agreed to be named. The npm downloads and
GitHub stars are fetched during the build; SITE_OFFLINE=1 make site uses the
last fetch (site/data/stats.json) or the committed site/references/stats.json.
The playground has three modes, kept in the address (?mode=xslt3,
?mode=xpath; XSLT 1.0 has no parameter) and remembered in localStorage.
XSLT 1.0 uses dist/xslt-processor.browser.min.js (copied to
site/static/vendor/, so run npm run build first). XSLT 3.0 and XPath 3.1
use @tradik/xslt3, which site/scripts/vendor.mjs bundles with esbuild from
packages/xslt3/src/ into site/static/vendor/xslt3.browser.min.js (one
minified ES module for both modes; npm run site:content prints its size).
The page loads it with import() the first time one of those two modes runs
(playground-library.js). XSLT 1.0 and 3.0 share the XML, stylesheet and
parameter editors (playground-stylesheet.js), and a link runs the current
stylesheet with the other engine. The mode logic lives in
site/templates/xslt-site/js/: playground-modes.js, xslt3-core.js (compile,
transform and serialize with xsl:output, xsl:result-document outputs,
xsl:message, errors with their code and a line guess), xslt3-presets*.js,
xpath-core.js, xpath-items.js and xpath-presets.js. Tests:
site/scripts/xslt3.test.mjs, xpath.test.mjs and vendor.test.mjs run
every example under jsdom; playground-ui.test.mjs covers the shared page
helpers. A new XSLT 3.0 example goes in one of the xslt3-presets*.js files
with a test of its output.
Cookie consent comes from ssg's cookie-consent worker in
site/workers/cookie-consent/ (scaffolded with ssg new worker cookie-consent; see its README). variables.cookie_consent in
site/ssg.yaml configures the banner; the theme writes it into every page
with /cookie-consent.js and .css. The banner opens by itself in the EEA
and the UK: the Pages Function /api/consent/geo answers from the visitor's
country (without the Function, as in a local preview, it always opens). Google
Analytics starts in Consent Mode v2 with storage denied. The policy page is site/pages/cookie-policy.md; list any new cookie
there. ssg copies the worker's functions/ into site/public, so the site is
deployed from that directory (make site-deploy, and the Site workflow's
workingDirectory), where wrangler builds the Functions.
Google Analytics 4 runs on every page with the measurement id in
variables.ga_id (site/ssg.yaml); the redirect page in site/redirect/
has the same id written in. It is the site's only tracking: there is no Google
Tag Manager, and check-site.mjs fails a page that lacks the Google Analytics
snippet or carries a Tag Manager one.
DOM matrix
npm run test:dom (or make test-dom) runs the test suites, the CLI tests and
the conformance suite with jsdom and with @xmldom/xmldom.
DOM=xmldom node --test <files> runs any suite built on
src/domEnvironment.test.js with xmldom; mark jsdom-only tests with
jsdomOnly("feature").
Package managers
npm run test:package-managers (after npm run build) packs
@tradik/xslt-processor, @tradik/xslt3 and xslt-migrate-check and
installs the tarballs in a fresh temporary project with npm, Yarn 1, Yarn 4
(Plug'n'Play), pnpm and bun. In each it runs the smoke tests of
tests/package-managers/ (CommonJS, ES modules with /polyfill,
xsltVersion: "auto" without @tradik/xslt3, where the "install
@tradik/xslt3" message must appear, and with it) and the xslt and
xslt-migrate-check bins through the manager's exec, then prints a table
with one ✓/✗ per check and the output of each failure.
--manager <name> (repeatable: npm, yarn-classic, yarn-berry-pnp,
pnpm, bun) picks the managers. Yarn and pnpm run at the versions pinned
in scripts/package-managers/managers.mjs through npx --yes (Node.js 25
no longer bundles corepack); bun comes from ~/.bun/bin or the PATH. A
manager that cannot start is "not available", which fails the run only with
--require; the package-managers job of test.yml passes it, with bun
pinned by oven-sh/setup-bun. Bump the pins there and in managers.mjs
together.
Benchmarks
npm run bench (about 6 minutes) measures 1.1.3 against the working tree and writes scripts/benchmark/results.json; node scripts/benchmark/charts.mjs redraws docs/benchmarks/*.svg and the tables in BENCHMARKS.md.
npm run bench -- --suite xpath and npm run bench -- --suite xslt run the XPath 1.0 vs 3.1 and the XSLT 1.0 package vs @tradik/xslt3 benchmarks (--suite release, the default, is the one above); node scripts/benchmark/charts.mjs --suite xpath or --suite xslt redraws their charts and sections. The release and xslt suites also refresh the "All versions" overview at the top of the page (every version on the shared scenarios, from both result files); --suite overview refreshes only that. npm run test:bench runs the unit tests of the benchmark helpers.